Docker Compose
Multi-service local environments, dependency ordering, networking, and the patterns that make compose actually useful.
Real-World Analogy
A stage manager’s call sheet: one document that says who needs to be where and when before the show can start — orchestra in pit, actors backstage, lights ready. Docker Compose is your call sheet for services: one file, one command, all the pieces start in the right order.
The Problem Compose Solves
Running a modern application locally typically means starting: your API server, a database, a cache, a queue, maybe a worker process. Doing this manually means multiple terminal windows, fragile shell scripts, and “works on my machine” debugging.
Compose defines all of this in one declarative file — docker-compose.yml — and starts everything with docker compose up.
A Complete Example
# docker-compose.yml
services:
api:
build: . # build from local Dockerfile
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@db:5432/mydb
REDIS_URL: redis://redis:6379
NODE_ENV: development
volumes:
- ./src:/app/src # mount source for hot reload
depends_on:
db:
condition: service_healthy # wait for DB to be healthy, not just started
redis:
condition: service_started
worker:
build: .
command: node dist/worker.js # override CMD from Dockerfile
environment:
DATABASE_URL: postgres://app:secret@db:5432/mydb
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data # persist across restarts
- ./migrations:/docker-entrypoint-initdb.d # run on first start
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d mydb"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata: # Start everything
docker compose up
# Start in background
docker compose up -d
# View logs
docker compose logs -f api
# Run a one-off command (migrations)
docker compose run --rm api node dist/migrate.js
# Stop everything (keep volumes)
docker compose down
# Stop and remove volumes (reset state)
docker compose down -v Networking
All services in a Compose file share a default network. Services reach each other by service name:
services:
api:
environment:
# Use service name 'db', not 'localhost' — they're on the same Docker network
DATABASE_URL: postgres://app:secret@db:5432/mydb
# ^^
# service name # Inspect the network
docker network ls
# NETWORK ID NAME DRIVER
# abc123 myproject_default bridge
# From inside the api container, 'db' resolves to the postgres container's IP
docker compose exec api ping db
# PING db (172.20.0.3): 56 data bytes Custom networks for isolation:
services:
api:
networks:
- frontend
- backend
db:
networks:
- backend # not exposed to frontend services
nginx:
networks:
- frontend # not connected to backend
networks:
frontend:
backend: depends_on and Startup Order
depends_on controls startup order but not readiness — a container can be “started” and not yet accepting connections. Use health checks for proper ordering:
services:
api:
depends_on:
db:
condition: service_healthy # wait until healthcheck passes
redis:
condition: service_started # just wait for container to start
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s # grace period before failures count Without health checks: your app starts, tries to connect to postgres, fails because postgres is still initializing, and crashes. With health checks: api waits until postgres reports healthy.
Environment Variables
Three ways to pass environment variables:
services:
api:
# Inline (fine for non-secrets)
environment:
NODE_ENV: development
PORT: "3000"
# From .env file (don't commit this file)
env_file:
- .env
# Reference host environment
environment:
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} # from shell
API_KEY: ${API_KEY:-default-value} # with fallback # .env file (gitignored)
DATABASE_URL=postgres://app:secret@db:5432/mydb
REDIS_URL=redis://redis:6379
JWT_SECRET=dev-secret-not-for-production Compose automatically loads .env in the project directory. Variables in .env are available as ${VAR} in the compose file — but they’re for compose configuration, not automatically passed to containers unless you explicitly reference them.
Override Files
Compose merges multiple files — use this for environment-specific config:
# docker-compose.yml (base — committed)
services:
api:
image: myapp:latest
ports:
- "3000:3000"
# docker-compose.dev.yml (development — committed)
services:
api:
build: . # override: build locally instead of pull
volumes:
- ./src:/app/src # hot reload
environment:
NODE_ENV: development
# docker-compose.override.yml (auto-loaded in dev — often gitignored)
# Docker Compose automatically merges this with docker-compose.yml # Development (auto-merges docker-compose.override.yml)
docker compose up
# Production (explicit files)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# CI (explicit)
docker compose -f docker-compose.yml -f docker-compose.ci.yml up --abort-on-container-exit Useful Patterns
Run database migrations before starting the app:
services:
migrate:
build: .
command: node dist/migrate.js
depends_on:
db:
condition: service_healthy
restart: "no" # run once, don't restart
api:
build: .
depends_on:
migrate:
condition: service_completed_successfully
db:
condition: service_healthy Scale a service:
docker compose up --scale worker=3
# Starts 3 worker containers, all pulling from the same queue Watch for file changes and rebuild:
# Docker Compose Watch (v2.22+)
docker compose watch services:
api:
build: .
develop:
watch:
- action: sync # sync files without rebuild
path: ./src
target: /app/src
- action: rebuild # rebuild on dependency changes
path: package.json Profiles for Optional Services
services:
api:
build: .
db:
image: postgres:16
mailhog:
image: mailhog/mailhog
profiles: [dev] # only started when --profile dev is passed
ports:
- "8025:8025"
adminer:
image: adminer
profiles: [dev, tools]
ports:
- "8080:8080" # Start without optional dev tools
docker compose up
# Start with dev profile
docker compose --profile dev up This keeps the default compose startup minimal while making optional services easy to activate.