Creando Imágenes de Contenedor basadas en Workspaces y Monorepos
Introducción
Cuando desarrollamos soluciones complejas, es muy común acabar con varios repositorios: uno para el frontend, otro para el backend, otro para una librería común… y así sucesivamente. Al principio puede parecer manejable, pero a medida que el proyecto crece, mantener todo sincronizado se vuelve un dolor de cabeza: versiones desalineadas, configuraciones duplicadas, pasos manuales para compartir código...
Ahí es donde entra el concepto de monorepo: un único repositorio donde conviven todos los proyectos relacionados. Esto nos permite centralizar el desarrollo, sincronizar dependencias y compartir código fácilmente.
Una forma sencilla de trabajar con monorepos en el ecosistema de JavaScript es usando NPM Workspaces . Gracias a esta funcionalidad, podemos estructurar paquetes de forma flexible, por ejemplo como apps o librerías, dentro del mismo repo y compartirlos entre sí sin necesidad de herramientas como npm link
Un ejemplo de que pinta podría tener la estructura de un monorepo:
monorepo-root/
├─ apps/
│ ├─ app-1/
│ │ ├─ ...
│ │ ├─ package.json
│ ├─ app-2/
│ │ ├─ ...
│ │ ├─ package.json
├─ .gitignore
├─ package-lock.json
├─ package.json
├─ README.md
Ahora bien, a la hora de construir imágenes de las aplicaciones embebidas dentro de esta estructura, podemos encontrarnos situaciones que no esperábamos. En este artículo vamos a crear un monorepo básico y enfrentarnos a la construcción de una imagen de contenedor, que contenga dependencias con otros proyectos del monorepo.
Creando la solución con NPM Workspaces
Construyamos un monorepo, paso a paso, utilizando NPM Workspaces. El objetivo será tener una estructura sencilla con una aplicación (api) y una librería (utils) que esta aplicación consume.
1. Crear el directorio raíz y el package.json principal
Primero, crea una carpeta para tu monorepo y genera un package.json en la raíz:
mkdir monorepo-solution && cd monorepo-solution
npm init -y
Dentro del archivo package.json, define la propiedad workspaces. Esta sección le dice a NPM qué carpetas deben tratarse como paquetes independientes gestionados desde el raíz:
{
"name": "monorepo-solution",
....
+ "workspaces": [
+ "apps/*",
+ "libs/*"
+ ],
....
}
2. Crear la estructura de carpetas
Ahora podemos crear las carpetas, que van a contener las aplicaciones y librerías:
mkdir -p apps/api
mkdir -p libs/utils
3. Crear la librería utils
Inicializamos el paquete utils:
npm init -w ./libs/utils -y
Creamos libs/utils/index.js, una simple función que devuelve la zona horaria:
module.exports = {
getTimeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
};
4. Crear la aplicación api que consume la librería
Inicializamos el paquete api:
npm init -w ./apps/api -y
Instalamos las dependencias de terceros que la aplicación consumirá (en este caso, Express):
npm install express -w ./apps/api
Instalamos la librería local desde el workspace raíz:
npm i -w ./apps/api ./libs/utils
Si ahora abrimos ./apps/api/package.json, veremos que la librería se ha añadido como dependencia:
{
"name": "api",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"express": "^5.1.0",
"utils": "file:../../libs/utils"
}
}
5. Crear la aplicación Express
Creamos ./apps/api/index.js
const utils = require("utils");
const express = require("express");
const app = express();
const port = 3000;
app.get("/api/tz", (_, res) => {
res.send({
serverTZ: utils.getTimeZone(),
});
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Con esto ya tenemos una aplicación que utiliza una librería local, todo gestionado desde un único monorepo con NPM Workspaces.
A partir de aquí, el siguiente paso más que probable será, construir una imagen de contenedor de la aplicación, para que sea consumida por algún tipo de orquestador (K8s, Nomad, Docker Compose...)
Creando la Imagen de Contenedor
Ahora que tenemos nuestra aplicación funcionando dentro del monorepo, el siguiente paso es crear una imagen Docker.
Vamos a empezar con un enfoque básico, conteniendo sólo la carpeta api, como si fuera un proyecto independiente. Esto nos servirá para entender las limitaciones y por qué necesitamos tener en cuenta el contexto del monorepo completo.
1. Creando Imagen sencilla, primer intento
Creamos apps/api/Dockerfile con el siguiente contenido:
FROM node:22-alpine
WORKDIR /opt/app
COPY ./index.js ./index.js
COPY ./package.json ./package.json
RUN npm install
CMD ["node", "index.js"]
En el manifiesto de la imagen, copiamos el fichero index.js y el package.json del proyecto api, instala las dependencias y arranca el proceso principal.
Construimos la imagen:
cd apps/api
docker build -t lemon/api .
docker run -d -p 3000:3000 lemon/api
2. Falla... ¿y qué ha fallado?
Si buscamos el contenedor veremos que está 'exited', el proceso principal, lanza una excepción no controlada haciendo que el contenedor se pare.
El error es claro: no encuentra el módulo utils. Esto tiene sentido, porque aunque nuestra app lo usa, el Dockerfile actual no está copiando la librería —ni tampoco el node_modules completo del monorepo.
Recordemos que estamos usando NPM Workspaces, lo que significa que las dependencias están gestionadas desde la raíz del proyecto, y no funcionan si tratamos las aplicaciones como si fueran independientes.
Creando la Imagen de Contenedor de la API correctamente (con Workspaces)
Para solucionar esto, necesitamos construir la imagen desde la raíz del monorepo, asegurándonos de incluir todas las piezas necesarias: tanto la aplicación como sus dependencias.
1. Preparar estructura para Docker
Creamos, al nivel del proyecto raíz, una nueva carpeta, docker/ y movemos ./apps/api/Dockerfile a esta carpeta.
mkdir docker
mv apps/api/Dockerfile docker/Dockerfile
Actualizamos el Dockerfile para que tenga en cuenta la estructura de nuestro monorepo:
FROM node:22-alpine
WORKDIR /opt/app
+
+COPY ./libs/utils ./libs/utils
+
+COPY ./apps/api ./libs/api
+
- COPY ./index.js ./index.js
- COPY ./package.json ./package.json
COPY ./package.json ./package.json
+
+RUN npm install -w ./apps/api
+
- CMD ["node", "index.js"]
+CMD ["node", "./apps/api/index.js"]
2. Construir desde la raíz
Nos aseguramos de estar en el directorio raíz del proyecto (donde está el package.json con los workspaces) y construimos la imagen usando el nuevo Dockerfile:
docker build -t lemon/api -f docker/Dockerfile .
3. Ejecutar y comprobar que funciona
Arrancamos el contenedor:
docker run -d -p 3000:3000 lemon/api
Y probamos que esté respondiendo correctamente:
curl http://localhost:3000/api/tz
Con esto ya tenemos una imagen Docker funcional para nuestra aplicación dentro de un monorepo, aprovechando las funcionalidades de los NPM Workspaces.
Wrap Up
En este artículo hemos visto cómo construir una imagen en el contexto de un monorepo construido con NPM Workspace, cuando, los paquetes tienen interdependencias con otros paquetes del monorepo.
Este enfoque es especialmente útil cuando los paquetes aún no están publicados en un registry, como puede ser NPM o un registro privado. En estos casos, construir desde el contexto completo del monorepo nos permite mantener la coherencia entre dependencias sin complicaciones adicionales.
Dicho esto, si tus paquetes ya están publicados, puedes simplificar la imagen Docker instalándolos directamente desde el registro. Solo asegúrate de configurar correctamente el archivo .npmrc si estás usando un registro privado.
Esta solución es ideal en etapas de desarrollo o en entornos de pruebas, donde queremos iterar rápido y validar cambios sin tener que versionar y publicar cada paquete constantemente.
Espero que este artículo te haya sido útil. Seguiremos compartiendo más contenido técnico como este.
Happy coding 😝, and stay tuned!