Guía11 min

Multi-stage Docker para un agente IA: build gordo, runtime flaco

Resumen

Cómo no meter compiladores y pnpm store en prod: stage build con devDependencies, stage runtime solo node_modules de prod, COPY --from. La imagen chica arranca más rápido, filtra tools de build y no lleva el .env que ignoraste a medias. Docs Docker multi-stage, 3 sep 2026.

Docker
Dos etapas de build Docker: compilacion a la izquierda y runtime minimo a la derecha

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

Un pnpm install sin --prod deja typescript, eslint y el cache en la imagen que Fly corre a las 3 AM. Multi-stage: stage build instala todo; stage runner copia solo dist + prod deps. dockerignore recorta el contexto. Esta guía recorta lo que queda después del COPY. Fuentes oficiales consultadas el 3 de septiembre de 2026.

La regla

Dos FROM. El último es el que se publica. COPY --from=build paths explícitos. Si el runner tiene tsc, fallaste.

El stage de build puede ser root y gordo. El runner es USER node + digest en ambos FROM.

Cada FROM abre un stage y borra el estado del anterior: variables, USER, WORKDIR no se heredan. Si omites tag y digest, el builder asume latest. ARG es la única instrucción que puede ir antes del primer FROM.

Esqueleto

FROM node:22-alpine@sha256:DIGEST AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY src ./src
RUN pnpm build

FROM node:22-alpine@sha256:DIGEST AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json /app/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile --prod
COPY --from=build /app/dist ./dist
USER node
CMD ["node","dist/server.js"]

Ajusta el digest real. --prod en el runner. No copies src TS al runtime si ya hay dist.

Por qué

Imagen más chica: pull más corto, menos disco (ENOSPC), menos superficie (no tsc como tool accidental del agente). Cold start de Fly/Railway mejora si el layer es menor (cold starts).

La guía de multi-stage lo dice en operativo, no en marketing: el segundo FROM parte de una base limpia y COPY --from trae solo el artefacto. El SDK, el compiler y los archivos intermedios se quedan atrás. El ejemplo oficial usa scratch + un binario Go; para un agente Node no uses scratch (necesitas libc/musl y el runtime), usa la misma alpine pineada.

BuildKit descarta el stage build del tag final. Sigue existiendo en cache local: no lo docker push.

Nombres, índices y --target

Sin AS, los stages se numeran desde 0. COPY --from=0 funciona hasta que reordenas el Dockerfile y el 0 deja de ser el build. Nombra: FROM … AS build y copia --from=build.

docker buildx build --target runner (o docker build --target runner) publica ese stage y omite lo que viene después. Útil para un stage test en el medio: CI construye --target test, prod construye el último (o --target runner explícito). Si dejas el stage de test al final, el default publica el test.

BuildKit solo construye los stages de los que el target depende. El builder legado (BuildKit apagado) procesa todos los stages hasta el target, aunque el target no los use. En un agente con stage lint / test / runner, eso es CPU y minutos tirados en CI. Deja BuildKit encendido.

COPY --from: raíz del stage, no del contexto

El path de COPY --from=build se resuelve desde la raíz del filesystem de ese stage, no desde el contexto de tu laptop. COPY --from=build dist ./dist busca /dist en el stage, no /app/dist. Por eso el esqueleto usa /app/dist.

También puedes copiar de otra imagen: COPY --from=nginx:latest /etc/nginx/nginx.conf. Para un agente, no copies binarios de un tag flotante. Si necesitas un CLI de un registry, ancla digest igual que el FROM.

Sin --chown, los archivos copiados nacen con UID/GID 0. En el runner, COPY --from=build --chown=1000:1000 (números: no hace falta /etc/passwd). Windows containers no soportan --chown ni --chmod.

Tabla

Stage de build versus stage runner

StageQué haySe publica
buildcompiler, devDeps, srcno
runnernode, prod deps, dist
“todo en uno”todoincidente

Errores comunes

Runtime con typescript y eslint todavía dentro

SíntomaCausaFix
Imagen 1.2 GBun solo FROMsegundo stage
tsc en prodCOPY --from demasiadopaths explícitos
native addon rotabuild alpine ≠ runner debianmismo digest base
pnpm no estáolvidaste corepack en runnerinstálalo o copia node_modules
USER root otra vezUSER solo en buildUSER en runner
CI construye linttarget por default = último FROM--target runner

Relación con el resto

Checklist

  • Dos FROM, mismo digest base
  • --prod solo en runner
  • No src/ TS en runner
  • USER node en el último stage
  • docker image ls tamaño razonable vs un-stage
  • Ensayo: docker run --rm img ls node_modules/typescript → fail
  • CI: --target runner (no el stage de test)

FAQ

¿Un stage con pnpm deploy? Válido. El criterio es “runner sin compiler”.

¿Workers/Vercel? No hay tu multi-stage. Igual no subas devDeps al Function inútilmente.

¿Cache de pnpm? --mount=type=cache en build, no en el runner publicado. sharing default es shared (varios writers a la vez); locked espera al primero; private abre un mount nuevo si hay conflicto.

HEALTHCHECK, CMD y cache

El HEALTHCHECK vive en el runner. Copiar el Dockerfile de un-stage y dejar wget en build no ayuda: alpine runner no lo tiene. Usa node -e http.get como en healthchecks. CMD ["node","dist/server.js"] en exec form para SIGTERM. Si pones dos CMD, solo el último cuenta.

Fly/Railway cachean layers. Si cambias solo src, el pnpm install --prod del runner puede cachear. Bien. Si cambias el lockfile, ambos stages se rehace. Esperado.

El cache de un RUN no se invalida solo porque pasó el tiempo: docker build --no-cache rehace capas; no baja una base nueva. Para la base, --pull. Los dos juntos: docker build --pull --no-cache. En un agente, eso es el rebuild de parche de seguridad, no el de cada commit.

No copies node_modules del build al runner si el build tenía devDeps: filtrarías typescript. O --prod fresco en runner o pnpm deploy a una carpeta limpia. Mezclar es el 1.2 GB otra vez.

El ensayo: construye, compara bytes un-stage vs multi-stage, ls typescript en runner debe fallar.

No publiques el stage build “por si debug”. Debug usa el tag local (--target build). Prod es el runner.

Si el agente ejecuta pnpm dlx en runtime, el multi-stage no te salva: volviste a bajar tools flotantes. El runner no tiene red de “instalar más cosas”; tiene el lockfile de prod.

Un lockfile frozen en ambos stages evita el “en CI instaló v2 y en prod v3”. Mismo pnpm-lock.yaml, mismo digest de Node. Si CI usa pnpm install sin frozen, el runner de prod ya no es el que testeaste.

Puedes reusar un stage como base de otro: FROM build AS test hereda el compiler y corre tests; FROM node:… AS runner no hereda nada de build salvo lo que copies. La guía de best practices pide dos bases: una gorda para build/test, una flaca para prod. Alpine oficial cabe en menos de 6 MB; igual ancla digest.

Un stage intermedio FROM builder AS build1 / FROM builder AS build2 comparte el apk add del builder. BuildKit lo construye una vez. El builder legado lo pagaría dos veces y además construiría stages huérfanos.

--platform=$BUILDPLATFORM en el stage de build (ARG automático) sirve si cruzas amd64→arm64: compilas en la máquina de CI y copias el binario al runner de la plataforma destino. Un agente Node puro rara vez lo necesita; un native addon sí.

Siguiente paso: si la imagen ya es flaca y prod igual es lento, CPU y cold starts. Sin runtime: curso.