Multi-stage builds en Docker | Nicolás Garzón
FROM
Texto
Copiar source + toolchain + dev dependencies
→ build stage
→ artefacto compilado
→ runtime stage mínimoEl objetivo no es solo reducir megabytes. La separación permite evitar que compiladores, credenciales temporales, tests, source y herramientas de desarrollo formen parte de la imagen que se ejecuta en producción.
Muchas aplicaciones requieren más herramientas para construirse que para ejecutarse.
Una API TypeScript puede necesitar:
TypeScript;
dependencias de desarrollo;
linters y test runners;
compiladores nativos;
headers del sistema;
Git o SSH para dependencias privadas.
En runtime quizá solo necesita:
Node.js;
dependencias de producción;
archivos dist;
certificados;
usuario y configuración predeterminada.
Una imagen de una sola etapa tiende a conservar todo:
docker
Copiar FROM node:22
WORKDIR /app
COPY . .
RUN npm ci
RUN npm test
RUN npm run build
CMD ["node", "dist/server.js"]
source y tests terminan en producción;
dependencias de desarrollo aumentan superficie;
toolchains ocupan espacio;
una vulnerabilidad en una herramienta de build aparece en scans del runtime;
el usuario final puede disponer de shells y utilidades innecesarias;
se mezclan fronteras de cache y permisos.
Cada FROM inicia una etapa nueva:
docker
Copiar FROM node:22 AS build
# filesystem A
FROM node:22-slim AS runtime
# filesystem BEl filesystem B no hereda automáticamente el A. Solo recibe lo que se copie explícitamente:
docker
Copiar COPY --from=build /app/dist ./distTexto
Copiar stage build
├── source
├── test
├── node_modules de desarrollo
├── compiler
└── dist ───────────────┐
↓ COPY --from
stage runtime dist
├── Node.js
├── production deps
└── distLas etapas anteriores pueden existir en la cache del builder, pero no forman parte de las capas de la imagen final salvo que su contenido se copie.
docker
Copiar FROM node:22-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm test
RUN npm run build
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]La etapa final es la última por defecto. También puede seleccionarse mediante --target.
docker
Copiar FROM node:22 AS dependencies
FROM dependencies AS test
FROM dependencies AS build
FROM node:22-slim AS runtimeUsar nombres es preferible a índices numéricos:
docker
Copiar COPY --from=build /app/dist ./distdocker
Copiar COPY --from=1 /app/dist ./distLos nombres sobreviven mejor a reordenamientos y expresan responsabilidad.
Una etapa puede partir de otra:
docker
Copiar FROM node:22-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./
FROM base AS development
RUN npm ci
CMD ["npm", "run", "dev"]
FROM base AS production-deps
RUN npm ci --omit=devLa nueva etapa hereda filesystem y metadata de base hasta ese punto. Esto permite compartir configuración sin duplicar texto.
La herencia también crea dependencias de cache: cambiar base afecta a todas las ramas que parten de ella.
docker
Copiar COPY --from=build /app/dist ./dist
una etapa nombrada;
un índice de etapa;
una imagen externa;
un contexto nombrado compatible.
Ejemplo desde una imagen:
docker
Copiar COPY --from=busybox:1.37 /bin/busybox /usr/local/bin/busyboxFija versiones o digests cuando la identidad importe.
Solo archivos y directorios seleccionados. No se transfieren automáticamente:
environment de la etapa fuente;
USER;
CMD;
history completo;
mounts temporales;
secretos;
procesos ejecutados.
El stage final debe declarar su propia metadata.
Para Node.js es común separar dependencias de build y producción.
docker
Copiar FROM node:22-slim AS development-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:22-slim AS production-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=devAunque ambos ejecutan npm, producen árboles con propósitos distintos.
Otra opción es instalar todo, compilar y ejecutar npm prune --omit=dev. Debes verificar que el resultado sea correcto con lifecycle scripts y módulos nativos. Instalar producción de forma independiente suele ser más claro, aunque puede repetir trabajo.
Copiar un binario no garantiza que funcione en el runtime.
arquitectura;
libc, como glibc o musl;
dynamic linker;
shared libraries;
certificados;
zonas horarias;
permisos;
kernel features;
versión del runtime.
docker
Copiar FROM node:22-alpine AS build
RUN npm ci
FROM node:22-slim AS runtime
COPY --from=build /app/node_modules ./node_modulesUn módulo nativo compilado contra musl en Alpine puede fallar sobre una base Debian con glibc, y viceversa.
usa familias de base compatibles entre build y runtime;
recompila dependencias nativas en el runtime target;
construye binarios estáticos cuando sea apropiado;
inspecciona dependencias con herramientas como ldd en un entorno controlado;
prueba en la plataforma exacta.
Una imagen final necesita todo lo que la aplicación usa realmente:
CA certificates para HTTPS;
timezone data si la lógica la requiere;
shared libraries;
fonts para generación de PDFs;
locales;
user y directorios;
healthcheck executable;
archivos de configuración no sensibles predeterminados.
Eliminar una biblioteca para ahorrar espacio y descubrir en producción que un módulo la necesita no es optimización.
La meta es mínimo compatible y operable .
Bash
Copiar docker build --target test -t my-api:test .
docker build --target runtime -t my-api:runtime . Un target permite detener la construcción en una etapa y exportarla como resultado.
imagen de desarrollo con herramientas;
etapa de tests;
debug image;
artefacto final;
benchmark;
builder reutilizable.
No publiques accidentalmente una etapa de build como si fuera producción. Los nombres y pipelines deben distinguir outputs.
BuildKit resuelve el grafo necesario para el target. Una etapa independiente puede no ejecutarse:
docker
Copiar FROM base AS lint
RUN npm run lint
FROM base AS build
RUN npm run build
FROM runtime
COPY --from=build /app/dist ./distConstruir runtime no obliga a ejecutar lint porque no existe una dependencia en el grafo.
CI debe ejecutar explícitamente:
Bash
Copiar docker build --target lint .
docker build --target test .
docker build --target runtime . O diseñar una etapa de validación conectada conscientemente. No insertes dependencias artificiales que copien basura solo para forzar orden.
docker
Copiar FROM development-deps AS test
COPY . .
RUN npm test
tests usan dependencias y filesystem de build;
CI no necesita instalar Node localmente;
resultado puede reutilizar cache;
la etapa final no contiene tests.
tests que requieren database o red necesitan servicios externos;
un RUN npm test no prueba comportamiento real del contenedor en runtime;
tests end-to-end suelen ejecutarse después de construir la imagen final;
etapas no referenciadas deben solicitarse explícitamente.
docker
Copiar FROM development-deps AS development
COPY . .
CMD ["npm", "run", "dev"]Puede usarse desde Compose con bind mounts. No debe confundirse con producción:
contiene dev dependencies;
puede ejecutar hot reload;
suele usar permisos y mounts distintos;
prioriza feedback rápido, no hardening.
Compartir Dockerfile no implica que todas las etapas tengan el mismo nivel de seguridad.
Una imagen distroless o muy mínima puede ser difícil de inspeccionar. Puedes definir:
docker
Copiar FROM runtime AS debug
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl procps \
&& rm -rf /var/lib/apt/lists/*La etapa debug no debe publicarse bajo el tag productivo ni ejecutarse permanentemente. Permite diagnóstico controlado con herramientas adicionales.
Alternativas incluyen debug containers externos, observabilidad y herramientas del host.
Multi-stage reduce exposición únicamente si los outputs copiados están controlados.
Un secret mount no forma parte automáticamente de una capa. Pero un comando puede copiarlo al artefacto:
docker
Copiar RUN --mount=type=secret,id=token \
cp /run/secrets/token /app/dist/tokenLa etapa final copiará el secreto si incluye /app/dist.
Un bundler puede incorporar variables o source maps con información sensible.
Excluir el compilador del runtime no impide que un compilador malicioso altere el binario. Supply-chain controls siguen siendo necesarios.
Multi-stage reduce superficie del artefacto final; no demuestra integridad del proceso de build.
docker
Copiar COPY --from=build --chown=10001:10001 /app/dist /app/distEl ownership de la etapa fuente puede no coincidir con el usuario final. Usa --chown y --chmod cuando estén soportados y sean necesarios.
Bash
Copiar docker run --rm my-api:runtime id
docker run --rm my-api:runtime find /app -maxdepth 2 -printf '%u:%g %m %p\n' No corrijas todos los permisos con chmod -R 777; elimina la protección y oculta un modelo de ownership mal diseñado.
docker
Copiar # syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS base
WORKDIR /app
FROM base AS development-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci
FROM development-deps AS test
COPY tsconfig.json ./
COPY src ./src
COPY test ./test
RUN npm test
FROM development-deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM base AS production-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci --omit=dev \
&& npm cache clean --force
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=production-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
base centraliza working directory.
development-deps instala todo lo requerido para tests y compile.
test valida source.
build produce dist.
production-deps genera un árbol separado sin dev dependencies.
runtime copia únicamente módulos, dist y metadata necesaria.
El command se ejecuta como usuario no root.
La etapa runtime no depende de test, por lo que CI debe ejecutar ambos targets o un workflow equivalente:
Bash
Copiar docker build --target test .
docker build --target runtime -t registry/api:$GIT_SHA . Después debe probar también la imagen final.
Aspecto Una etapa Multi-stage Dockerfile Más corto inicialmente Más explícito y estructurado Toolchain en runtime Frecuente Puede excluirse Superficie Mayor Menor si se copia con cuidado Cache Puede mezclarse Fronteras especializadas Compatibilidad Una sola base Debe verificarse entre etapas Debug Más herramientas disponibles Puede necesitar target de debug
Multi-stage añade navegación y mantenimiento. Para una imagen trivial que copia un único binario ya compilado, varias etapas pueden no aportar suficiente valor.
El build termina, pero el proceso falla con error del linker. Inspecciona dependencias y alinea bases.
docker
Copiar COPY --from=build /app /appPuede reintroducir source, tests, caches y secretos. Copia outputs específicos.
BuildKit omitió una etapa independiente. Corrige el pipeline, no asumas ejecución por orden textual.
Un build generado en /workspace puede fallar al ejecutarse en /app. Prueba el output en la imagen final.
Pueden revelar source y paths. Decide si se publican, se almacenan aparte o se restringen.
Copiarlas entre arquitectura o libc distintas produce errores. Construye para el target correcto.
Puede incluir root, package manager y utilidades. Protege tags y targets del pipeline.
Consecuencia: etapas duplicadas sin beneficio y cache peor.
Corrección: cada etapa debe tener una responsabilidad concreta.
Consecuencia: reaparecen herramientas y archivos que querías excluir.
Corrección: copiar artefactos mínimos identificados.
Consecuencia: fallos de libc y módulos nativos.
Corrección: familias compatibles o builds estáticos probados.
Consecuencia: secretos pueden quedar en cache, logs u outputs, y el stage puede exportarse.
Corrección: secret mounts y no persistir valores.
Consecuencia: tests pasan en el builder, pero faltan archivos o libraries en runtime.
Corrección: smoke tests y pruebas de integración sobre el target final.
Consecuencia: fallos TLS, fonts, timezone o diagnóstico.
Corrección: definir requisitos reales y medir, no eliminar a ciegas.
Bash
Copiar docker build --target test --progress = plain .
docker build --target runtime -t my-api:runtime . Bash
Copiar docker image history my-api:runtime
docker run --rm my-api:runtime find /app -maxdepth 3 -type fPara binarios nativos, usa herramientas apropiadas en un stage o entorno de análisis. No instales permanentemente herramientas solo para revisar una vez.
Bash
Copiar docker run --rm --read-only --tmpfs /tmp my-api:runtime id
docker run --rm --read-only --tmpfs /tmp my-api:runtimeCompara SBOM y vulnerabilidades del builder y runtime. La imagen final debería excluir componentes que no necesita, pero debe seguir pasando tests funcionales.
Puede ser innecesario cuando:
recibes un único binario final y solo necesitas copiarlo;
el lenguaje es interpretado y producción requiere exactamente las mismas dependencias;
separar etapas duplica instalación sin reducir contenido relevante;
la plataforma ya entrega un artefacto mínimo mediante otro proceso.
Aun en esos casos, una etapa de tests o debug puede seguir siendo útil. Decide por responsabilidades, no por cantidad de FROM.
Cada FROM inicia una etapa con filesystem y metadata propios.
Solo el contenido copiado llega a la etapa final.
Multi-stage separa build, tests, desarrollo, debug y runtime.
La imagen final debe ser mínima, compatible y operable.
Binarios y módulos nativos requieren compatibilidad de plataforma y libc.
Una etapa independiente no se ejecuta si no pertenece al grafo del target.
Secretos pueden filtrarse a través de outputs aunque el stage no se publique.
Debes probar la imagen final, no solo la etapa de build.
Comprueba lo aprendido
Diseña las etapas de una API TypeScript y explica qué artefacto cruza cada frontera.
¿Por qué copiar node_modules de Alpine a Debian puede fallar?
¿Cómo demostrarías que tests se ejecutaron si la etapa final no depende de ellos?
¿Qué archivos evitarías al copiar desde /app y por qué?
Diseña una etapa debug sin permitir que se publique como producción.
¿Qué diferencia existe entre reducir superficie e incrementar confianza en el build?
¿Cuándo una imagen mínima deja de ser operable?
Secrets y mounts de build , para transferir credenciales y accesos temporales sin incorporarlos a las capas ni convertirlos en parámetros visibles.