El pipeline de Reservalia ya ejecuta las pruebas en cada pull request, pero todavía no construye nada: trabaja sobre el código fuente y ahí termina. Falta la pieza que convierte un repositorio en algo ejecutable. En esta lección veremos qué significa exactamente "construir", por qué debe ser un único comando reproducible y no una secuencia de pasos que alguien recuerda, y cómo se orquesta la construcción de un monorepo donde un paquete depende de otro. Después atacaremos el concepto central de todo el módulo —la reproducibilidad: por qué npm ci no es lo mismo que npm install, qué papel juega el lockfile y qué cosas hacen que una build deje de ser reproducible—. Terminaremos empaquetando apps/api en un Dockerfile multi-etapa explicado etapa a etapa, y añadiendo el job build al ci.yml. Lo que no haremos aquí es publicar ni versionar ese artefacto: eso es la lección 02-06.

Contenido

  1. Qué es realmente "construir"
  2. La build de Reservalia, paquete a paquete
  3. Reproducibilidad: el lockfile y npm ci
  4. Qué hace que una build no sea reproducible
  5. Empaquetar con Docker: el Dockerfile multi-etapa
  6. .dockerignore: lo que no debe entrar
  7. Cachés de construcción y el peligro de invalidarlas mal
  8. El job build en el ci.yml de Reservalia
  9. Errores Comunes y Consejos
  10. Ejercicios
  11. Conclusión

  1. Qué es realmente "construir"

Construir es transformar el código fuente en el artefacto que se ejecutará, sin intervención humana. Según la tecnología, esa transformación incluye compilar, transpilar, empaquetar, minimizar, generar código o crear una imagen de contenedor.

Lo que define una buena build no es lo que hace, sino cuatro propiedades:

Propiedad Qué significa Cómo se comprueba
Un solo comando npm run build y nada más ¿Alguien tiene que ejecutar algo "antes"?
Reproducible El mismo código produce el mismo resultado Construir dos veces y comparar
Sin estado No depende de restos de builds anteriores rm -rf node_modules dist && npm ci && npm run build
Autoverificable Falla ruidosamente, con código de salida ≠ 0 Romper algo a propósito y ver si se pone en rojo

La primera es la que más se incumple. Reservalia parte de un caso real: Diego construye la API ejecutando npm install && npm run build, pero antes copia a mano un fichero .env que solo está en su portátil, y si ha tocado packages/tipos-compartidos tiene que construir ese paquete primero y acordarse de hacerlo. Eso no es un comando: es un procedimiento oral. Y un procedimiento oral no se puede automatizar hasta que alguien lo escribe.

La regla práctica. Si necesitas explicarle a alguien cómo construir el proyecto con más de una frase, el paso siguiente no es escribir el pipeline: es arreglar el comando de build.

  1. La build de Reservalia, paquete a paquete

El monorepo tiene tres paquetes y una dependencia real entre ellos: tanto la API como la web importan tipos de @reservalia/tipos-compartidos.

flowchart LR
    T["packages/tipos-compartidos<br/>tsc → dist/"] --> A["apps/api<br/>tsc → dist/"]
    T --> W["apps/web<br/>vite build → dist/"]

Esto significa que el orden importa: si apps/api compila antes que tipos-compartidos, TypeScript no encontrará las declaraciones y fallará. Los tres comandos, uno por paquete:

npm run build --workspace packages/tipos-compartidos  # genera JS + ficheros .d.ts
npm run build --workspace apps/api                    # tsc → apps/api/dist/
npm run build --workspace apps/web                    # vite build → apps/web/dist/

Cada uno hace algo distinto:

  • tsc (paquete compartido y API) transpila TypeScript a JavaScript ejecutable por Node y genera los .d.ts. No agrupa ni minimiza: el resultado conserva la estructura de carpetas de src/.
  • vite build (web) hace mucho más: resuelve todos los import, agrupa el código en unos pocos ficheros, elimina lo que nadie usa, minimiza, procesa el CSS y añade un hash al nombre de cada fichero (index-4a7f2b.js) para que el navegador pueda cachearlos indefinidamente.

En la raíz del monorepo, la lección 01-04 ya dejó definido el comando único: "build": "npm run build --workspaces --if-present". La opción --workspaces recorre todos los paquetes y npm resuelve el orden topológico: como apps/api declara "@reservalia/tipos-compartidos": "*" en sus dependencias, npm construye primero el paquete del que se depende. Este es un motivo excelente para declarar bien las dependencias internas: el gestor de paquetes puede deducir el orden y tú no tienes que escribirlo.

En herramientas de otros ecosistemas la idea es idéntica: Maven, Gradle, Nx o Turborepo resuelven el mismo grafo. Lo que no debes hacer nunca es escribir el orden a mano en el YAML del pipeline, porque el día que alguien añada un paquete, el pipeline no se enterará.

  1. Reproducibilidad: el lockfile y npm ci

Este apartado es el corazón de la lección. Reproducible significa: el mismo commit, construido hoy en tu portátil y dentro de seis meses en un runner limpio, produce el mismo artefacto.

3.1. El papel del lockfile

En package.json declaras intenciones ("express": "4.19.2" o, peor, "^4.19.2"). En package-lock.json queda registrado el resultado exacto de resolver esas intenciones: la versión concreta de cada una de las cientos de dependencias transitivas, su URL y su hash de integridad.

"node_modules/express": {
  "version": "4.19.2",
  "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
  "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZ..."
}

El campo integrity es lo que hace que el lockfile sea también un control de seguridad: si el paquete descargado no coincide con ese hash, la instalación falla. Por eso el lockfile se versiona en el repositorio, siempre, y en Reservalia hay uno solo para todo el monorepo.

3.2. npm ci frente a npm install

npm install npm ci
Qué usa package.json, y el lockfile como sugerencia Solo el lockfile
Si hay discrepancia Modifica el lockfile silenciosamente Falla con un error explícito
node_modules previo Lo actualiza de forma incremental Lo borra y lo instala de cero
Determinismo No garantizado Garantizado
Velocidad en CI Menor Mayor (no resuelve el árbol)
Uso correcto En tu portátil, al añadir una dependencia En el pipeline, siempre

El caso que ilustra la diferencia: alguien añade "lodash": "^4.17.20" al package.json y no actualiza el lockfile. Con npm install, el pipeline instala lo que haya y reescribe el lockfile dentro del runner, así que la build pasa; y como el runner es efímero, ese lockfile modificado desaparece sin que nadie se entere. Cada ejecución podría instalar una versión distinta. Con npm ci, el pipeline se detiene con un mensaje claro —npm ci can only install packages when your package.json and package-lock.json are in sync—, y ese error no es una molestia: es el pipeline haciendo exactamente su trabajo.

  1. Qué hace que una build no sea reproducible

Los enemigos habituales, y cómo se neutralizan:

Enemigo Síntoma típico Antídoto
Rangos de versión (^, ~) sin lockfile aplicado "Ayer funcionaba y hoy no, sin tocar nada" Versiones exactas + npm ci
Runtime no fijado Funciona con Node 20 y falla con Node 22 .nvmrc + engines + node-version-file
Imagen base flotante (node:20, postgres:latest) La build cambia cuando el proveedor publica Fijar la versión completa
Ficheros no versionados (.env local, certificados) "En mi máquina funciona" Todo al repositorio o inyectado por variables
Descargas desde internet durante la build Falla cuando la URL cambia o se cae Dependencias declaradas, no descargadas a mano
Restos de builds anteriores Pasa en el segundo intento pero no en el primero Limpiar dist/ antes de construir
Dependencia de la fecha o la máquina El artefacto binario cambia entre ejecuciones Sellos de tiempo fijos si necesitas bit a bit

La prueba definitiva de reproducibilidad cabe en cuatro líneas y merece ejecutarse de vez en cuando:

git clone https://github.com/reservalia/reservalia.git /tmp/limpia && cd /tmp/limpia
git checkout a3f9c21           # el commit exacto
npm ci                         # instalación desde el lockfile
npm run build                  # ¿construye sin ningún paso manual?

Si esto falla, el pipeline fallará. Si funciona, el pipeline será aburrido, que es justo lo que se busca.

  1. Empaquetar con Docker: el Dockerfile multi-etapa

apps/api/dist/ es JavaScript suelto: para ejecutarlo hace falta un Node de la versión correcta y las dependencias de producción. Una imagen de contenedor empaqueta las tres cosas juntas y elimina de un plumazo la diferencia entre entornos.

El problema es que para construir hace falta TypeScript, y para ejecutar no. Meterlo todo en la imagen final da una imagen enorme y con más superficie de ataque de la necesaria. La solución es una construcción multi-etapa.

# apps/api/Dockerfile

# ───────── ETAPA 1: dependencias completas ─────────
FROM node:20.11.0-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json                 apps/api/
COPY packages/tipos-compartidos/package.json packages/tipos-compartidos/
RUN npm ci

# ───────── ETAPA 2: construcción ─────────
FROM node:20.11.0-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build --workspace packages/tipos-compartidos \
 && npm run build --workspace apps/api

# ───────── ETAPA 3: dependencias de producción ─────────
FROM node:20.11.0-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json                 apps/api/
COPY packages/tipos-compartidos/package.json packages/tipos-compartidos/
RUN npm ci --omit=dev

# ───────── ETAPA 4: imagen final ─────────
FROM node:20.11.0-bookworm-slim AS runtime
ENV NODE_ENV=production TZ=Europe/Madrid
WORKDIR /app
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build     --chown=node:node /app/apps/api/dist ./dist
COPY --from=build     --chown=node:node /app/packages/tipos-compartidos/dist ./node_modules/@reservalia/tipos-compartidos/dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Etapa a etapa:

Etapa 1 (deps). Copia solo los package.json y el lockfile, no el código, y ejecuta npm ci. Este orden es deliberado y es el truco de caché más rentable de Docker: mientras no cambien las dependencias, esta capa se reutiliza y te ahorras un npm ci completo en cada build. Si copiaras el código antes, cualquier cambio en un .ts invalidaría la instalación.

Etapa 2 (build). Reutiliza los node_modules de la etapa anterior con COPY --from=deps, copia ya sí todo el código y construye en el orden correcto: primero el paquete compartido, después la API.

Etapa 3 (prod-deps). Vuelve a instalar, pero con --omit=dev: sin TypeScript, sin Vitest, sin ESLint. Reduce mucho el tamaño final y, sobre todo, saca del contenedor de producción decenas de paquetes que nunca deberían estar ahí. Etapa 4 (runtime). La única que acaba en el registro. Solo contiene Node, las dependencias de producción y el dist/ compilado. Cuatro decisiones importantes:

  • node:20.11.0-bookworm-slim: versión completamente fijada —igual que el .nvmrc— y variante slim, mucho más ligera que la imagen por defecto. Nunca node:20 ni node:latest.
  • USER node: el proceso no corre como root. Las imágenes oficiales de Node ya traen ese usuario creado. Es una línea que reduce de forma real el impacto de una vulnerabilidad: si alguien logra ejecutar código dentro del contenedor, lo hace sin privilegios.
  • --chown=node:node en cada COPY: los ficheros pertenecen al usuario que los va a usar, no a root.
  • ENV NODE_ENV=production: muchas librerías, Express entre ellas, cambian su comportamiento con esta variable (menos logs, más caché, mensajes de error sin traza interna).

  1. .dockerignore: lo que no debe entrar

El COPY . . de la etapa 2 copia todo el contexto de construcción. Sin filtro, eso incluye node_modules locales (de otra arquitectura), el historial de git y, en el peor caso, un .env con credenciales. El fichero .dockerignore va en la raíz del repositorio:

node_modules          .git             .env
**/node_modules       .github          .env.*
**/dist               *.log            infra/
**/coverage           docker-compose.yml

Tres motivos, por orden de importancia. Seguridad: un .env copiado a una capa queda dentro de la imagen para siempre aunque una instrucción posterior lo borre, porque las capas son inmutables y cualquiera con la imagen puede leerlas. Corrección: copiar node_modules desde un macOS a una imagen Linux mete binarios nativos incompatibles. Velocidad: un contexto más pequeño se transfiere antes y evita que un fichero irrelevante invalide capas.

  1. Cachés de construcción y el peligro de invalidarlas mal

Hay dos cachés distintas en juego. La caché de dependencias es la que activamos en la 02-02 con cache: npm: guarda las descargas de npm entre ejecuciones, con una clave derivada del hash del package-lock.json; si el lockfile no cambia, la clave coincide y no hay que descargar nada de la red. La caché de capas de Docker funciona por instrucción: cada línea del Dockerfile produce una capa identificada por su contenido, y si la instrucción y sus entradas no han cambiado, Docker la reutiliza. De ahí la regla de oro de ordenar el Dockerfile de lo que menos cambia a lo que más cambia, que es exactamente lo que hace nuestra etapa 1.

Ahora la parte importante: una caché mal invalidada es peor que no tener caché.

Imagina una clave de caché fija, del tipo cache-key: dependencias-api, que no incluye el hash del lockfile. Alguien actualiza express de 4.19.2 a 4.19.3 y el lockfile cambia; pero como la clave es la misma, el pipeline restaura los node_modules viejos. Resultado: el pipeline verifica una versión del código que no existe. Las pruebas pasan, el artefacto se publica y en producción corre otra cosa. Ese fallo puede tardar semanas en aparecer y es endiabladamente difícil de diagnosticar, porque el pipeline dice que todo está bien.

Cuatro reglas para no caer en esto: la clave debe derivarse del contenido de lo que cachea (hash del package-lock.json, nunca un nombre fijo); nunca caches el resultado de la construcción, solo las entradas, porque un dist/ cacheado es un artefacto potencialmente obsoleto; ante la duda, invalida —reconstruir cuesta minutos, publicar el artefacto equivocado cuesta un incidente—; y una caché ausente no debe romper la build: si falta, se reconstruye y ya está.

La optimización avanzada de tiempos —cachés remotas compartidas, construcción solo de lo afectado— es materia de la lección 04-04.

  1. El job build en el ci.yml de Reservalia

Añadimos el segundo job al workflow de la lección anterior:

  build:
    name: Construcción
    runs-on: ubuntu-22.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci

      - name: Construir todos los paquetes
        run: npm run build            # 1

      - name: Comprobar que el artefacto existe
        run: |                        # 2
          test -f apps/api/dist/index.js
          test -d apps/web/dist
          echo "Tamaño de la web: $(du -sh apps/web/dist | cut -f1)"

      - name: Construir la imagen de la API
        uses: docker/build-push-action@v5   # 3
        with:
          context: .
          file: apps/api/Dockerfile
          push: false                       # 4
          tags: reservalia/api:${{ github.sha }}
          cache-from: type=gha              # 5
          cache-to: type=gha,mode=max
  1. npm run build en la raíz: un solo comando construye los tres paquetes en orden. El pipeline no sabe nada del grafo de dependencias, y así debe ser.
  2. La comprobación explícita parece redundante, pero atrapa un fallo real: una configuración mal puesta puede hacer que tsc termine con éxito sin escribir nada. Verificar que el artefacto existe convierte un falso verde en un rojo honesto.
  3. docker/build-push-action construye la imagen usando BuildKit, con soporte de caché entre ejecuciones. context: . es la raíz del monorepo, porque el Dockerfile necesita el lockfile de la raíz.
  4. push: false: en un pull request construimos para comprobar que el Dockerfile sigue funcionando, pero no publicamos nada. La publicación es la lección 02-06.
  5. cache-from/cache-to de tipo gha guardan las capas de Docker en la caché de Actions. mode=max guarda también las capas intermedias, lo que acelera mucho las builds multi-etapa a cambio de más espacio.

Errores Comunes y Consejos

Error 1: npm install en el pipeline. Es el error más extendido y el más silencioso: cada ejecución puede instalar algo distinto y el lockfile modificado desaparece con el runner. En CI, siempre npm ci.

Error 2: copiar el código antes que el package.json en el Dockerfile. Invalida la capa de dependencias en cada cambio de una línea de código y convierte una build de 40 segundos en una de 4 minutos.

Error 3: no tener .dockerignore. El contexto se dispara de tamaño, se cuelan node_modules incompatibles y, en el peor caso, un fichero con credenciales queda dentro de la imagen para siempre. Error 4: ejecutar el contenedor como root, que es el valor por defecto: sin USER node, tu proceso de producción tiene privilegios que no necesita.

Error 5: claves de caché que no dependen del contenido. El fallo que hace que el pipeline verifique una versión del código que no existe. Si alguna vez sospechas de la caché, bórrala antes de seguir investigando.

Consejo 1: construye una vez, prueba muchas. Aunque en este módulo el job build construye para verificar, la meta —lección 02-06— es que exista un solo artefacto por commit y que todo lo demás opere sobre él.

Consejo 2: mide el tamaño de la imagen y ponle un tope. Una imagen que crece de 180 MB a 900 MB casi siempre significa que se han colado dependencias de desarrollo; un docker images en el log basta para detectarlo. Y prueba el Dockerfile en local antes de subirlo: docker build -f apps/api/Dockerfile -t prueba . cuesta un minuto y evita cinco ejecuciones rojas.

Ejercicios

Ejercicio 1

Ordena las siguientes instrucciones de un Dockerfile de una sola etapa para maximizar el aprovechamiento de la caché, y explica el criterio:

COPY . .
RUN npm ci
COPY package.json package-lock.json ./
FROM node:20.11.0-bookworm-slim
RUN npm run build
WORKDIR /app

Ejercicio 2

El pipeline de un equipo usa esta configuración de caché:

      - uses: actions/cache@v4
        with:
          path: node_modules
          key: modulos-node

Describe el fallo concreto que se producirá y por qué es especialmente difícil de diagnosticar. Propón la corrección.

Ejercicio 3

La imagen final de apps/api pesa 1,1 GB. Enumera cuatro causas probables ordenadas por impacto y la corrección de cada una.

Soluciones

Solución 1. Orden correcto:

FROM node:20.11.0-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

El criterio es de lo que menos cambia a lo que más cambia. Los package.json cambian pocas veces por semana; el código, varias veces al día. Al copiar primero solo los manifiestos y ejecutar npm ci inmediatamente después, la capa de dependencias solo se invalida cuando cambian las dependencias de verdad. Con COPY . . antes de npm ci, cualquier cambio en cualquier fichero obligaría a reinstalar todo.

Solución 2. La clave modulos-node es fija: no depende del contenido del lockfile. La primera ejecución guarda los node_modules de ese momento; a partir de ahí, todas las ejecuciones restauran esa misma copia aunque el lockfile cambie. El pipeline probará y construirá con dependencias antiguas.

Es difícil de diagnosticar porque el pipeline está verde: no hay ningún error, simplemente se verifica un código que no es el que se va a desplegar. El síntoma aparece mucho más tarde y en otro sitio —una función que no existe en producción, un fallo que en local no se reproduce—, sin ninguna pista que apunte a la caché.

La corrección es cachear ~/.npm (la caché de descargas, no node_modules) con una clave derivada del contenido: key: npm-${{ hashFiles('package-lock.json') }}. Y en Reservalia, mejor todavía: cache: npm en actions/setup-node, que ya hace exactamente esto.

Solución 3. Por impacto:

  1. Dependencias de desarrollo en la imagen final: TypeScript, Vitest y ESLint pesan cientos de megas. Corrección: npm ci --omit=dev en una etapa aparte y copiar solo esos node_modules, como hace la etapa 3 del ejemplo.
  2. No usar construcción multi-etapa: todo el código fuente, las herramientas de compilación y las capas intermedias quedan en la imagen. Corrección: separar build de runtime y copiar únicamente dist/.
  3. Imagen base pesada: node:20.11.0 completa ronda el gigabyte frente a los ~200 MB de la variante slim. Corrección: usar -slim.
  4. Falta de .dockerignore: se cuelan node_modules locales, .git completo y ficheros de cobertura. Corrección: añadirlo con las entradas del apartado 6.

Conclusión

Reservalia ya construye de forma automática y reproducible:

  • Construir es transformar el código en el artefacto ejecutable con un solo comando, sin estado previo y fallando ruidosamente cuando algo va mal. Si hace falta explicar el procedimiento de palabra, aún no está automatizado.
  • En el monorepo, npm resuelve el orden topológico a partir de las dependencias declaradas: tipos-compartidos antes que api y web. El pipeline no debe conocer ese orden.
  • La reproducibilidad se apoya en tres pilares: el lockfile versionado, npm ci en lugar de npm install y el runtime fijado por .nvmrc. Los enemigos son siempre los mismos: versiones flotantes, ficheros no versionados, descargas durante la build y restos de ejecuciones anteriores.
  • El Dockerfile multi-etapa de apps/api separa instalación, construcción, dependencias de producción y ejecución; la imagen final solo lleva Node fijado a 20.11.0, las dependencias de producción y dist/, corre con USER node y va acompañada de un .dockerignore.
  • Hay dos cachés distintas —dependencias y capas de Docker— y una regla que no se negocia: la clave debe derivarse del contenido. Una caché mal invalidada produce un pipeline verde que verifica código que no existe.
  • El job build ya forma parte del ci.yml: construye los tres paquetes, comprueba que los artefactos existen y construye la imagen sin publicarla.

Tenemos, pues, algo que se construye. Falta responder a la pregunta de si funciona, y responderla con rigor. En la siguiente lección, Pruebas Automatizadas, veremos la pirámide de pruebas aplicada al pipeline —qué se ejecuta en cada PR y qué no—, escribiremos con Vitest una prueba unitaria de la lógica de disponibilidad de citas y una de integración contra el PostgreSQL del services:, hablaremos de cobertura sin convertirla en un objetivo, y le pondremos nombre y política al mayor destructor de confianza en un pipeline: las pruebas inestables.

Curso de CI/CD: Integración y Despliegue Continuo

Módulo 1: Introducción a CI/CD

Módulo 2: Integración Continua (CI)

Módulo 3: Despliegue Continuo (CD)

Módulo 4: Prácticas Avanzadas de CI/CD

Módulo 5: Implementación de CI/CD en Proyectos Reales

Módulo 6: Herramientas y Tecnologías

Módulo 7: Ejercicios Prácticos

Módulo 8: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados