TL;DR — Resumen Rápido

Los multi-stage builds de Docker reducen el tamaño de imagen y eliminan herramientas de build de producción, mejorando la seguridad en Node.js, Go y .NET.

Las imágenes Docker de producción que contienen compiladores, frameworks de prueba y gigabytes de dependencias de desarrollo son una carga. Ralentizan el arranque de contenedores, consumen almacenamiento innecesario en registros y exponen una superficie de ataque mayor a posibles exploits. Los Docker multi-stage builds resuelven esto permitiéndote compilar y probar dentro de un entorno de build completo, y luego copiar solo los artefactos terminados a una imagen de runtime lean y mínima — todo desde un solo Dockerfile. Esta guía cubre los multi-stage builds para Node.js, Go y .NET, incluyendo estrategias de caché, refuerzo de seguridad e integración con GitHub Actions.

Requisitos Previos

  • Docker Engine 24+ instalado (docker --version)
  • Familiaridad básica con Dockerfiles (FROM, RUN, COPY, CMD)
  • Una aplicación funcional en Node.js, Go o .NET (se proporcionan ejemplos)
  • BuildKit de Docker habilitado (por defecto en Docker 23+; establece DOCKER_BUILDKIT=1 para versiones anteriores)
  • Opcional: flujo de trabajo de GitHub Actions para integración CI/CD

El Problema con los Builds de Una Sola Etapa

Un Dockerfile típico de Node.js sin multi-stage builds luce así:

FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

Esta imagen contiene el entorno de desarrollo completo de Node.js: npm, cadenas de compilación nativas, el directorio node_modules incluyendo devDependencies, archivos fuente y utilidades de prueba. La imagen final puede superar fácilmente 1,2 GB para una aplicación modesta. Cada capa se sube al registro, se descarga en cada despliegue y se escanea en busca de CVEs en todas esas herramientas que nunca se ejecutan en producción.

Cómo Funcionan los Multi-Stage Builds

Los multi-stage builds usan múltiples instrucciones FROM en un solo Dockerfile. Cada FROM inicia una nueva etapa con un sistema de archivos limpio. Nombras las etapas con AS y las referencias en instrucciones COPY --from=<etapa> posteriores:

# Etapa 1: entorno de build
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

# Etapa 2: runtime de producción
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

Docker construye todas las etapas secuencialmente pero solo conserva la etapa final en la imagen de salida. Cada herramienta, archivo temporal y artefacto de build de etapas anteriores se descarta. El flag --from=builder en COPY accede al sistema de archivos de la etapa nombrada para extraer rutas específicas.

Multi-Stage Build para Node.js

Aquí un Dockerfile multi-stage listo para producción para una aplicación TypeScript de Node.js:

# ── Etapa 1: instalar todas las dependencias ───────────────────
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --frozen-lockfile

# ── Etapa 2: compilar TypeScript ───────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ── Etapa 3: runtime de producción ─────────────────────────────
FROM node:22-alpine AS production
ENV NODE_ENV=production
WORKDIR /app

# Crear usuario no root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist

# Eliminar devDependencies de node_modules
RUN npm prune --production

USER appuser
EXPOSE 8080
CMD ["node", "dist/server.js"]

El patrón de tres etapas separa las responsabilidades con claridad. La etapa deps instala todo incluyendo devDependencies. La etapa builder compila TypeScript a JavaScript. La etapa production parte desde cero, copia solo la salida compilada y node_modules, elimina devDependencies y ejecuta como usuario no root. La imagen resultante pesa aproximadamente 180 MB frente a los 1,2 GB de un build de una sola etapa.

Multi-Stage Build para Go

Go es un candidato ideal para los multi-stage builds porque el compilador produce un binario enlazado estáticamente que puede ejecutarse en prácticamente cualquier sistema de archivos Linux:

# ── Etapa 1: compilar ─────────────────────────────────────────
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o /app/server ./cmd/server

# ── Etapa 2: runtime mínimo ───────────────────────────────────
FROM gcr.io/distroless/static-debian12 AS production
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

CGO_ENABLED=0 produce un binario completamente estático sin dependencias de bibliotecas compartidas. Los -ldflags="-s -w" eliminan la tabla de símbolos e información de depuración, reduciendo el tamaño del binario entre un 20 y 30%. La imagen final usa distroless/static-debian12, que contiene solo lo mínimo para ejecutar un binario estático — sin shell, sin gestor de paquetes, sin /bin/sh. El resultado es una imagen de 10-20 MB frente a los 600 MB de la etapa de build.

Multi-Stage Build para .NET

.NET separa claramente el SDK (para construir) del runtime (para ejecutar). La imagen de runtime tiene aproximadamente un cuarto del tamaño de la imagen SDK:

# ── Etapa 1: restaurar paquetes NuGet ─────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS restore
WORKDIR /src
COPY *.sln .
COPY src/**/*.csproj ./
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore

# ── Etapa 2: build y publicación ──────────────────────────────
FROM restore AS builder
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet publish src/MyApp/MyApp.csproj \
    -c Release \
    -o /app/publish \
    --no-restore

# ── Etapa 3: runtime de producción ─────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine AS production
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/publish .
USER appuser
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

La etapa restore descarga paquetes NuGet una vez y los almacena en caché. La etapa builder compila y publica. La etapa production usa aspnet:9.0-alpine, que incluye solo el runtime de ASP.NET Core — sin SDK, sin compiladores, sin código fuente. Una API típica de .NET 9 se reduce de 1,1 GB (imagen SDK) a aproximadamente 130 MB (imagen runtime Alpine).

Estrategias de Caché

Los cachés de montaje de BuildKit (--mount=type=cache) son la optimización más impactante para la velocidad de reconstrucción. A diferencia del caché de capas de Docker, los cachés de montaje persisten entre builds sin incluirse en la imagen:

# npm
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# Go — cachear descargas de módulos y caché de build por separado
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build ./...

# NuGet
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore

El orden de las capas importa igual. Copia solo los archivos de manifiesto de dependencias (package.json, go.mod, *.csproj) antes de copiar el código fuente. Docker invalida el caché para todas las capas después de la primera capa modificada, así que si copias el código fuente primero, cada instalación de dependencias se vuelve a ejecutar en cada cambio de código:

# Correcto: copiar manifiestos primero, código fuente después
COPY package*.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build

Comparación de Tamaño de Imágenes

AplicaciónImagen Una EtapaImagen Multi-StageReducción
Node.js TypeScript API1.240 MB185 MB85%
Servicio REST en Go612 MB12 MB98%
API ASP.NET Core .NET 91.080 MB130 MB88%
App Python Flask945 MB180 MB81%
Java Spring Boot (JRE)880 MB250 MB72%

Escenario del Mundo Real

Estás construyendo y desplegando una API REST de Node.js a través de GitHub Actions. Actualmente, cada ejecución de CI tarda 8 minutos en construir una imagen de 1,2 GB. Con multi-stage builds y caché de capas de BuildKit, puedes reducir esto a menos de 2 minutos en ejecuciones con caché.

name: Build and Deploy

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          target: production
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache,mode=max

Gotchas y Casos Límite

Los valores ARG no cruzan límites de etapa. Si defines ARG VERSION=1.0 antes del primer FROM, está disponible globalmente. Si lo defines dentro de una etapa, solo existe en esa etapa. Vuelve a declarar ARG en cada etapa que lo necesite.

El contexto de build se comparte entre todas las etapas. Usa un archivo .dockerignore para excluir node_modules, .git y fixtures de prueba del contexto para acelerar la transferencia y prevenir que archivos sensibles aparezcan en capas intermedias.

El flag --target detiene el build en una etapa nombrada. Úsalo para probar etapas individuales:

# Construir solo la etapa builder
docker build --target builder -t myapp:debug .

# Ejecutar de forma interactiva para depurar
docker run --rm -it myapp:debug sh

Los timestamps no se preservan con COPY --from. Los archivos copiados entre etapas obtienen el timestamp del build actual. Esto puede afectar aplicaciones que verifican tiempos de modificación de archivos.

Solución de Problemas

La imagen final aún contiene archivos fuente — Olvidaste usar COPY --from=builder y en cambio usas un COPY . . simple en la etapa de producción. Revisa cada COPY en tu etapa de producción y asegúrate de que referencie una ruta específica de una etapa nombrada.

El binario de Go falla con “exec format error” en Alpine — Compilaste con CGO_ENABLED=1 (el predeterminado) pero usaste una etapa sin runtime C. Establece CGO_ENABLED=0 explícitamente, o cambia la etapa de runtime a alpine:3.19 que incluye musl libc.

Los cachés de montaje no persisten en CI--mount=type=cache es una característica local de BuildKit. En CI, usa la estrategia de caché de registro (cache-from/cache-to) en su lugar.

El tamaño de imagen no disminuyó significativamente — Verifica que tu etapa de producción no copie directorios innecesarios. Ejecuta docker history myimage:latest para ver qué capas contribuyen más al tamaño.

Resumen

  • Los builds de una sola etapa incluyen compiladores, herramientas de desarrollo y código fuente en la imagen final — los multi-stage builds eliminan todo eso
  • Usa COPY --from=<etapa> para copiar solo los artefactos compilados a una imagen base de runtime mínima
  • Las apps Node.js se reducen 85%, las apps Go 98% y las apps .NET 88% con multi-stage builds
  • Monta cachés del gestor de paquetes con --mount=type=cache para acelerar reconstrucciones incrementales sin inflar la imagen
  • Copia los manifiestos de dependencias antes del código fuente para maximizar la reutilización del caché de capas de Docker
  • Ejecuta contenedores como usuarios no root y usa sistemas de archivos de solo lectura cuando sea posible
  • En GitHub Actions, usa cache-from/cache-to con un caché de registro para compartir capas de BuildKit entre ejecuciones
  • Usa --target para construir y depurar etapas específicas sin ejecutar el build completo

Artículos Relacionados