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 espostgres(el nombre del servicio), nolocalhost. Docker Compose crea una red interna donde los servicios se resuelven por su nombre. - El volumen
/app/node_modulessin ruta de host evita que elnode_modulesdel 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 topVariables 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 redisEjemplo 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 -dScripts 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 ordenLos 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 apiBuenas prácticas
- Nunca uses
latestcomo versión de imagen.postgres:latestpuede 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
-alpineson imágenes basadas en Alpine Linux, mucho más pequeñas y con menos superficie de ataque de seguridad. - El archivo
.envnunca va al repositorio. Agrégalo al.gitignore. Sí incluye un.env.examplecon 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 adocker 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.jsony ejecutanpm installantes de copiar el código fuente. Así Docker solo reinstala dependencias cuandopackage.jsoncambia, 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_healthyLos 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 nodemonNo 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_datosLos 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 permisosEl 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-contenedorConclusió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í.
No hay comentarios todavía. Sé el primero en compartir tu opinión.