Ejecutar aplicaciones multi-contenedor en producción requiere mucho más que un archivo docker-compose.yml básico. Aunque Docker Compose se asocia comúnmente con el desarrollo local, es una herramienta poderosa para desplegar cargas de trabajo en producción en entornos de un solo host y clústeres pequeños cuando se configura correctamente. Esta guía te lleva paso a paso a endurecer tu configuración de Docker Compose para producción con health checks, políticas de reinicio, límites de recursos, gestión de secretos, logging centralizado y proxy inverso Nginx con terminación SSL.

Requisitos Previos

  • Servidor Linux con Docker Engine 24+ y Docker Compose v2 instalados
  • Nombre de dominio apuntando a la IP pública de tu servidor
  • Familiaridad básica con conceptos de Docker (imágenes, contenedores, volúmenes, redes)
  • Certificado SSL o disposición para usar Let’s Encrypt con Certbot
  • Acceso SSH a tu servidor de producción
  • Al menos 2 GB de RAM y 2 núcleos de CPU disponibles para tu stack

Configuración de Docker Compose para Producción

Un docker-compose.yml listo para producción difiere significativamente de una configuración de desarrollo. Necesitas políticas de reinicio explícitas, health checks, restricciones de recursos y sistemas de archivos de solo lectura donde sea posible.

Políticas de Reinicio

Las políticas de reinicio aseguran que tus contenedores se recuperen automáticamente de crashes, OOM kills o reinicios del host:

services:
  web:
    image: myapp:latest
    restart: unless-stopped
    deploy:
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
        window: 120s

Usa unless-stopped para la mayoría de los servicios. Esto reinicia contenedores después de crashes y reinicios del host, pero respeta las paradas manuales. Para servicios críticos que deben ejecutarse siempre, usa always. Evita no en producción — deja los contenedores caídos muertos hasta la intervención manual.

Health Checks

Los health checks permiten a Docker monitorear si tu aplicación realmente está funcionando, no solo si el proceso está ejecutándose:

services:
  api:
    image: myapp-api:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

El start_period da a tu aplicación tiempo para inicializarse antes de que Docker comience a contar las verificaciones fallidas. Configúralo más alto que el tiempo promedio de arranque de tu aplicación. Usa endpoints de salud específicos en lugar de verificar si un puerto está abierto — un proceso puede escuchar en un puerto mientras está en un estado roto.

Límites de Recursos

Sin límites de recursos, un solo contenedor descontrolado puede consumir toda la memoria del sistema y hacer caer todo:

services:
  api:
    image: myapp-api:latest
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

Configura limits al máximo que un servicio debería usar y reservations para garantizar recursos mínimos. Monitorea tus contenedores con docker stats durante una semana antes de establecer los límites finales. Límites demasiado ajustados causan OOM kills y rendimiento degradado.

Sistemas de Archivos de Solo Lectura

Minimiza la superficie de ataque ejecutando contenedores con sistemas de archivos raíz de solo lectura:

services:
  api:
    image: myapp-api:latest
    read_only: true
    tmpfs:
      - /tmp
      - /var/run
    volumes:
      - app-data:/app/data

Esto previene que procesos maliciosos escriban en el sistema de archivos del contenedor. Usa tmpfs para directorios que necesitan acceso temporal de escritura y volúmenes nombrados para datos persistentes.

Gestión de Secretos y Variables de Entorno

Las credenciales codificadas directamente en tu archivo compose son un riesgo de seguridad. Docker Compose soporta múltiples enfoques para la gestión de secretos.

Usando Archivos .env

Crea un archivo .env junto a tu archivo compose:

# .env - NUNCA hagas commit de este archivo
POSTGRES_PASSWORD=tu_contraseña_segura_aqui
API_SECRET_KEY=otro_valor_seguro
REDIS_PASSWORD=contraseña_redis_aqui

Referéncialos en tu archivo compose:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

Agrega .env a tu .gitignore inmediatamente. Para pipelines CI/CD, inyecta variables de entorno desde tu gestor de secretos (GitHub Secrets, AWS Secrets Manager o HashiCorp Vault).

Docker Secrets

Para un manejo más seguro, usa Docker secrets que se montan como archivos en lugar de variables de entorno:

secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  db:
    image: postgres:16
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password

Muchas imágenes oficiales de Docker soportan la convención del sufijo _FILE, leyendo el secreto desde un archivo montado en lugar de una variable de entorno. Esto mantiene las credenciales fuera de la salida de docker inspect y los listados de variables de entorno del proceso.

Proxy Inverso y SSL con Nginx

Exponer puertos de aplicación directamente es inseguro e inflexible. Usa Nginx como proxy inverso con terminación SSL:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - certbot-webroot:/var/www/certbot:ro
      - certbot-certs:/etc/letsencrypt:ro
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  certbot:
    image: certbot/certbot
    volumes:
      - certbot-webroot:/var/www/certbot
      - certbot-certs:/etc/letsencrypt
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'"

volumes:
  certbot-webroot:
  certbot-certs:

La configuración de Nginx debe hacer proxy hacia tus servicios internos:

upstream api_backend {
    server api:8080;
}

server {
    listen 80;
    server_name example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://api_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Observa que solo el servicio Nginx expone puertos al host. Todos los demás servicios se comunican a través de la red interna de Docker, reduciendo significativamente la superficie de ataque.

Logging y Monitoreo

Los despliegues en producción necesitan logging estructurado con rotación para prevenir el agotamiento del disco.

Logging JSON File con Rotación

services:
  api:
    image: myapp-api:latest
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
        tag: "{{.Name}}"

Sin max-size y max-file, los logs de Docker crecen indefinidamente y pueden llenar tu disco. El driver json-file es el predeterminado y funciona bien para la mayoría de las configuraciones. Para entornos multi-host, considera reenviar logs a un sistema centralizado.

Reenvío a Sistemas Externos

Para observabilidad en producción, reenvía logs a un servicio de agregación:

services:
  api:
    image: myapp-api:latest
    logging:
      driver: syslog
      options:
        syslog-address: "tcp://logserver:514"
        tag: "myapp-api"

Las alternativas incluyen los drivers fluentd y gelf para stacks ELK o Graylog. Sea cual sea tu elección, siempre configura la rotación de logs a nivel del daemon de Docker como red de seguridad en /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Monitoreo Básico

Agrega monitoreo de contenedores con un stack ligero:

services:
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    deploy:
      resources:
        limits:
          memory: 256M

  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
    deploy:
      resources:
        limits:
          memory: 128M

Comparativa: Docker Compose vs Kubernetes vs Docker Swarm

CaracterísticaDocker ComposeKubernetesDocker Swarm
Complejidad de configuraciónBajaAltaMedia
Soporte multi-hostNo (un solo host)Sí (clúster)Sí (clúster)
Auto-escaladoNoSí (HPA)Limitado
Descubrimiento de serviciosBasado en DNSDNS + IngressBasado en DNS
Gestión de secretosBasada en archivosIntegrada (etcd)Integrada
Actualizaciones progresivasManualIntegradaIntegrada
Health checksSí (liveness/readiness)
Límites de recursosSí (requests/limits)
Curva de aprendizajeSuavePronunciadaModerada
Ideal paraUn solo host, equipos pequeñosGran escala, multi-equipoClústeres pequeños

Docker Compose es la elección correcta cuando despliegas en un solo servidor o un número pequeño de servidores, tu equipo es pequeño y no necesitas auto-escalado. Muchos productos SaaS exitosos se ejecutan completamente sobre Docker Compose en producción.

Escenario del Mundo Real

Estás desplegando una aplicación web compuesta por una API Node.js, una base de datos PostgreSQL, una caché Redis y un proxy inverso Nginx. El servidor tiene 4 GB de RAM y 2 núcleos de CPU.

Aquí está el archivo compose completo de producción:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - certbot-certs:/etc/letsencrypt:ro
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "0.25"
          memory: 64M
    logging:
      driver: json-file
      options:
        max-size: "5m"
        max-file: "3"

  api:
    image: myapp-api:1.2.3
    env_file: .env
    secrets:
      - db_password
      - api_key
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_DB: myapp
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 1G
        reservations:
          memory: 256M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redisdata:/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
    deploy:
      resources:
        limits:
          cpus: "0.25"
          memory: 256M
    logging:
      driver: json-file
      options:
        max-size: "5m"
        max-file: "3"

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    file: ./secrets/api_key.txt

volumes:
  pgdata:
  redisdata:
  certbot-certs:

Despliega con:

docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs --tail=50

Esta configuración asigna aproximadamente 1.8 GB de los 4 GB disponibles, dejando margen para el sistema operativo y picos de uso.

Errores Comunes y Casos Especiales

El orden de arranque de contenedores no garantiza disponibilidad. Usar depends_on solo espera a que el contenedor se inicie, no a que el servicio interno esté listo. Siempre combina depends_on con condition: service_healthy y health checks adecuados.

Los contenedores huérfanos se acumulan. Cuando eliminas un servicio de tu archivo compose, el contenedor antiguo sigue ejecutándose. Siempre ejecuta docker compose up -d --remove-orphans para limpiar contenedores obsoletos.

Los permisos de volúmenes causan fallos silenciosos. Si tu contenedor se ejecuta como usuario no-root, el volumen montado puede no ser escribible. Pre-crea volúmenes con la propiedad correcta o usa un patrón de contenedor init.

Docker Compose no descarga nuevas imágenes por defecto al reconstruir. Ejecutar docker compose up -d reutiliza imágenes en caché. Usa docker compose pull && docker compose up -d para asegurar que despliegas la última versión.

La expansión de variables del archivo .env puede generar conflictos. Docker Compose interpola ${VAR} en el archivo compose antes de pasarlo al contenedor. Si la configuración de tu aplicación usa caracteres $, escápalos con $$ en el archivo compose.

La rotación de logs debe configurarse por servicio y globalmente. Las opciones de log a nivel de servicio no anulan los valores predeterminados del daemon para otros contenedores. Configura ambos para cobertura completa.

Las redes bridge tienen problemas de resolución DNS con guiones bajos. Los nombres de servicio con guiones bajos pueden no resolverse correctamente en versiones antiguas de Docker. Usa guiones en los nombres de servicio para máxima compatibilidad.

Resumen

  • Usa restart: unless-stopped y health checks en cada servicio de producción
  • Configura límites de memoria y CPU con deploy.resources para prevenir contenedores descontrolados
  • Gestiona secretos con Docker secrets o archivos .env — nunca codifiques credenciales directamente
  • Coloca Nginx como proxy inverso frente a los servicios de aplicación con terminación SSL
  • Configura rotación de logs con max-size y max-file para prevenir agotamiento de disco
  • Usa depends_on con condition: service_healthy para un ordenamiento confiable de arranque
  • Ejecuta docker compose up -d --remove-orphans para limpiar contenedores obsoletos en cada despliegue
  • Docker Compose está listo para producción en despliegues de un solo host y equipos pequeños

Artículos Relacionados