← Volver al blog

Docker Compose para desarrollo local: configuración productiva

2026-05-24

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:

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:

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:

  1. Identificá las dependencias externas (bases de datos, servicios, etc.)
  2. Creá un docker-compose.yml con esos servicios primero, sin tocar la app
  3. Asegurate de que funcione: conectate con psql, redis-cli, etc.
  4. Luego dockerizá la aplicación misma, manteniendo el puerto expuesto para desarrollo local
  5. 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:

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.