Una construcción tradicional de etapa única en Dockerfile para aplicaciones web modernas produce imágenes infladas, inseguras y lentas. Si la instalación de dependencias, la compilación de TypeScript y la ejecución del servidor se agrupan en una única imagen, el artefacto final arrastra en sus capas todo el sobrecoste del entorno de compilación: código fuente original, compiladores, cachés intermedias del gestor de paquetes y herramientas auxiliares.
El patrón Multi-Stage Build (construcción multi-etapa) transforma esta metodología al descomponer el ciclo de vida del contenedor en cuatro fases deterministas:
base: configuración común del sistema operativo, fijación de la versión estable de Node.js e inicialización global del gestor de paquetes pnpm mediante Corepack.deps: capa dedicada exclusivamente a la instalación de dependencias externas. El código fuente de la aplicación se excluye de forma deliberada para que Docker conserve la caché de esta capa mientras no se modifique pnpm-lock.yaml.builder: entorno de compilación donde se ejecuta pnpm build. En este paso se produce el paquete aislado output: 'standalone' y se compilan los chunks estáticos de cliente.runner: contenedor de ejecución final y minimalista que aloja únicamente el binario de Node.js, un usuario de sistema sin privilegios y los artefactos finales. Las herramientas de compilación y más del 90% de las dependencias desaparecen por completo de la imagen.[Pipeline de Compilación Multi-Stage]
┌───────────────┐
│ Stage: base │ ──► node:22-alpine / glibc-compat / pnpm setup
└───────┬───────┘
│
├──────────────────────────────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Stage: deps │ │ Stage: runner │
│ │ │ │
│ • package.json│ │ • non-root │
│ • lockfile │ │ nextjs:1001 │
│ • pnpm cache │ │ • Distroless │
└───────┬───────┘ │ o Alpine │
│ node_modules └───────▲───────┘
▼ │
┌───────────────┐ │ Copia de
│Stage: builder │ │ artefactos
│ │ │
│ • Código base │ │
│ • pnpm build │ ─────────────────────────┘
│ • standalone │ ──► .next/standalone + static + public
└───────────────┘En conjunción con el motor Docker BuildKit, los flujos multi-stage permiten el uso de Cache Mounts (--mount=type=cache). Esta técnica monta el almacén global de pnpm store directamente desde el host o el runner de CI sin persistir los paquetes descargados en las capas finales de la imagen, acelerando las compilaciones en CI/CD entre 5 y 10 veces.
La arquitectura de un contenedor seguro y de alto rendimiento se fundamenta en el principio de menor privilegio (Principle of Least Privilege) y la reducción de la superficie de ataque (Attack Surface Reduction).
Un contenedor no es una máquina virtual independiente; es un proceso aislado ejecutado sobre el kernel de Linux del host y delimitado por namespaces y cgroups. Si el proceso se ejecuta como root (UID 0), cualquier brecha de escape de contenedor (Container Breakout) o montaje peligroso de volúmenes (-v /var/run/docker.sock) confiere al atacante privilegios administrativos totales sobre el servidor físico subyacente.
Para blindar el entorno en producción se establecen dos directrices innegociables:
UID=1001 y GID=1001 (nextjs:nodejs), privado de privilegios sudo y de la capacidad de escalar permisos (no-new-privileges).gcr.io/distroless/nodejs22) que contienen únicamente el binario de Node.js y las librerías dinámicas del sistema indispensables (glibc, libssl). En Distroless se eliminan físicamente:
/bin/sh, /bin/bash).apt, apk).curl, wget, nc).[Comparativa entre Imagen Tradicional y Distroless]
Imagen Tradicional (node:22 / Ubuntu / Alpine):
┌─────────────────────────────────────────────────────────────┐
│ Aplicación (server.js, node_modules) │
├─────────────────────────────────────────────────────────────┤
│ Runtime Node.js (V8, libuv) │
├─────────────────────────────────────────────────────────────┤
│ Utilidades: sh, bash, apk, curl, tar, find, coreutils │ ◄── Vector de ataque (RCE)
├─────────────────────────────────────────────────────────────┤
│ Librerías del sistema: glibc/musl, libcrypto, ca-certs │
└─────────────────────────────────────────────────────────────┘
Imagen Distroless (gcr.io/distroless/nodejs22):
┌─────────────────────────────────────────────────────────────┐
│ Aplicación (server.js, node_modules) │
├─────────────────────────────────────────────────────────────┤
│ Runtime Node.js (V8, libuv) │
├─────────────────────────────────────────────────────────────┤
│ Librerías básicas: glibc, libssl, ca-certificates │
└─────────────────────────────────────────────────────────────┘
(Intérpretes de comandos y utilidades del SO eliminados)Ante un intento de explotación de ejecución remota de código (RCE) que intente invocar comandos del sistema operativo, el ataque fracasa de inmediato: el kernel de Linux responde exec: "sh": executable file not found in $PATH.
Conceptos clave de contenedorización avanzada:
Las auditorías de seguridad bajo la normativa PCI-DSS prohíben la presencia de compiladores (GCC, Python, Make) y gestores de paquetes en entornos productivos. El empleo de Distroless junto con un usuario non-root reduce los informes de escáneres de seguridad (Trivy, Snyk) a cero vulnerabilidades abiertas (0 Critical, 0 High), garantizando el pase automático de los Quality Gates en CI/CD.
En infraestructuras donde los equipos efectúan entre 40 y 60 despliegues diarios, BuildKit Cache Mounts disminuye el tiempo de pnpm install de 2.5 minutos a 4 segundos. La caché de paquetes se mantiene en el runner y solo se invalida al variar el hash de pnpm-lock.yaml.
Al desplegar Next.js en servidores periféricos con almacenamiento SSD limitado, condensar la imagen final a 70–90 MB mitiga el desgaste del disco y optimiza la descarga de capas desde registros corporativos.
Para maximizar la velocidad de compilación y asegurar un aislamiento riguroso, el pipeline multi-stage se estructura en cuatro etapas consecutivas:
Fijación de la distribución base, variables PNPM_HOME y activación de Corepack.
Montaje de caché BuildKit en /pnpm/store y ejecución de pnpm install con --frozen-lockfile.
Copia de node_modules y código fuente, ejecución de pnpm build y preservación de .next/cache.
Definición del usuario sin privilegios 1001:1001, copia de artefactos y lanzamiento en Distroless/Alpine.
El gestor de dependencias pnpm centraliza los paquetes en un almacén indexado por contenido (store-dir). Para aprovecharlo en Docker:
PNPM_HOME="/pnpm".--mount=type=cache,id=pnpm,target=/pnpm/store.pnpm config set store-dir /pnpm/store.De este modo, en compilaciones posteriores solo se descargan las librerías añadidas o actualizadas.
# syntax=docker/dockerfile:1.7
# ==============================================================================
# 1. ETAPA: BASE
# Capa base del sistema operativo, variables de entorno e inicialización de pnpm
# ==============================================================================
FROM node:22-alpine AS base
ENV NODE_ENV=production \
PNPM_HOME="/pnpm" \
PATH="$PNPM_HOME:$PATH" \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0
WORKDIR /app
# Instalación de libc6-compat para la correcta compilación de addons nativos (Sharp, SWC)
RUN apk add --no-cache libc6-compat && \
corepack enable pnpm
# ==============================================================================
# 2. ETAPA: DEPS
# Instalación de dependencias aisladas del código con caché persistente
# ==============================================================================
FROM base AS deps
WORKDIR /app
# Copiar ÚNICAMENTE los manifiestos para no invalidar la caché de la capa
COPY package.json pnpm-lock.yaml* ./
# Montar la caché del host en el almacén de pnpm.
# La información no se guarda en la imagen final, pero se reutiliza entre builds.
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile --prod=false
# ==============================================================================
# 3. ETAPA: BUILDER
# Compilación de la aplicación Next.js y generación del paquete standalone
# ==============================================================================
FROM base AS builder
WORKDIR /app
# Copiar dependencias resueltas desde la etapa deps
COPY --from=deps /app/node_modules ./node_modules
# Copiar el código fuente completo
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
# Montar la caché del compilador .next/cache para acelerar re-compilaciones en CI
RUN --mount=type=cache,id=nextcache,target=/app/.next/cache \
pnpm build
# ==============================================================================
# 4. ETAPA: RUNNER (Variante A: Alpine Linux Blindado)
# Capa de ejecución con usuario sin privilegios y copia de artefactos
# ==============================================================================
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME="0.0.0.0" \
NEXT_TELEMETRY_DISABLED=1
# Crear usuario y grupo de sistema sin privilegios con UID/GID 1001 deterministas
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Crear directorio para la caché incremental de ISR con permisos para nextjs
RUN mkdir -p .next/cache && \
chown -R nextjs:nodejs /app
# 1. Copiar recursos públicos estáticos
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# 2. Copiar el servidor compilado autónomo
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# 3. Copiar los chunks estáticos generados para el cliente
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Asignar ejecución al usuario no root
USER nextjs
EXPOSE 3000
# Iniciar el servidor directamente con Node.js
CMD ["node", "server.js"]
# ==============================================================================
# VARIANTE DISTROLESS (Opción B: Máxima Seguridad sin Shell)
# Comando de compilación: docker build --target=runner-distroless -t app:distroless .
# ==============================================================================
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runner-distroless
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME="0.0.0.0" \
NEXT_TELEMETRY_DISABLED=1
# El usuario nonroot (UID 65532) ya viene preconfigurado en Distroless
COPY --from=builder --chown=nonroot:nonroot /app/public ./public
COPY --from=builder --chown=nonroot:nonroot /app/.next/standalone ./
COPY --from=builder --chown=nonroot:nonroot /app/.next/static ./.next/static
USER nonroot
EXPOSE 3000
# En Distroless no hay shell, por lo que CMD debe usar obligatoriamente la forma exec:
CMD ["server.js"]Un desarrollador introduce COPY . . antes del comando pnpm install. Cualquier cambio en una línea de estilos o un componente de React invalida la capa de Docker, obligando a descargar de nuevo todos los gigabytes de dependencias en cada construcción.
Causa habitual: Ahorrar líneas de código en el Dockerfile.
Realidad en producción: El tiempo de compilación en CI/CD se incrementa de 30 segundos a 5–7 minutos por entrega, saturando los pipelines.
# ❌ ANTIPATRÓN: Copiar todo el repositorio antes de instalar módulos
COPY . .
RUN pnpm install
# ✅ CORRECTO: Aislar manifiestos de paquetes en una capa previa cacheable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .En la capa final de ejecución se omite la instrucción USER nextjs, permitiendo que node server.js corra con privilegios administrativos totales.
Causa habitual: Evitar errores de permisos de escritura (EACCES: permission denied) al crear logs o almacenar cachés.
Realidad en producción: Infracción directa de los estándares CIS Docker Benchmark. Si una dependencia externa presenta una vulnerabilidad de escritura de archivos o path traversal, un atacante puede alterar configuraciones críticas del sistema o comprometer el host si existen volúmenes compartidos.
# ❌ INSEGURO: El proceso corre como root
CMD ["node", "server.js"]
# ✅ CORRECTO: Creación y activación de un usuario de sistema sin privilegios
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 nextjs
USER nextjs
CMD ["node", "server.js"]En una imagen fundamentada en gcr.io/distroless/nodejs22, se escribe el comando de inicio como CMD node server.js en lugar del array JSON CMD ["server.js"].
Causa habitual: Confundir la sintaxis Shell (CMD comando arg) con la sintaxis Exec (CMD ["ejecutable", "parámetro"]).
Realidad en producción: El contenedor falla en el arranque con: container_linux.go: starting container process caused: exec: "/bin/sh": stat /bin/sh: no such file or directory. Distroless carece de shell, por lo que Docker no puede ejecutar /bin/sh -c.
# ❌ INCORRECTO: La forma Shell necesita /bin/sh
CMD node server.js
# ✅ CORRECTO: Sintaxis Exec estricta en array JSON
CMD ["server.js"]Al acceder a páginas que utilizan revalidate, el servidor emite en consola EACCES: permission denied, open '.next/cache/fetch-cache/...' e interrumpe la entrega de contenido actualizado.
Causa habitual: La carpeta .next fue generada por root en la fase de compilación. Al cambiar a USER nextjs, el proceso carece de permisos de escritura.
Realidad en producción: Los procesos de regeneración en segundo plano (ISR) fallan por excepciones no controladas al escribir en disco.
# ❌ INCORRECTO: Carpeta creada por root; nextjs no tiene permisos de escritura
USER nextjs
CMD ["node", "server.js"]
# ✅ CORRECTO: Creación previa de la carpeta de caché y asignación de permisos
RUN mkdir -p .next/cache && chown -R nextjs:nodejs /app/.next
USER nextjs
CMD ["node", "server.js"]Declarar # syntax=docker/dockerfile:1.7 en la primera línea para desbloquear cachés avanzadas.
Aislar la copia de package.json y el lockfile para preservar el almacenamiento en caché de capas.
Montar /pnpm/store y .next/cache mediante --mount=type=cache para instalaciones ultra rápidas.
Establecer usuario non-root 1001:1001 y migrar a entornos seguros como Distroless o Alpine reducido.