Aurora Libros ya está declarada y parametrizada, pero en la vida real la misma plataforma se levanta de tres maneras distintas: en el portátil de quien programa, en el runner de integración continua y en el servidor. Las diferencias son pocas —construir en vez de descargar, exponer o no ciertos puertos, límites más altos— y la tentación es duplicar el fichero.

Duplicarlo es el peor camino: dos ficheros divergen en una semana. Compose ofrece dos mecanismos complementarios para evitarlo: perfiles, que activan servicios opcionales bajo demanda, y ficheros de override, que se fusionan sobre una base común.

Contenido

  1. Perfiles: servicios que no siempre quieres
  2. Perfiles y dependencias: las reglas
  3. El override automático: compose.override.yaml
  4. Overrides explícitos con varios -f
  5. Las reglas de fusión
  6. !reset y !override: sustituir en lugar de fusionar
  7. La estrategia de Aurora Libros: base, desarrollo y producción
  8. extends: reutilizar definiciones entre proyectos
  9. include:: componer ficheros de varios equipos
  10. Convenciones de nombres de fichero y de proyecto

  1. Perfiles: servicios que no siempre quieres

Un servicio con la clave profiles: no se levanta salvo que su perfil esté activo. Es la forma de tener herramientas opcionales en el mismo fichero sin que molesten.

  adminer:
    image: adminer:5
    profiles: [herramientas]
    ports:
      - "${ADMINER_PUERTO:-8081}:8080"
    environment:
      ADMINER_DEFAULT_SERVER: aurora-db
    depends_on:
      aurora-db: { condition: service_healthy }
    networks: [frontal, trasera]

  mailhog:
    image: mailhog/mailhog:v1.0.1
    profiles: [herramientas]
    ports:
      - "8025:8025"     # interfaz web para leer los correos capturados
    networks: [frontal]

  semillas:
    image: auroralibros/aurora-api:${AURORA_API_VERSION:-1.2.0}
    profiles: [datos]
    command: ["node", "semillas.js", "--catalogo-demo"]
    environment:
      DB_HOST: aurora-db
      DB_USER: ${DB_USUARIO:-aurora}
      DB_NAME: ${DB_NOMBRE:-aurora_libros}
    depends_on:
      aurora-db: { condition: service_healthy }
    restart: "no"
    networks: [trasera]

Un servicio puede pertenecer a varios perfiles (profiles: [herramientas, ci]), y se activa si alguno de ellos está activo.

docker compose up -d                                   # solo los 5 servicios base
docker compose --profile herramientas up -d            # base + adminer + mailhog
docker compose --profile herramientas --profile datos up -d   # todo
COMPOSE_PROFILES=herramientas,datos docker compose up -d      # equivalente
docker compose --profile "*" up -d                     # todos los perfiles
docker compose ps --services
docker compose --profile herramientas ps --services
aurora-api
aurora-cache
aurora-db
aurora-migraciones
aurora-web
(con el perfil herramientas)
adminer
aurora-api
aurora-cache
aurora-db
aurora-migraciones
aurora-web
mailhog

Con Adminer levantado, http://localhost:8081 te da una interfaz web para explorar el catálogo sin instalar nada en tu máquina. Y COMPOSE_PROFILES=herramientas en tu .env local activa el perfil permanentemente para ti, sin afectar a nadie más.

Un uso muy práctico de un perfil para tareas puntuales:

docker compose --profile datos run --rm semillas

  1. Perfiles y dependencias: las reglas

Aquí hay tres comportamientos que conviene tener claros, porque son fuente de sorpresas:

Situación Qué hace Compose
Servicio con perfil, perfil inactivo No se crea, ni siquiera si otro lo tiene en depends_on
Servicio sin perfil que depende de uno con perfil Error: depends_on a un servicio no habilitado
Servicio con perfil que depende de uno sin perfil Correcto: la dependencia se levanta automáticamente
Se nombra el servicio explícitamente (up adminer) Su perfil se activa solo, sin --profile
down sin --profile No para los servicios con perfil activo

Las dos filas importantes: nombrar un servicio con perfil lo activa implícitamente —docker compose up -d adminer funciona sin más—, y docker compose down deja huérfanos los servicios de perfil si no repites el perfil. Es la causa número uno de "he hecho down y sigue habiendo contenedores".

docker compose --profile herramientas down    # correcto
docker compose down --remove-orphans          # alternativa contundente

Regla de diseño: los servicios base nunca deben depender de un servicio con perfil. La dependencia va siempre en la dirección contraria.

  1. El override automático: compose.override.yaml

Si existe un fichero llamado compose.override.yaml (o .yml) junto al compose.yaml, Compose lo carga y lo fusiona automáticamente, sin que tengas que indicar nada.

docker compose up -d
# equivale exactamente a:
docker compose -f compose.yaml -f compose.override.yaml up -d

Es el mecanismo perfecto para el entorno de desarrollo: el compose.yaml describe la plataforma de forma neutra y el override añade lo que solo tiene sentido en tu máquina. Y como el override se ignora en el servidor —donde se usan otros ficheros explícitos—, no hay riesgo de que un puerto de depuración acabe en producción.

Cuando pases ficheros con -f, el override automático deja de cargarse: mandas tú.

  1. Overrides explícitos con varios -f

docker compose -f compose.yaml -f compose.prod.yaml up -d

Compose lee los ficheros en el orden indicado y fusiona cada uno sobre el resultado acumulado: el último gana. El orden importa y es una fuente clásica de errores; si inviertes los ficheros, el fichero base sobrescribe al específico.

Las rutas relativas se resuelven respecto al directorio del primer fichero, salvo que uses --project-directory. Si tus overrides viven en un subdirectorio, tenlo presente:

docker compose -f compose.yaml -f entornos/compose.prod.yaml --project-directory . up -d

  1. Las reglas de fusión

Lo que ocurre al fusionar depende del tipo de cada clave:

Tipo de clave Ejemplos Comportamiento
Escalar image, restart, user, container_name Sustituye: gana el último fichero
Mapa environment (forma mapa), labels, deploy, healthcheck Se fusionan clave a clave: gana el último en las coincidentes, se conservan las demás
Lista ports, volumes, dns, env_file, networks Se concatenan: aparecen los elementos de ambos
Lista tratada como bloque command, entrypoint, healthcheck.test Sustituye entera: no se concatena
environment en forma de lista - CLAVE=valor Se fusiona por nombre de variable, no se duplica

La distinción entre las dos primeras filas y la tercera explica el 90 % de las sorpresas. Un ejemplo:

# compose.yaml
  aurora-api:
    image: auroralibros/aurora-api:1.2.0
    environment:
      NODE_ENV: production
      LOG_NIVEL: info
    ports:
      - "3000:3000"
# compose.override.yaml
  aurora-api:
    environment:
      LOG_NIVEL: debug
    ports:
      - "9229:9229"
docker compose config | grep -A6 "aurora-api:"
    environment:
      LOG_NIVEL: debug
      NODE_ENV: production
    ports:
      - "3000:3000"
      - "9229:9229"

environment se ha fusionado (NODE_ENV sobrevive, LOG_NIVEL se sustituye) y ports se ha concatenado (los dos puertos). Que las listas se concatenen es cómodo para añadir, pero significa que no puedes quitar un puerto o un volumen desde un override... salvo con lo que viene ahora.

  1. !reset y !override: sustituir en lugar de fusionar

Compose v2.24 introdujo dos etiquetas YAML que resuelven justo ese problema:

# compose.prod.yaml
services:
  aurora-api:
    ports: !reset []          # elimina TODOS los puertos heredados
    volumes: !override        # sustituye la lista en vez de concatenarla
      - aurora-logs:/app/logs

!reset vacía la clave heredada (y con null la elimina por completo), y !override sustituye el valor en lugar de fusionarlo. Son la única forma limpia de quitar en un override lo que la base añade, y evitan el antiguo apaño de mantener dos ficheros base casi idénticos.

  1. La estrategia de Aurora Libros: base, desarrollo y producción

La estructura recomendada son tres ficheros con responsabilidades bien separadas:

Fichero Contenido Cuándo se usa
compose.yaml La plataforma neutra: servicios, redes, volúmenes, dependencias, sondas Siempre
compose.override.yaml Comodidades de desarrollo Automático en local
compose.prod.yaml Endurecimiento y ajustes de servidor Explícito con -f

El base es el de la lección 04-04, con una regla añadida: nada específico de un entorno. Ni build, ni puertos de depuración, ni NODE_ENV fijado a mano.

# compose.override.yaml — desarrollo local (automático)
services:

  aurora-api:
    build:                       # construir desde el código, no descargar
      context: ./api
      dockerfile: Dockerfile
    environment:
      NODE_ENV: development
      LOG_NIVEL: debug
    ports:
      - "3000:3000"              # atacar la API directamente
      - "9229:9229"              # inspector de Node (lección 04-07)
    volumes:
      - ./api/src:/app/src       # código montado desde el host
    restart: "no"                # que un fallo no se esconda en un bucle

  aurora-db:
    ports:
      - "127.0.0.1:5432:5432"    # psql desde el host
    networks: [trasera, frontal] # necesaria para poder publicar el puerto

  adminer:
    profiles: [herramientas]
    image: adminer:5
    ports: ["8081:8080"]
    environment: { ADMINER_DEFAULT_SERVER: aurora-db }
    networks: [frontal, trasera]

El detalle de los bind mounts de código y de la recarga automática es la lección 04-07; aquí solo interesa dónde vive esa configuración: en el override, nunca en la base.

# compose.prod.yaml — servidor
services:

  aurora-api:
    image: auroralibros/aurora-api:${AURORA_API_VERSION:?fija la versión a desplegar}
    build: !reset null           # en el servidor no se construye: solo se descarga
    environment:
      NODE_ENV: production
      LOG_NIVEL: warn
    ports: !reset []             # sin acceso directo: todo pasa por el proxy
    restart: always
    deploy:
      resources: { limits: { memory: 512M, cpus: "2.0" } }
    logging:
      driver: json-file
      options: { max-size: "50m", max-file: "5" }

  aurora-db:
    restart: always
    deploy:
      resources: { limits: { memory: 2G, cpus: "2.0" }, reservations: { memory: 1G } }

  aurora-web:
    restart: always
    ports:
      - "80:80"
    deploy:
      resources: { limits: { memory: 256M, cpus: "1.0" } }

Fíjate en tres decisiones: la versión de la imagen es obligatoria (:?), porque desplegar latest es desplegar cualquier cosa; build: !reset null garantiza que el servidor jamás construya; y ports: !reset [] elimina el 3000 heredado, no lo añade.

# Desarrollo: el override se carga solo
docker compose up -d --build

# Producción
docker compose -f compose.yaml -f compose.prod.yaml -p aurora-prod up -d --wait

# CI: ni override de desarrollo ni ajustes de servidor
docker compose -f compose.yaml -f compose.ci.yaml up -d --wait

Verifica siempre un despliegue antes de ejecutarlo:

docker compose -f compose.yaml -f compose.prod.yaml config | grep -E "image:|restart:|memory:"
    image: auroralibros/aurora-api:1.2.0
    restart: always
      memory: "536870912"

  1. extends: reutilizar definiciones entre proyectos

Mientras que los overrides fusionan ficheros completos, extends importa un servicio concreto, incluso desde otro fichero o proyecto:

# comunes/servicios-base.yaml
services:
  nodo-base:
    image: node:22-alpine
    working_dir: /app
    user: "1001:1001"
    init: true
    restart: unless-stopped
# compose.yaml
services:
  aurora-api:
    extends:
      file: comunes/servicios-base.yaml
      service: nodo-base
    image: auroralibros/aurora-api:1.2.0    # lo local gana
    environment:
      PORT: "3000"
Ventaja Limitación
Comparte definiciones entre proyectos distintos No importa depends_on, volumes_from ni links
No exige orden de -f Solo un extends por servicio, aunque puede encadenarse
Documenta explícitamente el origen Las rutas relativas se resuelven desde el fichero extendido

La limitación de depends_on es deliberada: una dependencia solo tiene sentido dentro del proyecto que la define. Usa extends para plantillas de configuración (imagen base, usuario, política de reinicio) y anclas YAML para repetición dentro del mismo fichero.

  1. include:: componer ficheros de varios equipos

include: va un paso más allá: incorpora ficheros de Compose completos, con sus servicios, redes y volúmenes, como si estuvieran escritos en el tuyo.

# compose.yaml
include:
  - path: ../plataforma-comun/compose.observabilidad.yaml
  - path: ./pagos/compose.yaml
    env_file: ./pagos/.env          # cada fichero incluido resuelve SUS variables
    project_directory: ./pagos      # y sus rutas relativas

services:
  aurora-api:
    depends_on:
      - coleccionista-metricas       # servicio definido en el fichero incluido

La diferencia con -f es importante: con varios -f los ficheros se fusionan (se espera que hablen de los mismos servicios), mientras que include agrega ficheros independientes que aportan servicios propios. Cada fichero incluido mantiene su propio contexto de rutas y de variables, lo que permite que el equipo de pagos mantenga su compose.yaml sin coordinarse con el de plataforma. A cambio, los nombres de servicio deben ser únicos en el conjunto.

  1. Convenciones de nombres de fichero y de proyecto

Fichero Uso
compose.yaml Base neutra. Siempre
compose.override.yaml Desarrollo local. Automático
compose.prod.yaml Servidor
compose.ci.yaml Integración continua
compose.pruebas.yaml Pruebas de integración con datos efímeros

Y el nombre de proyecto por entorno, para que dos pilas convivan en la misma máquina sin pisarse:

docker compose -p aurora-dev up -d
docker compose -f compose.yaml -f compose.prod.yaml -p aurora-prod up -d
docker compose ls
NAME          STATUS       CONFIG FILES
aurora-dev    running(5)   /home/joan/aurora-libros/compose.yaml,...override.yaml
aurora-prod   running(5)   /home/joan/aurora-libros/compose.yaml,...prod.yaml

Dos pilas completas, con contenedores, redes y volúmenes prefijados de forma distinta, sin compartir un solo dato. Lo único que sigue siendo global de la máquina son los puertos publicados: por eso el override de desarrollo usa 8080 y el de producción, 80.

Para no teclear los -f cien veces al día, fija el conjunto en el entorno:

# .env.prod (cargado con --env-file, o exportado en el servidor)
COMPOSE_FILE=compose.yaml:compose.prod.yaml
COMPOSE_PROJECT_NAME=aurora-prod

Errores Comunes y Consejos

Invertir el orden de los -f. -f compose.prod.yaml -f compose.yaml hace que la base sobrescriba a producción. El específico va siempre el último.

Esperar que un override quite un puerto. Las listas se concatenan. Para eliminar, !reset o !override.

Hacer down sin repetir el perfil. Los servicios con perfil se quedan corriendo. Repite --profile o usa --remove-orphans.

Poner build en el fichero base. Un servidor acabará construyendo la imagen en vez de descargar la versión probada. build va en el override de desarrollo.

Desplegar sin fijar la etiqueta de la imagen. latest en producción significa que dos servidores pueden estar corriendo código distinto. Usa ${AURORA_API_VERSION:?...}.

Suponer que extends trae las dependencias. No importa depends_on: hay que redeclararlo en el servicio que extiende.

Consejo: antes de cualquier despliegue, docker compose -f ... config y léelo. Treinta segundos de lectura evitan desplegar un puerto de depuración abierto a Internet.

Ejercicios

Ejercicio 1. Crea un compose.override.yaml que, para aurora-api, cambie LOG_NIVEL a debug, añada el puerto 9229 y monte ./api/src. Sin levantar nada, demuestra con docker compose config que NODE_ENV heredado sobrevive, que hay dos puertos y que el volumen aparece junto a los de la base.

Ejercicio 2. Añade un servicio adminer bajo el perfil herramientas y responde con comandos: (a) ¿aparece en docker compose ps --services sin el perfil?, (b) ¿qué pasa si lo nombras explícitamente en up sin --profile?, y (c) ¿qué ocurre al hacer docker compose down sin el perfil? Propón la forma correcta de bajar todo.

Ejercicio 3. Escribe un compose.prod.yaml que elimine por completo la publicación del puerto 3000 de la API, exija la variable AURORA_API_VERSION e impida construir en el servidor. Demuestra con config que el resultado no contiene ni build ni el puerto 3000, y que falla de forma clara si no se define la versión.

Soluciones

Solución 1.

# compose.override.yaml
services:
  aurora-api:
    environment:
      LOG_NIVEL: debug
    ports:
      - "9229:9229"
    volumes:
      - ./api/src:/app/src
docker compose config | sed -n '/aurora-api:/,/aurora-cache:/p' | grep -E "NODE_ENV|LOG_NIVEL|published|source"
      LOG_NIVEL: debug
      NODE_ENV: production
        published: "3000"
        published: "9229"
        source: /home/joan/aurora-libros/api/src

Se ven las tres reglas en acción: environment es un mapa, así que NODE_ENV sobrevive y solo se sustituye la clave coincidente; ports y volumes son listas, así que se concatenan. Fíjate también en que config ha normalizado la ruta relativa ./api/src a ruta absoluta: esa normalización es justo lo que hace fiable la revisión previa a un despliegue.

Solución 2.

# (a)
docker compose ps --services | grep adminer || echo "adminer NO está activo"
# (b)
docker compose up -d adminer
docker compose ps --services | grep adminer
# (c)
docker compose down
docker ps --format "{{.Names}}" | grep adminer
adminer NO está activo
adminer
aurora-libros-adminer-1

(a) Sin el perfil, el servicio ni siquiera se considera. (b) Nombrarlo lo activa implícitamente: no hace falta --profile si lo pides por su nombre. (c) Y aquí la sorpresa: tras docker compose down, adminer sigue vivo, porque down sin perfil no lo contempla. Es un contenedor huérfano que sigue ocupando el puerto 8081 y con acceso a la base de datos.

docker compose --profile herramientas down    # forma correcta
docker compose down --remove-orphans          # alternativa que barre todo

Solución 3.

# compose.prod.yaml
services:
  aurora-api:
    image: auroralibros/aurora-api:${AURORA_API_VERSION:?fija la versión a desplegar}
    build: !reset null
    ports: !reset []
    environment:
      NODE_ENV: production
      LOG_NIVEL: warn
    restart: always
AURORA_API_VERSION=1.2.0 docker compose -f compose.yaml -f compose.prod.yaml config \
  | sed -n '/aurora-api:/,/aurora-cache:/p' | grep -E "image:|build|3000" || echo "sin build ni 3000"
unset AURORA_API_VERSION
docker compose -f compose.yaml -f compose.prod.yaml config --quiet; echo "código: $?"
    image: auroralibros/aurora-api:1.2.0
error while interpolating services.aurora-api.image: required variable
AURORA_API_VERSION is missing a value: fija la versión a desplegar
código: 1

La imagen queda fijada a una etiqueta concreta, no aparecen ni build ni el puerto 3000!reset los ha eliminado, cosa que una simple redefinición no habría conseguido con las listas— y sin la variable el despliegue falla antes de tocar nada, con un mensaje que dice qué hacer. Ese fallo temprano es exactamente lo que quieres en un pipeline: mejor un config --quiet en rojo que un servidor sirviendo una versión imprevista.

Conclusión

Un mismo proyecto sirve ya para tres entornos sin duplicar un solo servicio. Los perfiles te dan servicios opcionales bajo demanda —adminer y mailhog bajo herramientas, semillas bajo datos—, activables con --profile, con COMPOSE_PROFILES o simplemente nombrando el servicio, con dos reglas que se olvidan: un servicio base nunca debe depender de uno con perfil, y down sin repetir el perfil deja contenedores huérfanos corriendo.

Los overrides cubren el resto: el compose.override.yaml automático para las comodidades de desarrollo y la combinación explícita con varios -f donde el último gana. Dominas las reglas de fusión —escalares que se sustituyen, mapas que se fusionan clave a clave, listas que se concatenan y bloques como command o healthcheck.test que se reemplazan enteros— y sabes salir del callejón de las listas con !reset y !override, la forma limpia de quitar un puerto de depuración en producción. La estrategia queda clara: un compose.yaml neutro sin build ni puertos de depuración, un override de desarrollo y un compose.prod.yaml con etiqueta de imagen obligatoria, restart: always, límites propios y logging configurado; y docker compose config leído antes de cada despliegue.

Sabes además cuándo usar cada herramienta de reutilización: anclas YAML dentro de un fichero, extends para plantillas de servicio entre proyectos —recordando que no arrastra depends_on—, e include: para agregar ficheros completos que mantienen otros equipos, cada uno con su contexto de rutas y variables. Y la convención de nombres de fichero y de proyecto (-p aurora-dev, -p aurora-prod) que permite que dos pilas convivan en la misma máquina sin compartir ni un byte, con COMPOSE_FILE para no repetir los -f.

Queda la parte que más vas a usar: el día a día. En la lección siguiente, Desarrollo Local con Docker Compose, convertirás ese override de desarrollo en un entorno de trabajo real: bind mount del código con la solución al eterno problema de node_modules, recarga automática con --watch de Node 22 y con el bloque nativo develop.watch de Compose, depuración paso a paso desde VS Code con el inspector en el puerto 9229, pruebas en contenedores efímeros con su base de datos en tmpfs, datos de desarrollo y reseteo en un comando, el rendimiento de los bind mounts en macOS y Windows, y un Makefile con los atajos del equipo.

Docker: De Principiante a Avanzado

Módulo 1: Introducción a Docker

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados