La mayoría de los Dockerfile que circulan funcionan, pero dejan sobre la mesa las tres optimizaciones que marcan la diferencia en un proyecto real: imágenes de varios cientos de MB cuando podrían pesar 20, builds que recompilan todas las dependencias en cada cambio de código, y contenedores corriendo como root sin necesidad. Vamos a construir uno bien desde cero.
El orden de las instrucciones ES la estrategia de caché
Docker cachea cada instrucción como una capa. Si el contexto de una capa no cambia (los archivos que copia, los comandos que ejecuta), Docker reutiliza la capa anterior. Este hecho convierte el orden de las líneas en una decisión crítica:
FROM node:22-alpine
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY package*.json ./
RUN npm ci
COPY . .
USER app
EXPOSE 3000
CMD ["npm", "start"]
Fíjate en la secuencia COPY package*.json → RUN npm ci → COPY . .. Los manifests del paquete cambian solo cuando añades o actualizas dependencias (unas veces al mes); tu código cambia constantemente. Al copiar primero solo los manifests e instalar dependencias, cualquier edición de código reutiliza la capa de node_modules intacta. El npm ci de minutos se convierte en un hito de caché instantáneo.
El error clásico —empezar con COPY . .— invalida la caché de instalación con cada guardado de archivo.
Multistage: compila en una imagen, entrega en otra
Un build de Next.js necesita devDependencies, compiladores y tooling. El resultado final necesita casi nada. Las builds multistage separan ambos mundos usando múltiples FROM, donde cada etapa empieza limpia y copia de las anteriores solo lo que necesita:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
Tres etapas: deps instala dependencias, builder compila la aplicación, runner es lo único que llega a producción. La imagen final ni siquiera contiene el código fuente TypeScript ni las devDependencies — solo el output standalone de Next.js y su runtime. Detalle importante: este patrón exige "output": "standalone" en tu next.config.
Alpine, Slim o Bookworm: qué variante elegir
Las imágenes oficiales publican varias variantes y elegir mal cuesta espacio o compatibilidad:
- Alpine (~50 MB base): usa musl libc en lugar de glibc. Ideal para binarios estáticos y proyectos sencillos. Riesgo conocido: paquetes nativos compilados contra glibc pueden fallar; algunos equipos han reportado diferencias de rendimiento en cargas intensivas de DNS o criptografía por la implementación de musl.
- Slim (~75 MB): Debian recortada, mantiene glibc. La opción segura por defecto cuando usas dependencias nativas (bcrypt, sharp, canvas).
- Bookworm (~150+ MB): Debian completa. Solo cuando necesitas herramientas de sistema dentro del contenedor (compiladores, debuggers).
Regla práctica: empieza con Alpine, y si npm install falla compilando binarios nativos, baja a Slim antes que empezar a instalar cadenas de -dev packages.
Nunca corras como root
Por defecto, todo en el contenedor ejecuta como root. Si alguien escapa de la aplicación mediante una vulnerabilidad, tiene root dentro del contenedor — y aunque los contenedores aíslen, las fugas de privilegios son el primer escalón de cualquier cadena de explotación. La mitigación cuesta dos líneas:
RUN addgroup --system app && adduser --system --ingroup app app
# ... COPY ...
USER app
Go lo resuelve aún mejor con imágenes distroless (gcr.io/distroless/static-debian12:nonroot), que no tienen shell ni gestor de paquetes: tu binario estático y literalmente nada más.
El truco de caché de Rust
Rust merece mención aparte porque compilar sus dependencias es lento y el patrón anterior de "copiar manifest primero" requiere un rodeo: Cargo no puede compilar solo dependencias sin un main.rs que exista. La solución estándar es crear uno falso, compilar, borrar los artefactos y recompilar con el código real:
FROM rust:1-bookworm AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src target/release/deps/app*
COPY . .
RUN cargo build --release
El primer build compila dos veces. A partir del segundo, cambiar tu código solo recompila la última línea: las dependencias quedan cacheadas en capas previas. En proyectos medianos esto reduce builds de 10 minutos a menos de 1.
No olvides el .dockerignore
Sin .dockerignore, COPY . . arrastra node_modules local, .git histórico y tus .env al contexto de build. Un mínimo razonable:
node_modules
.git
.env*
*.log
Dockerfile
.dockerignore
No es solo cuestión de tamaño del contexto: copiar un node_modules local sobre el instalado dentro de la imagen (posiblemente compilado para otra arquitectura) es fuente clásica de bugs imposibles de reproducir.
Genera tu Dockerfile por plantilla
Si prefieres partir de un buen ejemplo en lugar de escribir desde cero, nuestro generador de Dockerfile produce configuraciones completas para Node, Next.js, React/Vite, Python, Go y Rust, con multistage, usuario no root, elección de gestor de paquetes y el .dockerignore correspondiente listo para copiar.
Preguntas frecuentes
¿Debería fijar la versión exacta de la imagen? Usar node:22-alpine te da parches automáticos; fijar digest (node@sha256:...) da reproducibilidad total a costa de actualizar manualmente. Para producción seria, fija digest y automatiza la actualización con Dependabot o Renovate.
¿COPY --chown o chown posterior? --chown en el COPY es mejor: un RUN chown -R duplica los archivos en una capa nueva, inflando la imagen.
¿Healthcheck va en el Dockerfile o en Compose? Ambos sitios son válidos. En Compose/Kubernetes suele gestionarse fuera de la imagen porque el endpoint de salud depende del entorno de despliegue.
Genera un Dockerfile completo para tu stack con el generador de Dockerfile, con multistage y buenas prácticas incluidas.