Saltar al contenido

Flujos de trabajo con Docker que realmente funcionan

Deja de pelearte con Docker en local: builds multi-stage, bind mounts, overrides de Compose y los patrones que hacen que los contenedores se sientan nativos.

4 min de lectura
Configuración de Docker Compose con build multi-stage y overrides de desarrollo

Docker debería hacer que tu entorno sea reproducible. En la práctica, muchos equipos terminan con Dockerfiles que funcionan en CI, se rompen en local y requieren un ritual de conocimiento tribal para poder ejecutarlos. La herramienta es poderosa; la brecha está en cómo se usa.

El build multi-stage no es negociable

Un Dockerfile de una sola etapa que copia todo en la imagen es la causa raíz de la mayoría de las frustraciones con Docker. Genera imágenes grandes, lentas de reconstruir e imposibles de optimizar tanto para desarrollo como para producción.

dockerfiledockerfile
# ❌ Single-stage — ships dev dependencies, source maps, everything
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
 
# ✅ Multi-stage — lean production image, proper layer caching
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
 
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]

La etapa deps se cachea por separado. Si package.json no cambia, Docker reutiliza la capa: tu npm ci solo se ejecuta cuando las dependencias cambian de verdad.

Overrides de Docker Compose para desarrollo local

Ejecutar el mismo docker-compose.yml en desarrollo y producción obliga a compromisos dolorosos. Mejor usa un archivo base más overrides específicos por entorno.

ymlyaml
# docker-compose.yml (base — shared config)
services:
  api:
    build:
      context: .
      target: deps # stop at deps stage for dev
    environment:
      DATABASE_URL: postgres://postgres:password@db:5432/app
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
ymlyaml
# docker-compose.override.yml (dev — auto-merged by Compose)
services:
  api:
    build:
      target: deps
    command: npm run dev # hot reload in dev
    volumes:
      - .:/app # bind mount source
      - /app/node_modules # keep container's node_modules
    environment:
      NODE_ENV: development
    ports:
      - "3000:3000"
      - "9229:9229" # Node debugger
ymlyaml
# docker-compose.prod.yml (production — explicit override)
services:
  api:
    build:
      target: runner
    restart: unless-stopped
    environment:
      NODE_ENV: production

Ejecuta docker compose up en local (fusiona el override automáticamente), docker compose -f docker-compose.yml -f docker-compose.prod.yml up en producción.

Bind mounts y el truco de node_modules

El punto de dolor más común en desarrollo local: haces bind mount de tu código fuente en el contenedor, pero ahora los node_modules del contenedor (compilados para Linux) se sobrescriben con los node_modules de tu Mac (compilados para macOS/Windows).

La solución es un volumen anónimo que enmascare el bind mount en node_modules:

ymlyaml
volumes:
  - .:/app # sync source code
  - /app/node_modules # hide host's node_modules, keep container's

El volumen vacío con destino /app/node_modules tiene prioridad sobre el bind mount padre para esa ruta específica. Tu código fuente se sincroniza; tus binarios específicos de plataforma permanecen intactos.

Caché de capas en CI

Los tiempos de build se acumulan. Un Dockerfile mal ordenado vuelve a descargar todas las dependencias en cada cambio de código. La regla: ordena las capas de menos cambiadas a más cambiadas.

dockerfiledockerfile
FROM node:22-alpine AS builder
WORKDIR /app
 
# 1. Copy only the manifest — cached until deps change
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
 
# 2. Copy generated/config files that rarely change
COPY tsconfig.json ./
COPY prisma ./prisma
RUN npx prisma generate
 
# 3. Copy source — changes on every commit
COPY src ./src
RUN npm run build

En GitHub Actions, usa cache-from y cache-to con el driver de registro para persistir la caché de capas entre ejecuciones:

ymlyaml
- name: Build image
  uses: docker/build-push-action@v6
  with:
    context: .
    target: runner
    cache-from: type=registry,ref=ghcr.io/org/app:cache
    cache-to: type=registry,ref=ghcr.io/org/app:cache,mode=max
    tags: ghcr.io/org/app:${{ github.sha }}

Health checks y apagado graceful

Los servicios que declaran health checks permiten que Compose (y Kubernetes) esperen a que estén listos antes de arrancar los servicios dependientes. Saltárselos causa condiciones de carrera que se manifiestan como fallos de test inconsistentes.

dockerfiledockerfile
# In your Dockerfile
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

El apagado graceful importa en los contenedores porque docker stop envía SIGTERM. Si tu proceso no lo maneja, Docker lo mata por la fuerza tras el timeout, dejando caer peticiones en vuelo.

tstypescript
// Handle SIGTERM for graceful shutdown
const server = app.listen(3000);
 
async function shutdown() {
  console.log("SIGTERM received, shutting down gracefully");
  server.close(async () => {
    await db.end(); // close DB connections
    process.exit(0);
  });
  // Force exit if graceful shutdown takes too long
  setTimeout(() => process.exit(1), 10_000);
}
 
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Puntos clave

  1. Los builds multi-stage son la base — separa las etapas de instalación de dependencias, build y runtime para imágenes más pequeñas y mejor caché
  2. Usa overrides de Compose, no variables de entorno, para las diferencias entre dev y prod — el patrón de override mantiene ambos entornos explícitos y revisables
  3. El truco del volumen anónimo para node_modules evita colisiones de binarios específicos de plataforma con bind mounts
  4. Ordena las capas del Dockerfile de menos cambiadas a más cambiadas — copiar los manifiestos de paquetes antes que el código fuente es la optimización de caché con mayor retorno de inversión
  5. Los health checks y el apagado graceful son requisitos de producción — depends_on con condiciones de salud previene condiciones de carrera; el manejo de SIGTERM evita peticiones perdidas
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX