Volver a las notas

Multi-stage builds en Docker: imágenes pequeñas de verdad

17 de octubre de 2026·
dockernodejsdevopscontenedores

Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía un Dockerfile que no compilaba y un ejemplo que no reducía nada.

Una imagen de producción no necesita el compilador de TypeScript, las dependencias de desarrollo ni el código fuente sin compilar. Con un multi-stage build, el Dockerfile tiene varias etapas FROM: en la primera instalas todo y compilas, y en la última copias solo el resultado. Lo que no copias explícitamente no llega a la imagen final.

Un ejemplo donde sí se nota

Una API en Node.js con TypeScript. El código fuente está en src/ y se compila a dist/:

# Etapa 1: dependencias completas y compilación
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

# Etapa 2: solo lo necesario para ejecutar
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Qué hace cada pieza:

  • npm ci instala exactamente lo que dice package-lock.json y falla si no coincide con package.json. Es lo que quieres en un build reproducible. npm install puede actualizar el lockfile por su cuenta.
  • Copiar primero package.json y el lockfile, y después el resto. Docker guarda en caché cada paso. Si solo cambias código, la capa de npm ci se reutiliza y el build tarda segundos.
  • npm prune --omit=dev después de compilar quita de node_modules lo que solo hacía falta para el build (TypeScript, tipos, linters).
  • La etapa final copia tres cosas: package.json, node_modules de producción y dist. Ni src/ ni la caché de npm ni las herramientas de build.
  • USER node: las imágenes oficiales de Node traen ese usuario sin privilegios. Sin esta línea, el proceso corre como root dentro del contenedor.
  • CMD ["node", ...] en vez de npm start: así el proceso que recibe las señales del sistema (como el SIGTERM de docker stop) es Node, no npm. La guía de buenas prácticas de las imágenes oficiales de Node lo recomienda, porque npm se "traga" esas señales. La misma guía avisa de que Node no está pensado para ser el PID 1: arranca el contenedor con docker run --init (o init: true en Compose) para que haya un init mínimo que gestione las señales.

Para ver la diferencia, construye una versión de una sola etapa y compara con docker image ls. Con docker history <imagen> ves cuánto aporta cada capa.

Tres errores que lo estropean

1. Comentarios al final de una línea. En un Dockerfile, solo es comentario una línea que empieza por #. Un # en mitad de la línea se trata como un argumento más:

# Mal: Docker intenta copiar también "#", "Copia", "el" y "código"
COPY --from=build /app/dist ./dist # Copia el código

# Bien: el comentario en su propia línea
# Copia el código compilado
COPY --from=build /app/dist ./dist

2. Repetir el trabajo en la etapa final. Si la última etapa vuelve a hacer npm install desde cero, la primera no sirve para nada: estás haciendo un build de una etapa con pasos de más. La gracia está en copiar el resultado, no en rehacerlo.

3. Sin .dockerignore. COPY . . envía al build todo lo que hay en la carpeta, incluido un node_modules local (compilado para tu sistema operativo, no para Alpine), .git y archivos .env con secretos. Un .dockerignore mínimo:

node_modules
dist
.git
.env*

La versión de la imagen base

Node.js 20 ya no tiene soporte. Para producción, usa una versión LTS (24 o 22) y fíjala en el FROM (node:24-alpine, o una versión concreta como node:24.11-alpine). node:latest cambia de versión mayor sin avisar. La variante -alpine es más pequeña, pero usa musl en lugar de glibc: algunos paquetes con binarios nativos dan problemas. Si te pasa, -slim (basada en Debian) es la alternativa.

Fuentes