StackPractices
intermediate Por Mathias Paulenko

Sobrescribe la Configuración de Docker Compose por Entorno

Cómo usar archivos override de Docker Compose para configuraciones por entorno: desarrollo, test, staging y producción, con profiles y secrets.

Temas: devops

Resumen

Docker Compose fusiona dos o más archivos en una sola configuración. Todo parte del docker-compose.yml base, que contiene la estructura de los servicios. Los archivos override la adaptan a cada entorno: puertos o volúmenes distintos en dev, límites de recursos en prod, quizá un Mailhog que solo existe en local. Compose fusiona los archivos en el orden en que los pasas —los posteriores sobrescriben o extienden a los anteriores— así que una sola config base cubre dev, test, staging y producción sin copiarla cuatro veces.

Mermaid flowchart LR diagram

Si lo que necesitas son dos stacks de Compose completamente separados para dev y producción en lugar de una base más diferencias, la receta de split dev/prod de Docker Compose cubre ese enfoque: los archivos override funcionan mejor cuando los entornos comparten la mayor parte de la configuración y solo cambian en detalles.

Cuándo Usarlo

  • Desarrollo local con hot reload y herramientas de debug
  • Ejecutar tests de forma aislada con configuración específica
  • Staging y producción con ajustes de producción (límites de recursos, sin debug)
  • Cuando necesitas servicios distintos por entorno (Mailhog en dev, SES en prod)
  • Gestionar aplicaciones multi-servicio en varios entornos

Cuándo NO Usarlo

  • Un solo entorno: con un único docker-compose.yml basta
  • Despliegues de producción: usa Kubernetes o ECS, no Docker Compose
  • Cuando las configuraciones difieren drásticamente: archivos separados son más claros que overrides
  • Cuando necesitas orquestación compleja: Compose es para dev/test, no para producción

Solución

Compose file base

# docker-compose.yml — Configuración base
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    restart: unless-stopped

volumes:
  db-data:

Override de desarrollo

# docker-compose.override.yml — Se usa por defecto con `docker compose up`
services:
  app:
    build:
      target: dev  # Target dev del multi-stage build
    ports:
      - "3000:3000"
      - "9229:9229"  # Debugger de Node.js
    environment:
      - NODE_ENV=development
      - LOG_LEVEL=debug
    volumes:
      - .:/app           # Hot reload
      - /app/node_modules  # Evita que el host sobrescribe el directorio
    command: npm run dev  # Sobrescribe el entrypoint

  db:
    ports:
      - "5432:5432"  # Expone el puerto para herramientas locales
    environment:
      POSTGRES_PASSWORD: devpassword

  # Servicios solo de dev
  mailhog:
    image: mailhog/mailhog:latest
    ports:
      - "1025:1025"  # SMTP
      - "8025:8025"  # Web UI

  adminer:
    image: adminer:latest
    ports:
      - "8080:8080"
    depends_on:
      - db

Override de producción

# docker-compose.prod.yml
services:
  app:
    build:
      target: production
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=warn
      - DATABASE_URL=postgres://myapp:${DB_PASSWORD}@db:5432/myapp
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 256M
      restart_policy:
        condition: on-failure
        max_attempts: 3
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

  db:
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 2G

  redis:
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 256M

Override de test

# docker-compose.test.yml
services:
  app:
    build:
      target: test
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgres://myapp:testpass@db:5432/testdb
    command: npm test
    depends_on:
      db:
        condition: service_healthy

  db:
    environment:
      POSTGRES_DB: testdb
      POSTGRES_PASSWORD: testpass
    # Sin volumen persistente: la base de datos de test es efímera
    volumes: !reset []

  # redis no existe en el entorno de test
  redis: !reset null

Ejecutar con distintos overrides

# Desarrollo (por defecto: docker-compose.yml + docker-compose.override.yml)
docker compose up

# Producción
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Test
docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit

# Compilar las imágenes de producción
docker compose -f docker-compose.yml -f docker-compose.prod.yml build

# Levantar un servicio concreto
docker compose -f docker-compose.yml -f docker-compose.prod.yml up app

# Ver la configuración ya fusionada
docker compose -f docker-compose.yml -f docker-compose.prod.yml config

Usar la variable COMPOSE_FILE

# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml

# Ahora `docker compose up` usa ambos archivos
docker compose up -d

Compose profiles

# docker-compose.yml
services:
  app:
    # Siempre se inicia
    image: my-app

  db:
    # Siempre se inicia
    image: postgres:16

  debug-tools:
    # Solo se inicia con --profile debug
    profiles:
      - debug
    image: nicolaka/netshoot
    network_mode: "service:app"

  load-test:
    # Solo se inicia con --profile loadtest
    profiles:
      - loadtest
    image: grafana/k6
    volumes:
      - ./tests:/scripts
# Arrancar sin profiles
docker compose up -d  # Solo app y db

# Arrancar con el profile debug
docker compose --profile debug up -d

# Arrancar con el profile de load test
docker compose --profile loadtest up

# Arrancar con varios profiles
docker compose --profile debug --profile loadtest up

Gestión de secrets

# docker-compose.prod.yml
services:
  app:
    secrets:
      - db-password
      - api-key
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db-password
      - API_KEY_FILE=/run/secrets/api-key

  db:
    secrets:
      - db-password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db-password

secrets:
  db-password:
    file: ./secrets/db-password.txt
  api-key:
    file: ./secrets/api-key.txt

Variables de entorno desde .env

# .env
DB_PASSWORD=supersecret
REDIS_URL=redis://redis:6379
JWT_SECRET=my-jwt-secret
# docker-compose.yml
services:
  app:
    environment:
      - DB_PASSWORD=${DB_PASSWORD}
      - REDIS_URL=${REDIS_URL}
      - JWT_SECRET=${JWT_SECRET}

Archivos .env por entorno

# .env.dev
DB_PASSWORD=devpassword
LOG_LEVEL=debug

# .env.prod
DB_PASSWORD=supersecret
LOG_LEVEL=warn
# Elige qué archivo env cargar: combina bien con los -f de override
docker compose --env-file .env.dev up
docker compose --env-file .env.prod -f docker-compose.yml -f docker-compose.prod.yml up -d
# O fija un archivo env por servicio: forma parte de la configuración,
# así que un override puede apuntarlo a otro archivo por entorno
services:
  app:
    env_file:
      - .env.dev

Comportamiento del merge entre archivos

# docker-compose.yml (base)
services:
  app:
    image: my-app:latest
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
# docker-compose.override.yml
services:
  app:
    # Los puertos se fusionan (no se reemplazan)
    ports:
      - "9229:9229"  # Se suma a 3000:3000
    # Las variables de entorno se fusionan
    environment:
      - NODE_ENV=development  # Sobrescribe production
      - DEBUG=true             # Se añade

Cómo Funciona el Merge

Compose fusiona los archivos de izquierda a derecha, con reglas distintas por tipo de atributo:

Tipo de atributoComportamientoEjemplo
Secuencias (ports, volumes, secrets)Se concatenan: el override añade, no reemplazaBase 3000:3000 + override 9229:9229 → ambos
Mapas (environment, labels, sysctls)Se fusionan por clave: gana el último valor de la misma claveNODE_ENV reemplazado, DEBUG añadido
Escalares (image, command, restart)Se reemplazan por completo con el archivo más recientecommand: npm run dev gana
depends_on, networksSe fusionan por nombre de servicio/redEl override añade condiciones

La Compose Specification ofrece dos mecanismos para salir de estas reglas:

  • !reset <valor> — elimina por completo el valor heredado. volumes: !reset [] descarta todos los volúmenes del base; redis: !reset null quita el servicio de la configuración fusionada (así es como el override de test elimina redis, más limpio que un profile falso).
  • !override — reemplaza un atributo por completo en lugar de fusionarlo, útil en ports cuando no quieres que los mapeos del base se filtren a producción.

Tres detalles que suelen sorprender:

  1. Todas las rutas relativas se resuelven respecto al directorio del archivo base, no del override. Si tu override vive en otra carpeta, mantén las rutas relativas al base o usa rutas absolutas.
  2. Ojo con los nombres de archivo si dependes de la carga automática. Compose v2 encuentra por sí solo compose.yaml, compose.yml, docker-compose.yaml o docker-compose.yml, más el .override que corresponda al que haya encontrado. Los nombres docker-compose.* siguen funcionando; compose.* es la convención nueva. Cualquier otro nombre solo carga con -f explícitos.
  3. El orden de los -f es el orden de override. -f a.yml -f b.yml significa que b gana en los conflictos, y por eso la gente suele apilar base → entorno → un archivo local sin trackear: los ajustes personales siempre quedan últimos.

Verifica qué fusionó Compose realmente antes de depurar cualquier otra cosa:

docker compose -f docker-compose.yml -f docker-compose.prod.yml config

Eso imprime la configuración resuelta: la fuente de verdad para “por qué está este puerto/variable/volumen aquí”.

Variantes

Usar extends para reutilizar definiciones de servicios

# docker-compose.yml
services:
  web:
    extends:
      file: docker-compose.base.yml
      service: app
    environment:
      - APP_ROLE=web

  worker:
    extends:
      file: docker-compose.base.yml
      service: app
    environment:
      - APP_ROLE=worker
    ports: []  # Sin puertos para worker

Docker Compose con dependencias por healthcheck

services:
  app:
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

Watch mode para desarrollo

# docker-compose.override.yml
services:
  app:
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json
docker compose watch  # Sincroniza cambios y reconstruye automáticamente

Buenas Prácticas

  • Mantén docker-compose.yml como base: no pongas configuración específica de entorno en él
  • Usa docker-compose.override.yml para dev: Compose lo carga automáticamente
  • Usa -f explícitos en todos los entornos que no sean dev: evita overrides accidentales de desarrollo
  • Guarda los secrets en .env o Docker secrets, nunca hardcodeados en el YAML
  • Marca los servicios opcionales con profiles para que docker compose up no lo levante todo
  • Combina depends_on con condition y healthchecks: la app conecta solo cuando la base de datos está realmente lista
  • Ejecuta docker compose config para verificar el resultado fusionado: detecta problemas de merge antes de levantar el stack

Errores Comunes

  • Olvidar que override.yml es automático: docker compose up fusiona docker-compose.override.yml siempre que existe — usa -f cuando quieras control sobre qué se carga.
  • Hardcodear secrets en el YAML: las contraseñas escritas en docker-compose.yml terminan en git. Guárdalas en .env o en Docker secrets.
  • No usar healthchecks: la app arranca antes de que la base de datos esté lista y llegan los errores de conexión. Pon healthchecks en todo lo que tus servicios esperan.
  • Usar Compose en producción: Compose está pensado para dev/test — la orquestación de producción de verdad significa Kubernetes, ECS o Swarm.
  • No limpiar los volúmenes: docker compose down conserva los volúmenes. Si quieres los datos fuera, es docker compose down -v.

Lectura Adicional

Preguntas frecuentes

¿Cómo fusiona Docker Compose los archivos?

Hace deep-merge: las listas (ports, volumes) se concatenan, los mapas (environment, labels) se fusionan por clave y ganan los valores del archivo posterior, y los escalares (image, command) se reemplazan por completo.

¿Cuál es el archivo override por defecto?

docker-compose.override.yml. Si existe en el mismo directorio, Compose lo fusiona automáticamente con docker-compose.yml al ejecutar docker compose up.

¿Puedo usar varios archivos override?

Sí. Encadena flags -f — los archivos posteriores ganan en los conflictos: docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.local.yml up. Un orden típico es base → entorno → archivo local sin trackear.

¿Qué diferencia hay entre compose.yaml y docker-compose.yml?

Compose v2 busca primero compose.yaml, luego compose.yml, docker-compose.yaml y por último docker-compose.yml, más el .override correspondiente al que encuentre. Los cuatro nombres funcionan; compose.yaml es la convención más nueva. Cualquier otro nombre solo se carga con -f explícitos.

¿Cómo elimino una clave o un servicio que define el archivo base?

Usa !reset en el override: volumes: !reset [] vacía una lista heredada y service_name: !reset null elimina un servicio completo de la configuración fusionada. Para campos de secuencia que quieras reemplazar en lugar de ampliar, !override cambia todo el atributo de una vez.

¿Cómo uso un .env distinto por entorno?

Pasa --env-file .env.prod por línea de comandos, o pon env_file: dentro del servicio para que un override lo repunte. Sin ninguna de las dos, Compose lee el .env del directorio de trabajo: un único archivo para todos los entornos.

¿Qué son los Compose profiles?

Los profiles marcan servicios como opcionales: un servicio con lista profiles solo arranca cuando pasas --profile <nombre>. Tus herramientas de debug y test pueden vivir en el mismo archivo sin levantarse cada vez que corres el stack.

¿Cómo paso variables de entorno?

Deja un .env junto a tu compose file: Compose lo detecta solo. Después referencia las variables con ${VAR_NAME} dentro del YAML.