Saltar al contenido
·18 min de lectura

Docker para Node.js: La configuración para producción de la que nadie habla

Builds multi-stage, usuarios no-root, health checks, gestión de secretos y optimización del tamaño de imagen. Los patrones de Docker que uso en cada deploy de Node.js a producción.

Compartir:X / TwitterLinkedIn

La mayoría de los Dockerfiles de Node.js en producción son malos. No "ligeramente subóptimos". Me refiero a ejecutarse como root, enviar imágenes de 600MB con devDependencies incluidas, sin health checks, y secretos hardcodeados en variables de entorno que cualquiera con docker inspect puede leer.

Lo sé porque yo escribí esos Dockerfiles. Durante años. Funcionaban, así que nunca los cuestioné. Luego, un día, una auditoría de seguridad señaló que nuestro contenedor se ejecutaba como PID 1 root con acceso de escritura a todo el sistema de archivos, y me di cuenta de que "funciona" y "listo para producción" son niveles muy diferentes.

Esta es la configuración de Docker que ahora uso para cada proyecto de Node.js. No es teórica. Ejecuta los servicios detrás de este sitio y varios otros que mantengo. Cada patrón aquí existe porque me quemé con la alternativa o vi a alguien más quemarse.

Por qué tu Dockerfile actual probablemente está mal#

Déjame adivinar cómo se ve tu Dockerfile:

dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

Este es el "hola mundo" de los Dockerfiles. Funciona. También tiene al menos cinco problemas que te van a perjudicar en producción.

Ejecutarse como Root#

Por defecto, la imagen Docker de node se ejecuta como root. Eso significa que el proceso de tu aplicación tiene privilegios de root dentro del contenedor. Si alguien explota una vulnerabilidad en tu app — un bug de path traversal, un SSRF, una dependencia con un backdoor — tiene acceso root al sistema de archivos del contenedor, puede modificar binarios, instalar paquetes, y potencialmente escalar más dependiendo de la configuración de tu runtime de contenedores.

"¡Pero los contenedores están aislados!" Parcialmente. Los escapes de contenedores son reales. CVE-2024-21626, CVE-2019-5736 — estos son breakouts de contenedores del mundo real. Ejecutarse como no-root es una medida de defensa en profundidad. No cuesta nada y cierra toda una clase de ataques.

Instalar devDependencies en producción#

npm install sin flags instala todo. Tus test runners, linters, herramientas de build, type checkers — todo en tu imagen de producción. Esto infla tu imagen en cientos de megabytes y aumenta tu superficie de ataque. Cada paquete adicional es otra vulnerabilidad potencial que Trivy o Snyk van a señalar.

COPY de todo#

COPY . . copia todo el directorio de tu proyecto en la imagen. Eso incluye .git (que puede ser enorme), archivos .env (que contienen secretos), node_modules (que vas a reinstalar de todos modos), archivos de test, documentación, configs de CI — todo.

Sin health checks#

Sin una instrucción HEALTHCHECK, Docker no tiene idea de si tu aplicación está realmente sirviendo tráfico. El proceso podría estar corriendo pero en deadlock, sin memoria, o atrapado en un bucle infinito. Docker reportará el contenedor como "running" porque el proceso no ha terminado. Tu load balancer sigue enviando tráfico a un contenedor zombi.

Sin estrategia de caché de capas#

Copiar todo antes de instalar dependencias significa que cambiar una sola línea de código fuente invalida la caché de npm install. Cada build reinstala todas las dependencias desde cero. En un proyecto con dependencias pesadas, eso son 2-3 minutos de tiempo desperdiciado por build.

Arreglemos todo esto.

Builds Multi-Stage: La mayor mejora individual#

Los builds multi-stage son el cambio más impactante que puedes hacer en un Dockerfile de Node.js. El concepto es simple: usa una etapa para construir tu aplicación, luego copia solo los artefactos que necesitas en una imagen final limpia y mínima.

Aquí está la diferencia en la práctica:

dockerfile
# Etapa única: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
 
# Multi-stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]

La etapa builder tiene todo: Node.js completo, npm, herramientas de build, código fuente, devDependencies. La etapa runner tiene solo lo necesario en tiempo de ejecución. La etapa builder se descarta completamente — no termina en la imagen final.

Comparaciones reales de tamaño#

Medí estos en un proyecto real de API con Express.js con unas 40 dependencias:

EnfoqueTamaño de imagen
node:20 + npm install1.1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 MB
Multi-stage + alpine + solo deps de producción150 MB
Multi-stage + alpine + deps podadas95 MB

Eso es una reducción de 10x respecto al enfoque ingenuo. Imágenes más pequeñas significan pulls más rápidos, deployments más rápidos y menos superficie de ataque.

¿Por qué Alpine?#

Alpine Linux usa musl libc en lugar de glibc, y no incluye caché de gestor de paquetes, documentación ni la mayoría de utilidades que encontrarías en una distribución Linux estándar. La imagen base node:20-alpine pesa unos 50MB comparada con 350MB de node:20-slim y más de 1GB del node:20 completo.

La contrapartida es que algunos paquetes npm con bindings nativos (como bcrypt, sharp, canvas) necesitan compilarse contra musl. En la mayoría de los casos simplemente funciona — npm descargará el binario precompilado correcto. Si tienes problemas, puedes instalar dependencias de compilación en la etapa builder:

dockerfile
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... resto del build

Estas herramientas de compilación solo existen en la etapa builder. No están en tu imagen final.

El Dockerfile completo para producción#

Aquí está el Dockerfile que uso como punto de partida para cada proyecto de Node.js. Cada línea es intencional.

dockerfile
# ============================================
# Etapa 1: Instalar dependencias
# ============================================
FROM node:20-alpine AS deps
 
# Seguridad: crear un directorio de trabajo antes que nada
WORKDIR /app
 
# Instalar dependencias basadas en el lockfile
# Copiar SOLO los archivos de package primero — esto es crítico para el caché de capas
COPY package.json package-lock.json ./
 
# ci es mejor que install: es más rápido, estricto y reproducible
# --omit=dev excluye las devDependencies de esta etapa
RUN npm ci --omit=dev
 
# ============================================
# Etapa 2: Construir la aplicación
# ============================================
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# Copiar archivos de package e instalar TODAS las dependencias (incluyendo dev)
COPY package.json package-lock.json ./
RUN npm ci
 
# AHORA copiar el código fuente — los cambios aquí no invalidan la caché de npm ci
COPY . .
 
# Construir la aplicación (compilación TypeScript, build de Next.js, etc.)
RUN npm run build
 
# ============================================
# Etapa 3: Runner de producción
# ============================================
FROM node:20-alpine AS runner
 
# Añadir labels para metadatos de la imagen
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
 
# Seguridad: instalar dumb-init para manejo adecuado de señales PID 1
RUN apk add --no-cache dumb-init
 
# Seguridad: establecer NODE_ENV antes que nada
ENV NODE_ENV=production
 
# Seguridad: usar usuario no-root
# La imagen de node ya incluye un usuario 'node' (uid 1000)
USER node
 
# Crear directorio de la app propiedad del usuario node
WORKDIR /app
 
# Copiar dependencias de producción de la etapa deps
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
 
# Copiar aplicación construida de la etapa builder
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
 
# Exponer el puerto (solo documentación — no lo publica)
EXPOSE 3000
 
# Health check: curl no está disponible en alpine, usar node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
 
# Usar dumb-init como PID 1 para manejar señales correctamente
ENTRYPOINT ["dumb-init", "--"]
 
# Iniciar la aplicación
CMD ["node", "dist/server.js"]

Déjame explicar las partes que no son obvias.

¿Por qué tres etapas en lugar de dos?#

La etapa deps instala solo las dependencias de producción (--omit=dev). La etapa builder instala todo y ejecuta el build. La etapa final copia las dependencias de producción de deps y los artefactos de build de builder.

¿Por qué no simplemente hacer npm prune --omit=dev después de construir? Porque npm prune no es fiable. He visto casos donde elimina paquetes que aún se necesitan en runtime porque el análisis del árbol de dependencias no coincide con lo que realmente importa tu código. Etapas separadas son deterministas: solo obtienes lo que la etapa deps instaló.

npm ci vs npm install#

npm ci es estrictamente mejor para contenedores:

  • Elimina node_modules existentes antes de instalar (build limpio)
  • Se niega a ejecutarse si package-lock.json no coincide con package.json
  • Nunca escribe en package.json o package-lock.json
  • Es significativamente más rápido porque salta la resolución de dependencias

Si estás usando npm install en tu Dockerfile, cámbialo. Es un cambio de una línea que mejora la reproducibilidad y velocidad.

El problema del PID 1#

Cuando tu proceso Node.js se ejecuta como PID 1 dentro de un contenedor, tiene un problema: PID 1 no obtiene los manejadores de señales por defecto que obtienen los procesos normales. Cuando Docker envía SIGTERM a tu contenedor (durante docker stop, rollouts de Kubernetes, etc.), tu proceso Node.js no va a terminar limpiamente a menos que hayas registrado explícitamente un manejador de SIGTERM.

Sin manejo adecuado de señales, esto es lo que sucede:

  1. Docker envía SIGTERM al contenedor
  2. Tu proceso Node.js ignora SIGTERM (porque PID 1 no recibe el comportamiento por defecto de SIGTERM)
  3. Docker espera 10 segundos (el período de gracia por defecto)
  4. Docker envía SIGKILL, matando inmediatamente tu proceso
  5. Las solicitudes en vuelo se pierden, las conexiones a base de datos no se cierran limpiamente, las escrituras parciales corrompen datos

dumb-init resuelve esto siendo PID 1 y reenviando señales a tu proceso Node.js. Tu app recibe SIGTERM, puede limpiar y terminar limpiamente.

javascript
// Manejador de shutdown limpio — esto funciona con dumb-init
process.on('SIGTERM', async () => {
  console.log('SIGTERM recibido, cerrando limpiamente...');
 
  // Dejar de aceptar nuevas conexiones
  server.close();
 
  // Esperar que las solicitudes existentes terminen
  await drainConnections();
 
  // Cerrar conexiones de base de datos
  await db.end();
 
  process.exit(0);
});

El patrón del health check#

El HEALTHCHECK usa Node.js en lugar de curl (que no está disponible en Alpine sin instalación extra) o wget (que añade otra dependencia). El one-liner de Node.js:

node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"

Hace una solicitud HTTP al endpoint de health de tu app y sale con código 0 (saludable) o 1 (no saludable). Los parámetros de tiempo:

  • --interval=30s: Verificar cada 30 segundos
  • --timeout=10s: Fallar si la verificación tarda más de 10 segundos
  • --start-period=40s: Dar a la app 40 segundos para arrancar antes de contar fallos
  • --retries=3: Marcar como no saludable después de 3 fallos consecutivos

Ajusta --start-period basándote en el tiempo real de arranque de tu app. Si tu app tarda 15 segundos en arrancar, pon 30 segundos de start period. Si tarda 5 segundos, 15 es suficiente.

El .dockerignore que realmente necesitas#

Tu .dockerignore es tan importante como tu Dockerfile. Sin él, COPY . . envía todo al daemon de Docker como contexto de build, incluyendo cosas que nunca deberían estar cerca de una imagen de producción.

# Dependencias
node_modules
npm-debug.log*

# Control de versiones
.git
.gitignore

# Configuración del IDE
.vscode
.idea
*.swp
*.swo

# Variables de entorno (NUNCA deben estar en una imagen)
.env
.env.*
!.env.example

# Archivos de test
__tests__
*.test.js
*.test.ts
*.spec.js
*.spec.ts
coverage
.nyc_output

# Documentación y metadatos
README.md
CHANGELOG.md
LICENSE
docs

# CI/CD
.github
.gitlab-ci.yml
.circleci
Jenkinsfile

# Docker
Dockerfile*
docker-compose*
.dockerignore

# OS
.DS_Store
Thumbs.db

La línea .env* con !.env.example es particularmente importante. Excluye todos los archivos de entorno (que contienen secretos) pero mantiene el ejemplo (que documenta qué variables se necesitan).

Medir el impacto del contexto de build#

Puedes ver cuántos datos se envían al daemon de Docker:

bash
# Sin .dockerignore
$ docker build .
Sending build context to Docker daemon  450.2MB
 
# Con .dockerignore correctamente configurado
$ docker build .
Sending build context to Docker daemon  2.1MB

Esa diferencia de 448MB importa, especialmente en CI donde el build context se envía al daemon de Docker por cada build.

Gestión de secretos: Deja de usar variables de entorno#

Esto es algo que la mayoría de los tutoriales de Docker hacen mal. Ves ENV DATABASE_URL=postgres://... en Dockerfiles, o -e SECRET_KEY=abc123 en comandos docker run, y parece razonable. No lo es.

El problema con las variables de entorno#

Las variables de entorno son visibles en:

bash
# Cualquiera con acceso al daemon puede ver los env vars
docker inspect container_name
 
# También visibles en el historial del proceso
cat /proc/1/environ
 
# Y en los logs de Docker si los imprimes accidentalmente
docker logs container_name

Si estableces secretos como env vars en tu Dockerfile o docker-compose.yml, están horneados en la imagen. Cualquiera que pueda hacer pull de tu imagen puede extraerlos. Esto no es teórico — lo he visto en múltiples auditorías de seguridad.

Docker Secrets (Modo Swarm)#

Si estás usando Docker Swarm, los secrets están integrados:

bash
# Crear un secret
echo "mi-clave-super-secreta" | docker secret create api_key -
 
# Usarlo en un servicio
docker service create \
  --name mi-api \
  --secret api_key \
  mi-imagen:latest

El secret se monta como un archivo en /run/secrets/api_key. Tu app lo lee como un archivo, no como un env var:

javascript
import { readFileSync } from 'node:fs';
 
function getSecret(name) {
  try {
    return readFileSync(`/run/secrets/${name}`, 'utf8').trim();
  } catch {
    // Fallback a env var para desarrollo local
    return process.env[name.toUpperCase()];
  }
}
 
const apiKey = getSecret('api_key');
const dbUrl = getSecret('database_url');

BuildKit Secrets para tiempo de build#

Si necesitas secretos durante el build (por ejemplo, tokens de registro npm privado), usa secretos de BuildKit mount:

dockerfile
# syntax=docker/dockerfile:1
 
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
 
# El secret se monta temporalmente — nunca se almacena en la imagen
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
    npm ci
 
COPY . .
RUN npm run build
bash
# Pasar el secret en build time
docker build --secret id=npmrc,src=$HOME/.npmrc .

El secret está disponible durante el paso RUN pero no se persiste en ninguna capa de la imagen. Puedes verificar esto inspeccionando las capas de la imagen — el secret no está ahí.

Docker Compose con env_file#

Para desarrollo local, env_file en docker-compose es aceptable — pero no commitees el archivo .env real:

yaml
services:
  api:
    build: .
    env_file:
      - .env  # En gitignore, nunca commiteado
    ports:
      - "3000:3000"
# .env.example (commiteado — documenta las variables necesarias)
DATABASE_URL=postgres://user:pass@host:5432/db
REDIS_URL=redis://host:6379
API_KEY=tu-clave-api-aqui

Optimización del tamaño de imagen: Más allá de lo básico#

Alpine y multi-stage te llevan a ~150MB. Aquí está cómo llegar más abajo.

Podar node_modules#

Incluso las dependencias de producción incluyen archivos innecesarios: archivos README, changelogs, archivos TypeScript de definición que no necesitas en runtime, archivos de test. Puedes podarlos:

dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
 
# Eliminar archivos innecesarios de node_modules
RUN find node_modules -name "*.md" -delete && \
    find node_modules -name "*.d.ts" -delete && \
    find node_modules -name "CHANGELOG*" -delete && \
    find node_modules -name "LICENSE*" -delete && \
    find node_modules -name "*.map" -delete && \
    find node_modules -name "*.ts" ! -name "*.d.ts" -delete && \
    find node_modules -name "test" -type d -exec rm -rf {} + 2>/dev/null; \
    find node_modules -name "__tests__" -type d -exec rm -rf {} + 2>/dev/null; \
    find node_modules -name "docs" -type d -exec rm -rf {} + 2>/dev/null; true

Esto típicamente ahorra 20-40% del tamaño de node_modules. En un proyecto, bajé node_modules de 80MB a 48MB solo con este paso.

Distroless como imagen base#

Para máxima seguridad y tamaño mínimo, considera las imágenes distroless de Google:

dockerfile
FROM gcr.io/distroless/nodejs20-debian12 AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/package.json ./
CMD ["dist/server.js"]

Las imágenes distroless no tienen shell. Sin bash, sin sh, sin apt, sin apk. No puedes hacer exec dentro del contenedor. Esto es una ventaja de seguridad (los atacantes no pueden conseguir un shell) y un inconveniente de depuración (tú tampoco puedes conseguir un shell).

Yo uso Alpine para la mayoría de servicios y distroless para los expuestos públicamente o que manejan datos sensibles. La incapacidad de hacer exec en el contenedor es una contrapartida que vale la pena cuando la seguridad importa más que la comodidad de depuración.

Compresión de capas#

Cada instrucción RUN crea una nueva capa de imagen. Combinar comandos relacionados reduce la cuenta de capas y el tamaño total:

dockerfile
# Malo: 3 capas, archivos intermedios persistidos
RUN apk add --no-cache dumb-init
RUN addgroup -g 1001 nodejs
RUN adduser -S -u 1001 -G nodejs nextjs
 
# Bueno: 1 capa, sin archivos intermedios
RUN apk add --no-cache dumb-init && \
    addgroup -g 1001 nodejs && \
    adduser -S -u 1001 -G nodejs nextjs

Sin embargo, no combines todo en un solo RUN monolítico. Equilibra la optimización de capas con la legibilidad y cacheabilidad. Agrupa los comandos relacionados.

Configuración de Docker Compose para producción#

Aquí está la configuración de docker-compose que uso para servicios de producción que no están en Kubernetes:

yaml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: runner
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"  # Vincular solo a localhost
    environment:
      - NODE_ENV=production
    env_file:
      - .env
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    security_opt:
      - no-new-privileges:true
    read_only: true
    tmpfs:
      - /tmp

Puntos clave:

  • 127.0.0.1:3000:3000: Vincula solo a localhost, no 0.0.0.0. El tráfico externo debe pasar por tu reverse proxy (nginx, Caddy).
  • Límites de recursos: Previene que un solo contenedor consuma todo el host. Ajusta basándote en el uso real de tu app.
  • Configuración de logging: Sin esto, los logs de Docker crecen indefinidamente. max-size: 10m con max-file: 3 limita los logs a 30MB.
  • no-new-privileges: Previene que los procesos ganen privilegios adicionales a través de setuid/setgid.
  • read_only: El sistema de archivos del contenedor es de solo lectura. El contenedor no puede escribir en su propio sistema de archivos (excepto tmpfs mounts). Esto rompe la mayoría de los intentos de los atacantes de persistir en un contenedor comprometido.
  • tmpfs: Monta /tmp como un sistema de archivos temporal en memoria para las apps que necesitan un directorio temporal de escritura.

Caché de Docker en CI#

El caché de builds de Docker en CI es esencialmente gratis y te ahorra minutos por build.

GitHub Actions con Docker Layer Caching#

yaml
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3
 
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/myorg/myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha usa la caché de GitHub Actions directamente. Es la opción con menos fricción — sin registro separado, sin almacenamiento S3, solo funciona.

Docker Build local con caché#

Para desarrollo local, la caché de BuildKit está habilitada por defecto. Si estás reconstruyendo con frecuencia, las cachés inline te ahorran tiempo:

bash
# Primer build — sin caché, velocidad completa
DOCKER_BUILDKIT=1 docker build -t myapp .
 
# Builds posteriores — las capas sin cambios vienen de la caché
DOCKER_BUILDKIT=1 docker build -t myapp .

La clave es la estructura de tu Dockerfile. Porque copiamos package.json antes que el código fuente, la capa de npm ci se cachea hasta que las dependencias realmente cambien. Los cambios de código fuente solo reconstruyen la capa COPY y las etapas posteriores.

Seguridad en contenedores más allá de lo básico#

Escaneo de imágenes#

Integra el escaneo de imágenes en tu pipeline de CI:

yaml
- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:latest
    format: table
    exit-code: 1
    severity: CRITICAL,HIGH

Esto falla el build si se encuentran vulnerabilidades críticas o altas. No es opcional — es la línea base.

Verificación en runtime#

Después de construir tu imagen, verifica que se ejecuta como se espera:

bash
# Verificar que el contenedor se ejecuta como no-root
docker run --rm myapp:latest whoami
# Debería imprimir: node
 
# Verificar que las dependencias de dev no están presentes
docker run --rm myapp:latest ls node_modules/.package-lock.json
# Debería mostrar solo dependencias de producción
 
# Verificar que no hay secretos en las variables de entorno
docker inspect myapp_container | jq '.[0].Config.Env'
# No debería contener secretos
 
# Verificar las capas de la imagen en busca de secretos filtrados
docker history myapp:latest

Configuración del Security Context (Kubernetes)#

Si estás desplegando en Kubernetes, refuerza la seguridad del contenedor con un security context:

yaml
securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  runAsGroup: 1000
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities:
    drop:
      - ALL

Esto refuerza a nivel de orquestador lo que hemos configurado en el Dockerfile. Incluso si alguien hace push de una imagen con un usuario root, Kubernetes se negará a ejecutarla.

Workflow de desarrollo local#

Nada de esto debería ralentizar tu desarrollo local. Aquí está cómo mantengo el workflow de desarrollo rápido mientras uso Docker en producción:

yaml
# docker-compose.dev.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: builder  # Usar la etapa builder, no runner
    volumes:
      - .:/app          # Montar código fuente para recarga en caliente
      - /app/node_modules  # No montar sobre node_modules
    command: npm run dev
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development

La clave es target: builder — usa la etapa builder que tiene todas las devDependencies, combinado con un volume mount para recarga en caliente. Los node_modules del contenedor se preservan (no se sobreescriben por tu directorio local) usando el mount anónimo /app/node_modules.

bash
# Desarrollo
docker compose -f docker-compose.dev.yml up
 
# Producción
docker compose up --build

La lista de verificación#

Antes de poner un contenedor de Node.js en producción, pasa por esta lista:

  1. Multi-stage build — Los artefactos de build no están en la imagen final
  2. Imagen Alpine o distroless — No la imagen node:20 completa
  3. Usuario no-root — El contenedor se ejecuta como node, no root
  4. npm ci --omit=dev — Sin devDependencies en producción
  5. Manejo de señales de PID 1 — dumb-init o tini como entrypoint
  6. Health check — Docker sabe si tu app está realmente saludable
  7. .dockerignore — Sin .git, .env, o node_modules en el contexto de build
  8. Sin secretos en env vars — Usar Docker secrets o archivos montados
  9. Límites de recursos — Los límites de CPU y memoria están establecidos
  10. Sistema de archivos de solo lectura — El contenedor no puede escribir en su propio filesystem
  11. Escaneo de seguridad — Trivy o equivalente se ejecuta en CI
  12. Rotación de logs — Los logs no llenan el disco

Cada elemento de esta lista me costó tiempo y estrés aprenderlo. La brecha entre un "Dockerfile que funciona" y un "Dockerfile que no te despierta a las 3 AM" es más ancha de lo que la mayoría de la gente piensa. Pero es una brecha que solo necesitas cerrar una vez. Construye la plantilla correcta, entiende por qué importa cada línea, y aplícala a todos los proyectos a partir de ahora.

Artículos relacionados