0%
Docker Compose

Compose

Una aplicación entera descrita en un único archivo.

10-15 min

Docker Compose

Una aplicación real casi nunca es un solo contenedor. Es una API, una base de datos, una caché, a veces un worker asíncrono y un proxy en el frontal. Cinco contenedores, es decir, cinco docker run, con los puertos correctos, las variables correctas, la red correcta, en el orden correcto, y acordándose de todo eso mañana por la mañana.

Aguanta un tiempo, a base de scripts de shell copiados de un proyecto a otro. Pero siempre se degrada de la misma manera: el script del compañero no usa la misma versión de Postgres, nadie recuerda por qué el worker lleva tal opción oscura, la incorporación de un nuevo desarrollador cuesta media jornada y la contraseña de la base de datos queda en el historial de tres terminales. El problema no es el número de comandos. Es que no existe ninguna descripción única y verificable de lo que significa «la aplicación».

Docker Compose responde exactamente a esa carencia. Un archivo YAML declara el conjunto de servicios, sus imágenes, sus variables, sus volúmenes y sus dependencias. Ese archivo vive en el repositorio Git, junto al código. Basta con clonar el proyecto y escribir un solo comando para obtener la pila completa, idéntica para todo el mundo, y git log explica por qué se añadió tal opción.

Unas palabras sobre la sintaxis antes de empezar, porque suele crear confusión. La forma moderna es docker compose, en dos palabras: es un plugin integrado en el cliente de Docker. La forma antigua docker-compose, con guion, es un binario Python independiente, la «v1», cuyo desarrollo está detenido desde 2023. Se parecen, pero la v1 desconoce parte de la sintaxis actual. Todo lo que sigue usa docker compose.

Un primer archivo, servicio por servicio

El archivo se llama compose.yaml y está en la raíz del proyecto. Es el nombre recomendado por la especificación de Compose; docker-compose.yml sigue siendo reconocido por compatibilidad, y lo encontrarás a menudo en proyectos existentes.

# compose.yaml
# Sin clave "version:" al principio: está obsoleta y no tiene efecto.

services:
  # ---------- La aplicación ----------
  api:
    build:
      context: .
      dockerfile: Dockerfile
    image: mon-api:dev          # nombre asignado a la imagen construida
    ports:
      - "3000:3000"             # host:contenedor
    environment:
      NODE_ENV: development
      # "db" y "cache" son nombres de SERVICIOS: se resuelven
      # por DNS en la red de la pila. No hay que escribir ninguna IP.
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  # ---------- La base de datos ----------
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      # Volumen con nombre: los datos sobreviven a la eliminación del contenedor
      - db-data:/var/lib/postgresql/data
    healthcheck:
      # El doble $$ escapa la interpolación de Compose: la variable debe
      # resolverse DENTRO del contenedor, no por Compose.
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
    restart: unless-stopped

  # ---------- La caché ----------
  cache:
    image: redis:7-alpine
    command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
    volumes:
      - cache-data:/data
    restart: unless-stopped

volumes:
  db-data:
  cache-data:

Hay dos detalles de este archivo que conviene destacar de inmediato.

La base de datos no publica ningún puerto: no hay ninguna clave ports bajo db. Aun así, la API puede alcanzarla perfectamente en la dirección db:5432, porque ambos servicios comparten una red interna creada automáticamente por Compose. Publicar 5432:5432 expondría tu base de datos en todas las interfaces de la máquina, algo que solo es necesario para conectar un cliente gráfico desde el host.

Y los servicios se designan por su nombre, nunca por una dirección IP. Las IP de los contenedores cambian en cada recreación; los nombres, no. La siguiente lección detalla este mecanismo de red.

El ciclo de comandos diario

Compose funciona por convergencia: describes el estado deseado y él calcula las acciones que hay que realizar. up no lo relanza todo, solo toca lo que ha cambiado.

docker compose up -d              # arrancar en segundo plano
docker compose up -d --build      # reconstruir antes las imágenes locales
docker compose up -d --wait       # esperar a que los healthchecks estén en verde
docker compose ps                 # estado de la pila, con la salud
docker compose logs -f            # registros agregados, en directo
docker compose logs -f --tail 100 api   # un solo servicio

# Abrir una shell en un servicio que está EN EJECUCIÓN
docker compose exec api sh
docker compose exec db psql -U app -d appdb

# Lanzar un comando en un contenedor DESECHABLE.
# run crea un contenedor nuevo, exec entra en el que está en ejecución.
docker compose run --rm api npm run migrate

docker compose restart api

Conviene distinguir con cuidado tres comandos de parada, porque uno de ellos destruye datos:

# Parar sin eliminar nada. Reversible con "start".
docker compose stop

# Parar Y eliminar contenedores y redes.
# Los VOLÚMENES se conservan: tus datos sobreviven.
docker compose down

# Lo mismo, más la eliminación de los volúmenes. Tus datos se PIERDEN.
docker compose down -v

# Eliminar también los contenedores huérfanos de una versión anterior del archivo
docker compose down --remove-orphans

Por último, el comando de diagnóstico más rentable:

# Muestra el archivo tal como Compose lo entiende REALMENTE: variables
# interpoladas, archivos fusionados, valores por defecto aplicados.
# El primer reflejo cuando una variable "no llega".
docker compose config

Unas palabras sobre el nombre del proyecto. Compose antepone a todos los objetos que crea un nombre derivado de la carpeta actual: el volumen db-data pasa a ser monprojet_db-data. Gracias a eso se pueden ejecutar dos pilas independientes en la misma máquina, y también explica por qué renombrar una carpeta hace que aparentemente se «pierdan» los datos: siguen ahí, bajo el prefijo anterior.

docker compose -p mon-projet up -d
# o, de forma persistente, en .env: COMPOSE_PROJECT_NAME=mon-projet

image o build: consumir o producir

image por sí sola descarga una imagen existente de un registro: es el caso de Postgres, Redis o Nginx, que nunca construyes. build construye a partir de un Dockerfile: es el caso de tu código. Combinar ambas claves da nombre a la imagen construida, lo que permite subirla a un registro.

services:
  api:
    build:
      context: .                        # como el "." de docker build
      dockerfile: docker/Dockerfile.api # relativo al contexto
      target: runtime                   # etapa de un Dockerfile multi-stage
      args:                             # equivale a --build-arg
        NODE_VERSION: "22"
    image: registre.exemple.com/equipe/mon-api:dev

Aquí está la trampa del principiante: up no reconstruye una imagen existente. Modificas tu Dockerfile, vuelves a lanzar up -d y no cambia nada. Compose reutiliza la imagen ya construida; hay que pedirle explícitamente que la reconstruya.

docker compose build api             # reconstruir un servicio
docker compose build --no-cache api  # sin caché
docker compose up -d --build         # reconstruir y luego arrancar

Las dependencias: depends_on solo no basta

Es la fuente de errores más frecuente y más desconcertante, porque el síntoma es intermitente.

La forma corta de depends_on garantiza únicamente un orden de arranque. Compose espera a que el contenedor db esté lanzado, no a que Postgres acepte conexiones. Y Postgres tarda de dos a diez segundos en inicializar su clúster en el primer arranque. Tu API arranca durante ese tiempo, falla con connection refused y termina. En una máquina rápida a veces funciona: de ahí la impresión de un fallo aleatorio.

# Insuficiente: garantiza el ORDEN, no la DISPONIBILIDAD
services:
  api:
    depends_on:
      - db

La solución correcta combina un healthcheck en el servicio del que se depende y una condición en el servicio dependiente:

services:
  api:
    depends_on:
      db:
        condition: service_healthy   # espera a que el healthcheck esté en verde
      cache:
        condition: service_started   # basta con que haya arrancado
      migrate:
        condition: service_completed_successfully  # espera una salida con código 0

  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s       # frecuencia de las comprobaciones
      timeout: 3s        # pasado este tiempo, la comprobación se considera fallida
      retries: 10        # fallos consecutivos antes del estado "unhealthy"
      start_period: 10s  # periodo de gracia: los fallos no se cuentan en él

  migrate:
    build: .
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"        # una tarea puntual, no un servicio

La tercera condición, service_completed_successfully, resuelve con elegancia el problema de las migraciones de esquema: el servicio migrate se ejecuta una vez, termina con código 0 y la API solo arranca después. Ya no hacen falta scripts de espera artesanales.

# La columna STATUS muestra (healthy), (unhealthy) o (health: starting)
docker compose ps

# Historial detallado de las comprobaciones, con las salidas de los comandos
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db)

Una salvedad honesta: depends_on solo interviene en el arranque. Si la base de datos se reinicia durante su vida útil, tu aplicación debe saber reconectarse. Sigue siendo indispensable una lógica de reconexión con espera progresiva (backoff) en el código; depends_on elimina el ruido en el arranque, pero no sustituye a la robustez.

Variables de entorno: tres mecanismos distintos

La confusión viene de que tres cosas diferentes tienen casi el mismo nombre.

El archivo .env situado junto a compose.yaml alimenta la interpolación en el propio YAML: sus valores sustituyen a los ${...} antes de que Compose procese nada. La clave environment define las variables que recibe el proceso dentro del contenedor. La clave env_file carga un archivo de variables directamente en el contenedor, sin pasar por la interpolación.

# .env  -- excluir de Git mediante .gitignore
DB_PASSWORD=motdepasse-de-dev
API_PORT=3000
TAG=dev
services:
  api:
    image: mon-api:${TAG}         # se interpola ANTES del arranque
    ports:
      - "${API_PORT}:3000"
    env_file:
      - ./config/api.env          # se inyecta tal cual en el contenedor
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}   # valor por defecto si no existe
      # Dos puntos + signo de interrogación: Compose FALLA si la
      # variable no existe o está vacía. Úsalo para cualquier secreto.
      JWT_SECRET: ${JWT_SECRET:?JWT_SECRET est obligatoire}

La forma ${VAR:?mensaje} es la mejor salvaguarda disponible: convierte un secreto olvidado en un error inmediato y explícito, en lugar de una aplicación que arranca con una cadena vacía y falla en la autenticación tres horas después.

Para terminar, dos reglas de seguridad. Un .env que contenga secretos reales no entra nunca en Git: versiona un .env.example documentado y sin valores. Y en producción, los secretos no pasan por variables legibles con docker inspect: se usa la sección secrets, que los monta como archivos bajo /run/secrets/, de modo que la aplicación lee una ruta en lugar de un valor.

Perfiles: servicios opcionales en la misma pila

Una pila de desarrollo acaba acumulando herramientas que nadie necesita de forma permanente: una interfaz de administración de la base de datos, un servidor SMTP falso, un inyector de carga. Arrancarlas siempre desperdicia memoria; ponerlas en un segundo archivo duplica la configuración. Un servicio asignado a un perfil solo arranca si se solicita ese perfil.

services:
  api:
    build: .
    ports:
      - "3000:3000"
    # Sin perfil: este servicio siempre arranca

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}

  adminer:                    # administración de la base de datos
    image: adminer:4
    profiles: ["debug"]
    ports:
      - "8080:8080"

  mailpit:                    # servidor SMTP falso, en DOS perfiles
    image: axllent/mailpit:latest
    profiles: ["debug", "mail"]
    ports:
      - "8025:8025"

  k6:                         # pruebas de carga, nunca por accidente
    image: grafana/k6:latest
    profiles: ["perf"]
    command: ["run", "/scripts/charge.js"]
    volumes:
      - ./perf:/scripts:ro    # :ro monta en solo lectura
docker compose up -d                                # solo api + db
docker compose --profile debug up -d                # + adminer + mailpit
docker compose --profile debug --profile perf up -d # perfiles acumulados
COMPOSE_PROFILES=debug docker compose up -d         # mediante el entorno
docker compose --profile debug config --services    # listar sin arrancar

Superponer varios archivos

Un mismo proyecto suele tener que funcionar en contextos distintos: código montado y recarga en caliente en desarrollo, imagen fija y límites de recursos en producción. Compose superpone los archivos en lugar de duplicarlos. Un archivo llamado compose.override.yaml se carga automáticamente además de compose.yaml.

# compose.yaml -- la base común, válida en todas partes
services:
  api:
    image: registre.exemple.com/equipe/mon-api:${TAG:-latest}
    environment:
      NODE_ENV: production
    restart: unless-stopped
# compose.override.yaml -- se carga automáticamente, para el desarrollo
services:
  api:
    build: .                   # se construye en local
    environment:
      NODE_ENV: development
      DEBUG: "app:*"
    volumes:
      # El código del host sustituye al de la imagen: una modificación
      # se ve sin reconstruir.
      - ./src:/app/src
      # Volumen anónimo: protege el node_modules de la imagen para que
      # no lo sobrescriba el del host.
      - /app/node_modules
    command: ["npm", "run", "dev"]
    ports:
      - "9229:9229"            # puerto del depurador de Node
# compose.prod.yaml -- se carga explícitamente
services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
# Desarrollo: compose.yaml + compose.override.yaml, automáticamente
docker compose up -d

# Producción: se nombran los archivos, el override NO se carga
docker compose -f compose.yaml -f compose.prod.yaml up -d

# Verifica siempre el resultado de la fusión antes de desplegar
docker compose -f compose.yaml -f compose.prod.yaml config

Conviene conocer la regla de fusión: para los valores simples, gana el último archivo; para las listas como ports o volumes, las entradas se acumulan. Es una sorpresa frecuente, que docker compose config revela de inmediato.

Lo que Compose no hace

Compose orquesta contenedores en un solo host. No reparte nada entre varias máquinas, no reinicia un servicio en otro lugar cuando cae un servidor y no garantiza ningún despliegue sin interrupciones. Eso es terreno de Kubernetes o de Docker Swarm.

Esto no lo descalifica para producción: un único servidor gestionado con Compose es una arquitectura perfectamente defendible para un servicio con tráfico moderado, y mucho más sencilla de operar que un clúster. La lección 6 trata precisamente este caso.

Sin embargo, existe el escalado local, que resulta útil para probar un worker:

# Tres instancias del servicio worker
docker compose up -d --scale worker=3

Es incompatible con la publicación de un puerto fijo: tres contenedores no pueden enlazarse al puerto 3000 del host. Elimina la clave ports del servicio afectado, o deja que Docker elija puertos libres.

Lo esencial

  • Un compose.yaml versionado sustituye a una colección de docker run frágiles: la definición de la aplicación pasa a ser un documento único, revisable en una revisión de código y reproducible para todo el equipo.
  • Usa docker compose en dos palabras. docker-compose con guion es la v1, abandonada, que ignora parte de la sintaxis actual. La clave version: al principio del archivo está obsoleta y no tiene efecto.
  • Los servicios se comunican por su nombre gracias al DNS interno. Publica con ports solo lo que deba ser accesible desde el exterior: una base de datos, por lo general, no entra en esa categoría.
  • depends_on por sí solo solo garantiza un orden de arranque. Para esperar a una disponibilidad real, añade un healthcheck y condition: service_healthy; service_completed_successfully resuelve limpiamente el caso de las migraciones.
  • docker compose up no reconstruye tus imágenes: acuérdate de usar --build tras cualquier modificación de un Dockerfile. Y docker compose down -v elimina los volúmenes, y por tanto los datos.
  • ${VAR:?message} convierte un secreto ausente en un error inmediato. Si tienes dudas sobre una variable, docker compose config muestra el archivo realmente interpretado.
  • Los perfiles aíslan las herramientas opcionales y los archivos de sobrescritura adaptan la misma pila al desarrollo o a la producción, sin duplicar la configuración.

Consejo: acostúmbrate a ejecutar docker compose config antes de cualquier up sobre una configuración modificada. La mayoría de las horas perdidas con Compose se deben a la diferencia entre lo que crees haber escrito y lo que la interpolación ha producido realmente.

Comentarios

Los comentarios funcionan con GitHub Discussions

Inicia sesión con GitHub para participar en la discusión

¡Enlace copiado!