Docker Compose: levantar múltiples servicios con un solo comando

D
DanisCh
• 9 min de lectura
Docker Compose: levantar múltiples servicios con un solo comando
DevOps Docker

Cuando desarrollas una aplicación real necesitas varios servicios funcionando a la vez: el servidor web, la base de datos, una caché, quizás un gestor de colas. Levantar cada contenedor manualmente con docker run es tedioso y difícil de reproducir. Docker Compose soluciona esto: describes todos los servicios en un archivo YAML y los levanta todos con un solo comando, con la red configurada automáticamente entre ellos.

Instalación y conceptos básicos

# Docker Compose viene incluido con Docker Desktop (Mac y Windows)
# En Linux, se instala junto con Docker Engine:
# https://docs.docker.com/compose/install/

# Verificar la instalación
docker compose version
# Docker Compose version v2.29.0

# Nota: la versión antigua era "docker-compose" (con guión)
# La versión moderna es "docker compose" (como plugin de Docker)
# Ambas son compatibles, pero la nueva es la recomendada
# Estructura de un proyecto con Docker Compose:
mi-proyecto/
├── docker-compose.yml     # o compose.yml (nombre moderno)
├── backend/
│   ├── Dockerfile
│   └── src/
├── frontend/
│   ├── Dockerfile
│   └── src/
└── nginx/
    └── nginx.conf

El archivo docker-compose.yml

Todo el stack se describe en un único archivo YAML. Cada bloque bajo services es un contenedor.

# docker-compose.yml básico: aplicación web + base de datos

services:

  # Servicio de base de datos
  db:
    image: postgres:16-alpine         # imagen oficial de PostgreSQL
    restart: unless-stopped           # reiniciar si falla, excepto si lo paras manualmente
    environment:
      POSTGRES_DB:       mi_app
      POSTGRES_USER:     usuario
      POSTGRES_PASSWORD: contraseña_segura
    volumes:
      - datos_postgres:/var/lib/postgresql/data   # persistir los datos
    ports:
      - "5432:5432"      # exponer al host (útil para conectar con un cliente SQL local)

  # Servicio de la aplicación backend
  backend:
    build: ./backend               # construir imagen desde el Dockerfile en ./backend
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://usuario:contraseña_segura@db:5432/mi_app
      #                                                      ^^
      #                              nombre del servicio de BD (no "localhost")
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy   # esperar a que la BD esté lista
    volumes:
      - ./backend:/app               # montar el código local (útil en desarrollo)

volumes:
  datos_postgres:    # volumen con nombre: persiste entre reinicios de contenedores
# Comandos fundamentales de Docker Compose

# Levantar todos los servicios (en segundo plano)
docker compose up -d

# Levantar y reconstruir las imágenes si cambiaron
docker compose up -d --build

# Ver el estado de los servicios
docker compose ps

# Ver los logs de todos los servicios
docker compose logs

# Ver los logs de un servicio concreto (y seguirlos en tiempo real)
docker compose logs -f backend

# Parar los servicios (sin borrar contenedores ni volúmenes)
docker compose stop

# Parar y borrar los contenedores (los volúmenes quedan intactos)
docker compose down

# Parar y borrar TODO incluyendo volúmenes (¡borra los datos de la BD!)
docker compose down -v

# Ejecutar un comando en un servicio en ejecución
docker compose exec backend bash
docker compose exec db psql -U usuario -d mi_app

Ejemplo completo: stack web moderno

Una aplicación real con frontend en React, backend en FastAPI, PostgreSQL, Redis y Nginx como proxy inverso.

# docker-compose.yml

services:

  # ── Base de datos ──────────────────────────────────────────────────────
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB:       ${DB_NAME:-mi_app}
      POSTGRES_USER:     ${DB_USER:-usuario}
      POSTGRES_PASSWORD: ${DB_PASSWORD:?La contraseña de BD es obligatoria}
    volumes:
      - datos_postgres:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql  # script de inicialización
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-usuario} -d ${DB_NAME:-mi_app}"]
      interval: 10s
      timeout:  5s
      retries:  5

  # ── Caché ──────────────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD:-redis_pass}
    volumes:
      - datos_redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout:  5s
      retries:  3

  # ── Backend (FastAPI) ──────────────────────────────────────────────────
  backend:
    build:
      context:    ./backend
      dockerfile: Dockerfile
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://${DB_USER:-usuario}:${DB_PASSWORD}@db:5432/${DB_NAME:-mi_app}
      REDIS_URL:    redis://:${REDIS_PASSWORD:-redis_pass}@redis:6379/0
      SECRET_KEY:   ${SECRET_KEY:?SECRET_KEY es obligatoria}
      DEBUG:        ${DEBUG:-false}
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - ./backend:/app              # hot reload en desarrollo
      - archivos_subidos:/app/uploads
    expose:
      - "8000"                      # solo accesible internamente, no al host

  # ── Frontend (React, en desarrollo) ───────────────────────────────────
  frontend:
    build:
      context:    ./frontend
      dockerfile: Dockerfile.dev    # Dockerfile específico para desarrollo
    restart: unless-stopped
    environment:
      VITE_API_URL: http://localhost/api
    volumes:
      - ./frontend:/app
      - /app/node_modules            # excluir node_modules del montaje
    expose:
      - "5173"

  # ── Proxy inverso (Nginx) ──────────────────────────────────────────────
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      - backend
      - frontend

volumes:
  datos_postgres:
  datos_redis:
  archivos_subidos:
# nginx/nginx.conf: enrutar /api al backend y el resto al frontend

events { worker_connections 1024; }

http {
    upstream backend  { server backend:8000;  }
    upstream frontend { server frontend:5173; }

    server {
        listen 80;

        # Rutas de API al backend
        location /api/ {
            proxy_pass         http://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;
        }

        # Todo lo demás al frontend
        location / {
            proxy_pass           http://frontend;
            proxy_http_version   1.1;
            proxy_set_header     Upgrade    $http_upgrade;
            proxy_set_header     Connection "upgrade";  # necesario para hot reload
        }
    }
}

Variables de entorno y el archivo .env

# Docker Compose lee automáticamente el archivo .env del mismo directorio
# .env

DB_NAME=mi_app
DB_USER=usuario
DB_PASSWORD=contraseña_muy_segura_aqui
REDIS_PASSWORD=redis_pass_segura
SECRET_KEY=clave_secreta_para_jwt_muy_larga
DEBUG=true

# En docker-compose.yml, las referencias con ${VARIABLE} se sustituyen
# ${VARIABLE:-default}      → usa "default" si VARIABLE no está definida
# ${VARIABLE:?mensaje}      → falla con "mensaje" si VARIABLE no está definida
# ${VARIABLE:+valor}        → usa "valor" solo si VARIABLE SÍ está definida
# ⚠️ El .env NUNCA debe subirse al repositorio
# Añadirlo al .gitignore:
echo ".env" >> .gitignore

# Proporcionar un .env.example con las claves sin valores reales:
# .env.example (sí va al repositorio)
DB_NAME=mi_app
DB_USER=usuario
DB_PASSWORD=           # ← rellenar con la contraseña real
REDIS_PASSWORD=        # ← rellenar
SECRET_KEY=            # ← generar con: python -c "import secrets; print(secrets.token_hex(32))"
DEBUG=false

Perfiles: configuraciones para dev y producción

# Los profiles permiten activar servicios solo en ciertos entornos

services:

  db:
    image: postgres:16-alpine
    # sin profile: siempre activo

  backend:
    build: ./backend
    # sin profile: siempre activo

  # Solo en desarrollo: herramienta de administración de BD
  adminer:
    image: adminer
    ports:
      - "8080:8080"
    profiles:
      - dev        # solo se levanta con --profile dev

  # Solo en producción: agente de monitorización
  datadog:
    image: datadog/agent
    environment:
      DD_API_KEY: ${DD_API_KEY}
    profiles:
      - prod       # solo se levanta con --profile prod

# Uso:
docker compose --profile dev up -d    # dev: levanta db + backend + adminer
docker compose --profile prod up -d   # prod: levanta db + backend + datadog
docker compose up -d                  # solo los servicios sin profile: db + backend

Compose para desarrollo vs producción

# Estrategia habitual: un archivo base + un override por entorno

# docker-compose.yml (base, compartido)
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB:       ${DB_NAME}
      POSTGRES_USER:     ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - datos_postgres:/var/lib/postgresql/data

  backend:
    image: mi-empresa/backend:${VERSION:-latest}
    environment:
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
    depends_on:
      db:
        condition: service_healthy

volumes:
  datos_postgres:
# docker-compose.override.yml (desarrollo, se aplica automáticamente)
# Docker Compose fusiona este archivo con el base cuando no se especifica otro

services:
  backend:
    build: ./backend          # en dev construimos la imagen localmente
    volumes:
      - ./backend:/app         # hot reload
    environment:
      DEBUG: "true"
    ports:
      - "8000:8000"            # exponer al host para depurar

  db:
    ports:
      - "5432:5432"            # exponer la BD al host para usar un cliente SQL
# docker-compose.prod.yml (producción, especificar manualmente)
services:
  backend:
    deploy:
      replicas:  3             # varias réplicas en producción
      resources:
        limits:
          cpus:   "1.0"
          memory: 512M
    restart: always
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

# Levantar con el archivo de producción:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Healthchecks y dependencias

# El problema sin healthchecks:
# depends_on solo espera a que el contenedor ARRANQUE,
# no a que el servicio dentro esté LISTO para recibir conexiones.
# PostgreSQL tarda unos segundos en inicializarse después de arrancar.

# Solución: healthcheck + condition: service_healthy

services:
  db:
    image: postgres:16-alpine
    healthcheck:
      test:     ["CMD-SHELL", "pg_isready -U usuario -d mi_app"]
      interval: 10s     # frecuencia de comprobación
      timeout:  5s      # tiempo máximo para considerar fallido
      retries:  5       # intentos antes de marcar como unhealthy
      start_period: 30s # tiempo de gracia al arrancar (no cuenta como fallo)

  backend:
    build: ./backend
    depends_on:
      db:
        condition: service_healthy   # esperar a que el healthcheck pase

  # Healthcheck para Redis:
  redis:
    image: redis:7-alpine
    healthcheck:
      test:     ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout:  3s
      retries:  3

  # Healthcheck para una API HTTP:
  api:
    build: ./api
    healthcheck:
      test:     ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout:  10s
      retries:  3

Comandos útiles del día a día

# Ver qué está corriendo y su estado (incluye healthcheck)
docker compose ps

# Ver el uso de recursos de cada contenedor
docker compose stats

# Reconstruir solo un servicio sin reiniciar los demás
docker compose up -d --build backend

# Reiniciar un solo servicio
docker compose restart backend

# Escalar un servicio a N réplicas (si no usa puertos fijos)
docker compose up -d --scale backend=3

# Ejecutar un comando puntual en un servicio (sin que esté en ejecución)
docker compose run --rm backend python manage.py migrate
docker compose run --rm backend pytest

# Copiar un archivo desde/hacia un contenedor
docker compose cp backend:/app/logs/error.log ./logs/

# Ver las variables de entorno que ve un servicio
docker compose exec backend env

# Forzar recrear los contenedores (útil cuando el compose.yml cambia)
docker compose up -d --force-recreate

Resumen

  • Docker Compose describe todo el stack de servicios en un archivo YAML y los levanta, conecta y gestiona con un solo comando. docker compose up -d levanta todos los servicios en segundo plano; docker compose down los para y elimina los contenedores.
  • Los servicios se comunican entre sí usando el nombre del servicio como hostname (por ejemplo, db o redis), no localhost. Docker Compose crea automáticamente una red privada entre todos los servicios del mismo archivo.
  • Usa variables de entorno con un archivo .env (que nunca va al repositorio) y proporciona un .env.example con las claves sin valores. La sintaxis ${VARIABLE:?mensaje} hace que Compose falle con un error claro si falta una variable obligatoria.
  • Los healthchecks con condition: service_healthy en depends_on garantizan que un servicio no arranca hasta que sus dependencias estén realmente listas para recibir conexiones, no solo iniciadas.
  • La estrategia de archivo base + override (docker-compose.yml + docker-compose.override.yml para dev, docker-compose.prod.yml para producción) evita duplicar configuración entre entornos.

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a