Docker Compose para desarrollo local: configuración productiva
Docker Compose para desarrollo local: configuración productiva
Trabajar con múltiples servicios en desarrollo local es una de las fricciones más comunes en el día a día del desarrollador. Bases de datos, caches, colas de mensajes, servicios backend y frontend: cada uno con sus propias dependencias, versiones y configuraciones. Docker Compose resuelve este problema, pero una configuración básica no alcanza. Necesitás una setup que sea rápido, reproducible y que no te haga perder horas en debugging de entornos.
En este artículo voy a mostrarte patrones concretos que aplico en proyectos reales, con ejemplos de código que podés adaptar directamente.
Por qué Docker Compose y no Docker puro
Docker solo te permite manejar contenedores individuales. Cuando tu aplicación necesita una base de datos PostgreSQL, Redis para cache, y tal vez MinIO para almacenamiento de archivos, terminás con scripts de bash interminables o comandos que tenés que recordar de memoria.
Docker Compose te da:
- Un archivo declarativo para definir toda tu stack
- Networking automático entre servicios
- Volúmenes persistentes para desarrollo
- Variables de entorno centralizadas
- Comandos simples para levantar o bajar todo el entorno
La clave está en cómo estructurás ese archivo para que escale con tu equipo.
Estructura de archivos recomendada
No pongas todo en un solo docker-compose.yml. Separar configuraciones te permite adaptar el entorno sin tocar código que funciona:
├── docker/
│ ├── docker-compose.yml # servicios base
│ ├── docker-compose.override.yml # overrides para dev
│ ├── docker-compose.prod.yml # configuración de producción
│ └── dev/
│ ├── postgres/init.sql # scripts de inicialización
│ └── redis/redis.conf # configuración custom
├── Dockerfile
└── .env.example
Esta estructura aprovecha la convención de Docker Compose: automáticamente detecta y fusiona docker-compose.yml con docker-compose.override.yml cuando ejecutás docker compose up.
Configuración base: servicios esenciales
Empecemos con un docker-compose.yml sólido para una aplicación típica con backend, PostgreSQL y Redis:
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
target: development
container_name: miapp-api
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:postgres@db:5432/miapp
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
command: npm run dev
db:
image: postgres:15-alpine
container_name: miapp-db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/dev/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: miapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d miapp"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7-alpine
container_name: miapp-cache
ports:
- "6379:6379"
volumes:
- ./docker/dev/redis/redis.conf:/usr/local/etc/redis/redis.conf
command: redis-server /usr/local/etc/redis/redis.conf
mailer:
image: mailhog/mailhog:latest
container_name: miapp-mailer
ports:
- "1025:1025"
- "8025:8025"
volumes:
postgres_data:
Decisiones importantes en esta configuración
Healthcheck en la base de datos: El backend no intenta conectarse hasta que PostgreSQL está realmente listo. Sin esto, tenés condiciones de carrera donde la app falla al arrancar porque la base de datos todavía se está inicializando.
Volumen anónimo para node_modules: La línea - /app/node_modules es crucial. Sin ella, al montar el directorio de tu host con .:/app, sobrescribís los node_modules instalados durante el build del contenedor. Esto causa errores de módulos no encontrados cuando las arquitecturas difieren (por ejemplo, desarrollás en macOS con Apple Silicon pero el contenedor corre con Linux/amd64).
MailHog para desarrollo: Captura todos los emails que tu aplicación envía. Accedés a la interfaz en http://localhost:8025. El puerto SMTP está en 1025, que configurás en tu aplicación.
Override para desarrollo: hot reload y debugging
El docker-compose.override.yml es donde ponés todo lo específico de desarrollo que no querés en producción:
services:
app:
environment:
- DEBUG=*,-express:*,-body-parser:*
- LOG_LEVEL=debug
volumes:
- ~/.gitconfig:/etc/gitconfig:ro
- ~/.ssh:/root/.ssh:ro
stdin_open: true
tty: true
db:
ports:
- "5432:5432"
volumes:
- ./docker/dev/postgres/backups:/backups
pgadmin:
image: dpage/pgadmin4:latest
container_name: miapp-pgadmin
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@local.dev
PGADMIN_DEFAULT_PASSWORD: admin
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- db
volumes:
pgadmin_data:
Acá agregamos PgAdmin para explorar la base de datos visualmente, mapeamos puertos que solo necesitás en local, y montamos configuraciones de Git y SSH para que funcionen comandos que puedan necesitarlas desde dentro del contenedor.
Dockerfile multi-stage: build vs development
El Dockerfile debe aprovechar el multi-stage building. Esto mantiene la imagen final limpia y permite diferentes configuraciones según el target:
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS dependencies
COPY package*.json ./
RUN npm ci
FROM dependencies AS development
ENV NODE_ENV=development
COPY . .
RUN npm install --include=dev
CMD ["npm", "run", "dev"]
FROM dependencies AS production
ENV NODE_ENV=production
COPY . .
RUN npm run build
RUN npm ci --only=production && npm cache clean --force
USER node
CMD ["node", "dist/main.js"]
Notá el USER node en producción: tu código no corre como root. En desarrollo corremos como root para simplificar permisos con los volúmenes montados.
Variables de entorno: el archivo .env
Nunca comitees valores sensibles. Usá un .env.example versionado y .env ignorado por Git:
# .env.example
COMPOSE_PROJECT_NAME=miapp
# Application
API_PORT=3000
APP_SECRET=change-me-in-production
# Database
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=miapp
# Redis
REDIS_HOST=cache
REDIS_PORT=6379
# External services (opcionales para dev)
S3_ENDPOINT=http://minio:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
El COMPOSE_PROJECT_NAME es importante: evita que Docker Compose genere nombres de contenedor con el nombre del directorio, que puede variar entre máquinas de los desarrolladores.
Scripts de conveniencia: Makefile
Memorizar comandos de Docker Compose es innecesario. Un Makefile estandariza las operaciones comunes:
.PHONY: up down logs shell db-console test
up:
docker compose up -d
down:
docker compose down -v
logs:
docker compose logs -f app
shell:
docker compose exec app sh
db-console:
docker compose exec db psql -U postgres -d miapp
db-backup:
docker compose exec db pg_dump -U postgres miapp > backup_$(shell date +%Y%m%d_%H%M%S).sql
db-restore:
docker compose exec -T db psql -U postgres -d miapp < $(file)
test:
docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit
migrate:
docker compose exec app npm run migrate
seed:
docker compose exec app npm run seed
Con esto, make up levanta todo, make shell te mete en el contenedor de la app, y make db-backup genera un dump con timestamp sin pensar en la sintaxis de docker exec.
Patrón para múltiples bases de datos o servicios
Cuando crecés, necesitás más servicios. Por ejemplo, un worker de background jobs con Bull y Redis:
worker:
build:
context: .
dockerfile: Dockerfile
target: development
container_name: miapp-worker
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- REDIS_URL=redis://cache:6379
- DATABASE_URL=postgresql://postgres:postgres@db:5432/miapp
depends_on:
- db
- cache
command: npm run worker
queue-monitor:
image: ej3/fundairy:latest
container_name: miapp-queue-monitor
ports:
- "3001:3000"
environment:
BULL_REDIS_HOST: cache
BULL_REDIS_PORT: 6379
depends_on:
- cache
El worker es una instancia separada de la misma imagen, pero con un comando diferente. Esto refleja cómo vas a deployar en producción: múltiples procesos con responsabilidades distintas.
Solución de problemas comunes
Contenedores que no arrancan: revisá los logs
docker compose logs --tail 100 servicio
El flag --tail evita que te inundes con logs históricos. Si un servicio falla en el healthcheck, los logs te muestran por qué.
Volúmenes corruptos o datos de prueba
make down && docker volume prune -f && make up
docker-compose down -v elimina volúmenes nombrados. docker volume prune limpia los anónimos. Úsalo con cuidado: perdés todos los datos locales.
Permisos de archivos en Linux
En Linux, los archivos creados desde el contenedor quedan con UID de root. Dos soluciones:
- Correr el contenedor con tu UID:
user: "${UID}:${GID}"en el compose - Configurar Docker Desktop para usar el namespace remapping (en settings)
En Mac y Windows, Docker Desktop maneja esto automáticamente con su filesystem proxy.
Hot reload que no funciona
Si usás nodemon o vite, asegurate de que detecte cambios correctamente. En algunos casos necesitás configurar CHOKIDAR_USEPOLLING=true o --legacy-watch dependiendo del filesystem:
environment:
- CHOKIDAR_USEPOLLING=true
command: npx nodemon --legacy-watch src/index.ts
Integración con bases de código existentes
No siempre empezás de cero. Para migrar un proyecto existente:
- Identificá las dependencias externas (bases de datos, servicios, etc.)
- Creá un
docker-compose.ymlcon esos servicios primero, sin tocar la app - Asegurate de que funcione: conectate con
psql,redis-cli, etc. - Luego dockerizá la aplicación misma, manteniendo el puerto expuesto para desarrollo local
- Finalmente, integrá todo y probá el flujo completo
Este approach incremental evita que te atasques tratando de hacer todo de una vez.
Consideraciones para equipos
Cuando trabajás en equipo, la consistencia es más importante que la perfección:
- Misma versión de Docker Engine: Documentá la versión mínima en el README
- Archivo
.env.exampleactualizado: Cada nueva variable de entorno debe aparecer ahí - Guía de troubleshooting: Los problemas que un dev resuelve, otros los van a tener
- CI que valide el compose:
docker compose configverifica sintaxis sin levantar nada
Un pipeline simple en GitHub Actions:
name: Validate Docker Compose
on: [push, pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate compose syntax
run: docker compose -f docker-compose.yml config
- name: Build images
run: docker compose build
Conclusión
Un buen setup de Docker Compose para desarrollo local no es solo "hacer funcionar" los contenedores. Es reducir la fricción entre escribir código y verlo ejecutarse, mantener la consistencia entre los miembros del equipo, y aproximar el entorno de desarrollo al de producción sin sacrificar la velocidad.
Los patrones que mostré —separación de archivos, healthchecks, volúmenes correctos, scripts de conveniencia— los uso en proyectos con equipos de 2 a 20 desarrolladores. La inversión inicial de configurarlos bien se paga en horas no perdidas debugueando problemas de entorno.
Empezá con lo esencial, iterá según las necesidades de tu proyecto, y mantené la documentación viva. El objetivo es que cualquier desarrollador nuevo levante el entorno con make up y esté productivo en minutos, no en horas.