Cómo usar Docker Compose para levantar un entorno de desarrollo

D
DanisCh
(Actualizado: ) • 16 min de lectura
Cómo usar Docker Compose para levantar un entorno de desarrollo

Uno de los problemas más frustrantes en el desarrollo de software es el clásico "en mi máquina funciona". Un desarrollador instala MySQL 8, otro tiene MySQL 5.7. Uno usa Node 18, otro Node 20. Alguien tiene Redis corriendo en un puerto distinto. La aplicación se comporta diferente en cada entorno y reproducir bugs se convierte en un juego de adivinanzas.

Docker Compose resuelve ese problema de raíz. Te permite definir en un solo archivo todos los servicios que necesita tu aplicación (base de datos, caché, servidor de correo, la propia aplicación) y levantarlos todos juntos con un solo comando. Cualquier miembro del equipo puede tener el entorno exacto funcionando en minutos, independientemente de su sistema operativo.

En este artículo aprenderás qué es Docker Compose, cómo funciona, cómo escribir un archivo de configuración y cómo levantar entornos de desarrollo completos y realistas paso a paso.

Qué es Docker Compose y cómo encaja con Docker

Docker te permite crear contenedores: entornos aislados que contienen una aplicación y todas sus dependencias. Un contenedor de MySQL tiene MySQL instalado con su configuración exacta. Un contenedor de Redis tiene Redis. Cada uno vive de forma aislada.

El problema es que las aplicaciones reales no son un solo servicio: son varios trabajando juntos. Tu aplicación web necesita una base de datos, una caché, quizás un servidor de cola de mensajes y un servidor de correo para desarrollo. Levantar cada contenedor manualmente con sus opciones de red, variables de entorno y volúmenes sería tedioso y propenso a errores.

Docker Compose es la herramienta que orquesta múltiples contenedores Docker. En lugar de ejecutar varios comandos docker run con decenas de parámetros, defines toda la infraestructura en un archivo docker-compose.yml y la levantas con docker compose up.

Desde Docker Desktop v2.0, Docker Compose está integrado directamente en Docker como un subcomando (docker compose en lugar del antiguo docker-compose). No necesitas instalarlo por separado.

Conceptos clave de Docker Compose

Antes de escribir código, conviene entender los conceptos fundamentales:

  • Servicio: cada contenedor que define tu aplicación. Una base de datos, una API, un servidor web son servicios distintos.
  • Imagen: la plantilla de solo lectura desde la que se crea un contenedor. Puede ser una imagen oficial de Docker Hub (postgres, redis, nginx) o una que construyes tú desde un Dockerfile.
  • Volumen: mecanismo para persistir datos entre reinicios del contenedor o para compartir archivos entre el host y el contenedor. Sin volúmenes, los datos desaparecen cuando el contenedor se detiene.
  • Red: Docker Compose crea automáticamente una red privada para todos los servicios definidos en el mismo archivo. Los servicios se comunican entre sí usando el nombre del servicio como hostname.
  • Variables de entorno: forma de pasar configuración a los contenedores sin hardcodear valores en el archivo de configuración.

Estructura del archivo docker-compose.yml

El archivo de configuración usa el formato YAML. La indentación es significativa, así que es importante mantenerla consistente.

# docker-compose.yml — estructura general
version: '3.8'   # versión del formato (opcional en versiones modernas)

services:
  nombre-del-servicio:
    image: imagen:version        # imagen de Docker Hub
    # o bien:
    build: ./ruta/al/dockerfile  # construir desde un Dockerfile

    container_name: mi-contenedor  # nombre opcional del contenedor
    ports:
      - "puerto-host:puerto-contenedor"
    environment:
      - VARIABLE=valor
    volumes:
      - ./ruta-local:/ruta-en-contenedor
    depends_on:
      - otro-servicio
    restart: unless-stopped

volumes:
  nombre-volumen-persistente:

networks:
  nombre-red-personalizada:

Tu primer docker-compose.yml: aplicación con base de datos

Empezamos con el caso más común: una API en Node.js que necesita PostgreSQL y Redis.

# docker-compose.yml
services:

  # ── Base de datos PostgreSQL ──────────────────────────────────
  postgres:
    image: postgres:15-alpine
    container_name: mi-app-postgres
    environment:
      POSTGRES_DB: mi_base_de_datos
      POSTGRES_USER: usuario
      POSTGRES_PASSWORD: contraseña_segura
    ports:
      - "5432:5432"      # puerto_host:puerto_contenedor
    volumes:
      - postgres-data:/var/lib/postgresql/data    # persistencia de datos
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql  # script de inicialización
    restart: unless-stopped

  # ── Caché Redis ───────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: mi-app-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes   # habilitar persistencia AOF
    restart: unless-stopped

  # ── API Node.js ───────────────────────────────────────────────
  api:
    build:
      context: .           # directorio con el Dockerfile
      dockerfile: Dockerfile
    container_name: mi-app-api
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      DATABASE_URL: postgresql://usuario:contraseña_segura@postgres:5432/mi_base_de_datos
      REDIS_URL: redis://redis:6379
      JWT_SECRET: mi-secreto-de-desarrollo
    volumes:
      - .:/app              # montar el código fuente para hot reload
      - /app/node_modules   # excluir node_modules del host
    depends_on:
      postgres:
        condition: service_healthy  # esperar a que postgres esté listo
      redis:
        condition: service_started
    restart: unless-stopped

# ── Volúmenes persistentes ────────────────────────────────────
volumes:
  postgres-data:
  redis-data:

Fíjate en dos detalles importantes:

  • En DATABASE_URL, el host es postgres (el nombre del servicio), no localhost. Docker Compose crea una red interna donde los servicios se resuelven por su nombre.
  • El volumen /app/node_modules sin ruta de host evita que el node_modules del contenedor sea sobreescrito por el del host al montar .:/app.

El Dockerfile para desarrollo

El servicio api usa un Dockerfile propio. Para desarrollo, queremos hot reload (reinicio automático cuando cambia el código):

# Dockerfile
FROM node:20-alpine

WORKDIR /app

# Copiar package files primero para aprovechar la caché de capas
COPY package*.json ./

# Instalar dependencias incluyendo devDependencies
RUN npm install

# Copiar el resto del código
# (en desarrollo esto se sobreescribe con el volumen)
COPY . .

EXPOSE 3000

# En desarrollo usamos nodemon para hot reload
CMD ["npm", "run", "dev"]
// package.json — scripts relevantes
{
  "scripts": {
    "dev": "nodemon src/index.js",
    "start": "node src/index.js",
    "test": "jest"
  }
}

Comandos esenciales de Docker Compose

Con el archivo listo, estos son los comandos que usarás en el día a día:

# Levantar todos los servicios en primer plano (ver logs en tiempo real)
docker compose up

# Levantar en segundo plano (modo detached)
docker compose up -d

# Reconstruir imágenes antes de levantar (útil cuando cambia el Dockerfile)
docker compose up --build

# Levantar solo algunos servicios
docker compose up postgres redis

# 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 específico
docker compose logs api

# Seguir los logs en tiempo real
docker compose logs -f api

# Detener los servicios (sin eliminar contenedores ni datos)
docker compose stop

# Detener y eliminar contenedores y redes (pero conserva los volúmenes)
docker compose down

# Detener y eliminar TODO, incluidos los volúmenes (¡borra los datos!)
docker compose down -v

# Ejecutar un comando dentro de un contenedor en ejecución
docker compose exec postgres psql -U usuario -d mi_base_de_datos
docker compose exec api sh

# Ejecutar un contenedor temporal con un comando
docker compose run --rm api npm run migrate

# Reiniciar un servicio específico
docker compose restart api

# Ver el uso de recursos
docker compose top

Variables de entorno con archivo .env

Poner contraseñas y secretos directamente en el docker-compose.yml es una mala práctica, especialmente si el archivo va al repositorio. La solución es usar un archivo .env que Docker Compose lee automáticamente.

# .env (este archivo NO va al repositorio — agrégalo al .gitignore)
POSTGRES_DB=mi_base_de_datos
POSTGRES_USER=usuario
POSTGRES_PASSWORD=contraseña_muy_segura_123
POSTGRES_PORT=5432
REDIS_PORT=6379
API_PORT=3000
JWT_SECRET=mi-jwt-secret-de-desarrollo
NODE_ENV=development
# .env.example (ESTE sí va al repositorio — sirve de plantilla)
POSTGRES_DB=nombre_base_de_datos
POSTGRES_USER=usuario_db
POSTGRES_PASSWORD=cambia_esto
POSTGRES_PORT=5432
REDIS_PORT=6379
API_PORT=3000
JWT_SECRET=cambia_esto_tambien
NODE_ENV=development
# docker-compose.yml usando las variables del .env
services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "${POSTGRES_PORT}:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "${REDIS_PORT}:6379"

  api:
    build: .
    ports:
      - "${API_PORT}:3000"
    environment:
      NODE_ENV: ${NODE_ENV}
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      REDIS_URL: redis://redis:6379
      JWT_SECRET: ${JWT_SECRET}

Health checks: esperar a que los servicios estén listos

Un problema común es que la API intenta conectarse a la base de datos antes de que PostgreSQL haya terminado de inicializarse. depends_on solo garantiza que el contenedor haya arrancado, no que el servicio dentro esté listo. Los health checks resuelven esto.

services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s      # comprobar cada 5 segundos
      timeout: 5s       # timeout de cada comprobación
      retries: 5        # intentos antes de marcar como unhealthy
      start_period: 10s # tiempo de gracia al arrancar

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

  api:
    build: .
    depends_on:
      postgres:
        condition: service_healthy   # espera al health check de postgres
      redis:
        condition: service_healthy   # espera al health check de redis

Ejemplo completo: stack fullstack con frontend

Un entorno de desarrollo completo con React en el frontend, Node.js en el backend, PostgreSQL, Redis y Nginx como proxy inverso:

# docker-compose.yml — stack fullstack completo
services:

  # ── Base de datos ─────────────────────────────────────────────
  postgres:
    image: postgres:15-alpine
    container_name: stack-postgres
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-appdb}
      POSTGRES_USER: ${POSTGRES_USER:-app}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret}
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./database/init:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app}"]
      interval: 5s
      retries: 5
    networks:
      - backend-network

  # ── Caché ─────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: stack-redis
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redispass}
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redispass}", "ping"]
      interval: 5s
      retries: 3
    networks:
      - backend-network

  # ── API Backend ───────────────────────────────────────────────
  api:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    container_name: stack-api
    environment:
      NODE_ENV: development
      PORT: 4000
      DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-secret}@postgres:5432/${POSTGRES_DB:-appdb}
      REDIS_URL: redis://:${REDIS_PASSWORD:-redispass}@redis:6379
      JWT_SECRET: ${JWT_SECRET:-dev-secret}
    volumes:
      - ./backend:/app
      - /app/node_modules
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend-network
      - frontend-network

  # ── Frontend React ────────────────────────────────────────────
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    container_name: stack-frontend
    environment:
      VITE_API_URL: http://localhost/api
    volumes:
      - ./frontend:/app
      - /app/node_modules
    networks:
      - frontend-network

  # ── Nginx proxy inverso ───────────────────────────────────────
  nginx:
    image: nginx:alpine
    container_name: stack-nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.dev.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - api
      - frontend
    networks:
      - frontend-network

  # ── Herramienta de administración de DB ───────────────────────
  adminer:
    image: adminer:latest
    container_name: stack-adminer
    ports:
      - "8080:8080"
    depends_on:
      - postgres
    networks:
      - backend-network

volumes:
  postgres-data:
  redis-data:

networks:
  backend-network:
    driver: bridge
  frontend-network:
    driver: bridge
# nginx/nginx.dev.conf
server {
  listen 80;

  # Redirigir /api al backend
  location /api {
    proxy_pass         http://api:4000;
    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 (React con hot reload)
  location / {
    proxy_pass         http://frontend:5173;
    proxy_set_header   Host $host;
    proxy_set_header   Upgrade $http_upgrade;
    proxy_set_header   Connection "upgrade";
    proxy_http_version 1.1;
  }
}

Múltiples archivos: separar desarrollo y producción

Una práctica muy común es tener un archivo base con la configuración compartida y archivos específicos para cada entorno que lo extienden o sobreescriben.

# docker-compose.yml — configuración base compartida
services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - app-network

  api:
    build: ./backend
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
    networks:
      - app-network
    depends_on:
      - postgres

volumes:
  postgres-data:

networks:
  app-network:
# docker-compose.override.yml — sobreescritura automática para desarrollo
# Docker Compose aplica este archivo automáticamente si existe
services:
  postgres:
    ports:
      - "5432:5432"   # exponer el puerto solo en desarrollo

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev     # Dockerfile específico para dev
    ports:
      - "4000:4000"
    environment:
      NODE_ENV: development
    volumes:
      - ./backend:/app               # hot reload
      - /app/node_modules
    command: npm run dev
# docker-compose.prod.yml — configuración para producción
services:
  api:
    build:
      context: ./backend
      dockerfile: Dockerfile         # Dockerfile de producción (optimizado)
    environment:
      NODE_ENV: production
    restart: always
    # Sin volúmenes de código fuente en producción
    # Sin puertos expuestos directamente (Nginx los gestiona)
# Usar el archivo de producción explícitamente
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Desarrollo usa el override automáticamente
docker compose up -d

Scripts de inicialización de la base de datos

Puedes ejecutar scripts SQL automáticamente cuando el contenedor de PostgreSQL arranca por primera vez, montándolos en el directorio /docker-entrypoint-initdb.d/:

# Estructura del proyecto
database/
  init/
    01-schema.sql      # crear tablas
    02-seed.sql        # datos de prueba
-- database/init/01-schema.sql
CREATE TABLE clientes (
  id         SERIAL PRIMARY KEY,
  nombre     VARCHAR(100) NOT NULL,
  email      VARCHAR(100) UNIQUE NOT NULL,
  creado_en  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE productos (
  id         SERIAL PRIMARY KEY,
  nombre     VARCHAR(100) NOT NULL,
  precio     DECIMAL(10, 2) NOT NULL,
  stock      INT DEFAULT 0
);

CREATE TABLE pedidos (
  id          SERIAL PRIMARY KEY,
  id_cliente  INT REFERENCES clientes(id),
  fecha       DATE NOT NULL,
  estado      VARCHAR(20) DEFAULT 'pendiente'
);
-- database/init/02-seed.sql
INSERT INTO clientes (nombre, email) VALUES
  ('Ana García', 'ana@correo.com'),
  ('Luis Pérez', 'luis@correo.com');

INSERT INTO productos (nombre, precio, stock) VALUES
  ('Laptop Pro', 1299.99, 25),
  ('Mouse', 45.50, 200);
# docker-compose.yml — montar los scripts de inicialización
services:
  postgres:
    image: postgres:15-alpine
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./database/init:/docker-entrypoint-initdb.d  # scripts ejecutados en orden

Los scripts dentro de docker-entrypoint-initdb.d se ejecutan en orden alfabético la primera vez que el contenedor arranca con un volumen vacío. Si el volumen ya tiene datos, se ignoran.

Flujo de trabajo diario con Docker Compose

Este es el flujo que seguirás en el día a día:

# Clonar el repositorio por primera vez
git clone https://github.com/equipo/mi-proyecto.git
cd mi-proyecto

# Copiar las variables de entorno
cp .env.example .env
# Editar .env con tus valores locales

# Levantar el entorno completo (primera vez construye las imágenes)
docker compose up --build -d

# Ver que todo está corriendo
docker compose ps

# Ver los logs mientras trabajas
docker compose logs -f api

# Cuando terminas el día
docker compose stop

# Al día siguiente, retomar donde lo dejaste
docker compose start

# Si alguien del equipo agregó una nueva dependencia (npm install)
docker compose up --build -d

# Acceder a la base de datos directamente
docker compose exec postgres psql -U usuario -d mi_base_de_datos

# Ejecutar migraciones
docker compose exec api npm run migrate

# Reiniciar solo la API después de un cambio en el Dockerfile
docker compose up -d --build api

Buenas prácticas

  • Nunca uses latest como versión de imagen. postgres:latest puede ser una versión diferente la próxima vez que alguien construya el entorno. Siempre especifica la versión exacta: postgres:15-alpine.
  • Usa imágenes Alpine cuando puedas. Las variantes -alpine son imágenes basadas en Alpine Linux, mucho más pequeñas y con menos superficie de ataque de seguridad.
  • El archivo .env nunca va al repositorio. Agrégalo al .gitignore. Sí incluye un .env.example con los nombres de las variables pero sin valores reales.
  • Usa volúmenes con nombre para datos persistentes. Los volúmenes con nombre (postgres-data:) son gestionados por Docker y sobreviven a docker compose down. Los bind mounts (./ruta:/ruta) son para código fuente.
  • No expongas puertos innecesarios. En producción, solo Nginx o el load balancer deben tener puertos expuestos al exterior. Los demás servicios se comunican internamente por la red de Docker.
  • Aprovecha la caché de capas en el Dockerfile. Copia el package.json y ejecuta npm install antes de copiar el código fuente. Así Docker solo reinstala dependencias cuando package.json cambia, no en cada cambio de código.
# Dockerfile optimizado para aprovechar la caché
FROM node:20-alpine
WORKDIR /app

# Estas capas solo se reconstruyen si package.json cambia
COPY package*.json ./
RUN npm ci --only=production

# Esta capa se reconstruye en cada cambio de código
COPY . .

EXPOSE 3000
CMD ["node", "src/index.js"]

Solución a problemas comunes

La API arranca antes de que la base de datos esté lista

# Solución: usar health checks con condition: service_healthy
depends_on:
  postgres:
    condition: service_healthy

Los cambios en el código no se reflejan

# Verificar que el volumen está montado correctamente
volumes:
  - .:/app           # montar el directorio actual en /app
  - /app/node_modules  # excluir node_modules

# Y que el comando usa nodemon u otro mecanismo de hot reload
command: npm run dev   # donde dev usa nodemon

No puedo conectarme a la base de datos desde el host

# Verificar que el puerto está expuesto
ports:
  - "5432:5432"   # puerto_host:puerto_contenedor

# Y que la URL de conexión desde el host usa localhost, no el nombre del servicio
# Desde el HOST:
psql -h localhost -p 5432 -U usuario -d mi_base_de_datos

# Desde DENTRO de un contenedor:
# DATABASE_URL=postgresql://usuario:pass@postgres:5432/mi_base_de_datos

Los datos de la base de datos desaparecen al reiniciar

# Asegúrate de tener un volumen con nombre para la base de datos
services:
  postgres:
    volumes:
      - postgres-data:/var/lib/postgresql/data  # ✅ volumen persistente

volumes:
  postgres-data:   # declarar el volumen aquí

# NO uses solo bind mount para datos de BD:
# - ./data:/var/lib/postgresql/data  # puede tener problemas de permisos

El contenedor se reinicia constantemente

# Ver los logs para entender el error
docker compose logs nombre-servicio

# Ver el estado detallado
docker compose ps

# Inspeccionar el contenedor
docker inspect nombre-contenedor

Conclusión

Docker Compose transforma la configuración del entorno de desarrollo de un proceso manual y propenso a errores en algo reproducible, versionado y compartible. Con un solo archivo y un solo comando, cualquier miembro del equipo puede tener el entorno exacto funcionando en minutos.

Los conceptos clave que debes recordar: los servicios se comunican por su nombre dentro de la red de Docker, los volúmenes con nombre persisten los datos, el archivo .env mantiene los secretos fuera del repositorio, y los health checks garantizan que los servicios arrancan en el orden correcto.

A medida que tu proyecto crezca, Docker Compose crece contigo: puedes separar la configuración en múltiples archivos, usar perfiles para servicios opcionales y integrar el mismo archivo en tu pipeline de CI/CD para tener el mismo entorno en todas partes.

Para seguir profundizando en este tema, te recomendamos leer nuestros artículos sobre qué es Docker y para qué sirve y sobre qué es CI/CD y cómo funciona el despliegue continuo, dos temas que van de la mano con lo que aprendiste aquí.

Etiquetas: docker

¿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