Docker Compose for Local Development: A Practical Guide
How I use Docker Compose to set up reproducible local development environments — databases, caches, and services without polluting my host system.
Introduction
Every backend developer I know has a graveyard of services installed directly on their machine. PostgreSQL that was set up four years ago for a project that's long dead. Redis running on default config with no password. RabbitMQ that you installed once and now it's just... there. Consuming ports. Consuming memory.
I stopped installing services on my host years ago. Everything runs in Docker Compose. One docker compose up and my entire development environment is ready — databases, message queues, caches, monitoring. When I'm done, docker compose down wipes the slate clean.
This isn't about production deployments. It's about making local development reproducible, fast, and clean.
"Your laptop is not a server. Stop treating it like one. Docker Compose gives you disposable infrastructure — use it, abuse it, throw it away."
Why Docker Compose (Not Plain Docker)
Docker Compose adds three things over plain docker run:
- Declarative config — the whole stack is defined in
docker-compose.yml - Networking — every service is reachable by its container name
- Orchestration — start everything with one command, stop with another
Step 1: A Typical stack
Here's a docker-compose file I use for almost every backend project:
# docker-compose.yml
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: myapp
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --requirepass myapp
volumes:
- redisdata:/data
rabbitmq:
image: rabbitmq:3-management-alpine
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: myapp
RABBITMQ_DEFAULT_PASS: myapp
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://myapp:myapp@postgres:5432/myapp
REDIS_URL: redis://:myapp@redis:6379
RABBITMQ_URL: amqp://myapp:myapp@rabbitmq:5672
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
rabbitmq:
condition: service_started
volumes:
- .:/app
- /app/node_modules
command: pnpm dev
volumes:
pgdata:
redisdata:Key Patterns
healthcheckon postgres ensures the app waits for the database to be readyvolumesfor both persistence (pgdata) and hot-reloading (.:/app)depends_onwithcondition: service_healthyprevents race conditions- Services use the internal Docker network (container names as hostnames)
- Ports are exposed to the host for debugging tools (pgAdmin, RedisInsight)
Step 2: Environment-Specific Overrides
Rarely do I want the same setup for development, testing, and CI. Docker Compose supports multiple files that merge together:
docker-compose.override.yml (Development)
Automatically loaded when you run docker compose up if it exists:
services:
postgres:
ports:
- "5432:5432" # Exposed for local tools
volumes:
- ./test-data:/docker-entrypoint-initdb.d
app:
volumes:
- .:/app # Mount source for hot reload
environment:
NODE_ENV: development
LOG_LEVEL: debugdocker-compose.test.yml (CI)
services:
postgres:
# No port exposure needed in CI
expose:
- "5432"
app:
build: .
environment:
NODE_ENV: test
DATABASE_URL: postgres://myapp:myapp@postgres:5432/myapp_test
command: pnpm test# Run in CI
docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exitStep 3: Wait Strategies
Containers start in parallel. Postgres takes 3 seconds. Your app starts in 1 second. Your app crashes because Postgres isn't ready.
The Wrong Way
sleep 5 # Please don'tThe Right Way
Use depends_on with health checks (shown above), or use a wait script:
#!/bin/bash
# wait-for.sh
until pg_isready -h postgres -U myapp; do
echo "Waiting for postgres..."
sleep 1
done
echo "Postgres is ready"
exec "$@"COPY wait-for.sh /wait-for.sh
CMD ["/wait-for.sh", "node", "server.js"]Or use the dockerize tool for more complex waits:
ADD https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64.tar.gz /tmp/
RUN tar -C /usr/local/bin -xzf /tmp/dockerize-linux-amd64.tar.gz
CMD ["dockerize", "-wait", "tcp://postgres:5432", "-timeout", "30s", "node", "server.js"]Step 4: Development Workflow
Starting the Stack
# Start everything in the background
docker compose up -d
# Check logs for a specific service
docker compose logs -f app
# Rebuild and restart a single service
docker compose up -d --build app
# Run a one-off command inside a service
docker compose exec app node scripts/migrate.ts
# Run a database command
docker compose exec postgres psql -U myapp -d myapp
# Access Redis CLI
docker compose exec redis redis-cli -a myappTeardown
# Stop but keep data volumes
docker compose down
# Stop and delete everything (fresh start)
docker compose down -v
# Rebuild everything from scratch
docker compose up -d --build --force-recreateThe Daily Loop
alias dc="docker compose"
alias dcu="dc up -d"
alias dcd="dc down"
alias dcl="dc logs -f"
alias dce="dc exec"
# Morning
dcu # Start databases
pnpm dev # Start app locally (hot reload)
# When switching projects
dcd
cd ../other-project
dcu
pnpm devStep 5: Performance Considerations
Docker on macOS and Windows uses a VM layer, and filesystem performance can be terrible:
Volume Mount Optimization
services:
app:
volumes:
# Use :delegated or :cached on macOS/Windows
- .:/app:delegated
# Exclude node_modules from mount
- /app/node_modulesUse Bind Mounts for Source, Named Volumes for Data
services:
postgres:
# Named volume for database — much faster than bind mount
volumes:
- pgdata:/var/lib/postgresql/data
app:
# Bind mount for source — needed for hot reload
volumes:
- .:/appNode Modules Optimization
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
# In docker-compose, mount . over /app but exclude node_modules
# Use a separate volume for node_modules to avoid overwritingThe /app/node_modules empty mount in the compose file prevents the host's (possibly empty or different-platform) node_modules from overwriting the one built inside the container.
- Use
:delegatedon macOS/Windows for source mounts - Use named volumes for database data
- Exclude
node_modulesfrom host mounts
Common Issues and Fixes
Port Conflicts
# Check what's using port 5432
sudo lsof -i :5432
# Change the host port in docker-compose.yml
ports:
- "5433:5432" # Use 5433 on the host insteadContainer Naming Conflicts
# If you have multiple projects using the same container names
docker compose -p myproject up -d # -p sets the project nameDocker Daemon Not Running
# Start Docker daemon
sudo systemctl start docker
# Or on macOS: open -a DockerComparison: Host vs Docker Compose
| Aspect | Host Installation | Docker Compose | |--------|-----------------|----------------| | Setup time | 20-60 min per service | 2 minutes (docker compose up) | | Reproducibility | Depends on host OS | Identical everywhere | | Cleanup | Manual, partial | docker compose down -v~ | | Version conflicts | Yes, frequently | No, each project has its own | | Resource overhead | Native | Some (but negligible for dev) | | Debugging | Direct connection | Port mapping required |
Conclusion
Docker Compose for local development is not about Docker skills. It's about hygiene. It keeps your host system clean, ensures reproducible environments, and lets you switch between projects without carrying baggage from one to another.
I have a docker-compose.yml in every project now. It's the first file I write after README.md. Because the first thing anyone should be able to do when they clone my repo is docker compose up and have a working development environment without installing a single service on their machine.
The future of development is disposable infrastructure. Containers you spin up, use, and destroy. Docker Compose is how you get there.
