Docker Compose is often dismissed as “just for development,” but with the right patterns it handles production workloads well. Here are patterns that have survived contact with real systems.
Service Dependencies Done Right#
The basic depends_on only waits for the container to start, not for the service to be ready:
1
2
3
4
5
6
7
| # This doesn't guarantee postgres is accepting connections
services:
app:
depends_on:
- db
db:
image: postgres:15
|
Use health checks for real dependency management:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| services:
app:
depends_on:
db:
condition: service_healthy
# ...
db:
image: postgres:15
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
|
Now app won’t start until db is actually accepting connections.
Environment Management#
Don’t hardcode secrets. Use .env files with a template:
1
2
3
4
5
6
7
| # .env.example (committed to git)
POSTGRES_PASSWORD=changeme
API_KEY=your-api-key-here
# .env (gitignored, actual values)
POSTGRES_PASSWORD=actual-secure-password
API_KEY=sk-actual-key
|
Reference in compose:
1
2
3
4
5
| services:
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
For production, consider Docker secrets or external secret management.
Network Isolation#
Default: all services can talk to each other. Better: explicit network boundaries.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| services:
frontend:
networks:
- frontend-net
api:
networks:
- frontend-net
- backend-net
db:
networks:
- backend-net # Not accessible from frontend
networks:
frontend-net:
backend-net:
|
The database is only reachable from the API, not from the frontend container.
Resource Limits#
Prevent runaway containers from killing the host:
1
2
3
4
5
6
7
8
9
10
| services:
app:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
|
Note: deploy requires docker compose (v2), not docker-compose (v1).
Restart Policies#
1
2
3
4
5
6
7
8
9
10
11
| services:
app:
restart: unless-stopped # Survives host reboot, respects manual stop
worker:
restart: on-failure # Only restart if exit code != 0
deploy:
restart_policy:
condition: on-failure
max_attempts: 3
delay: 5s
|
For critical services, unless-stopped is usually what you want.
Logging Configuration#
Don’t let logs fill your disk:
1
2
3
4
5
6
7
| services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
|
Or send to a central logging system:
1
2
3
4
5
6
7
| services:
app:
logging:
driver: syslog
options:
syslog-address: "tcp://logs.example.com:514"
tag: "myapp"
|
Volume Patterns#
Named volumes for persistence:
1
2
3
4
5
6
7
8
9
| services:
db:
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
# Survives `docker compose down`
# Removed only with `docker compose down -v`
|
Bind mounts for development, named volumes for production:
1
2
3
4
5
6
| services:
app:
volumes:
# Development: live code reload
- ./src:/app/src:ro
# Production: use built image instead
|
Multi-Environment Compose#
Base compose with overrides:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| # docker-compose.yml (base)
services:
app:
image: myapp:latest
environment:
NODE_ENV: production
# docker-compose.override.yml (auto-loaded in dev)
services:
app:
build: .
volumes:
- ./src:/app/src
environment:
NODE_ENV: development
# docker-compose.prod.yml (explicit for production)
services:
app:
deploy:
replicas: 3
resources:
limits:
memory: 1G
|
Usage:
1
2
3
4
5
| # Development (auto-loads override)
docker compose up
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
Health Check Patterns#
For web services:
1
2
3
4
5
6
| healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s # Grace period for slow starters
|
For services without curl:
1
2
| healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/health || exit 1"]
|
For databases:
1
2
3
4
5
6
7
8
9
10
11
| # PostgreSQL
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
# MySQL
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
# Redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
|
Initialization Scripts#
Run setup on first start:
1
2
3
4
5
6
| services:
db:
image: postgres:15
volumes:
- ./init-scripts:/docker-entrypoint-initdb.d:ro
- postgres_data:/var/lib/postgresql/data
|
Scripts in /docker-entrypoint-initdb.d/ run once when the volume is first created.
Sidecar Pattern#
Run supporting containers alongside your app:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| services:
app:
image: myapp:latest
volumes:
- app_logs:/var/log/app
log-shipper:
image: fluent/fluent-bit
volumes:
- app_logs:/var/log/app:ro
depends_on:
- app
volumes:
app_logs:
|
Graceful Shutdown#
Give containers time to finish work:
1
2
3
4
| services:
app:
stop_grace_period: 30s # Default is 10s
# Container receives SIGTERM, has 30s before SIGKILL
|
In your app, handle SIGTERM:
1
2
3
4
5
6
7
8
9
| import signal
import sys
def shutdown(signum, frame):
print("Graceful shutdown...")
# Close connections, finish requests
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
|
The Production Checklist#
Before deploying compose to production:
Docker Compose won’t replace Kubernetes for large-scale orchestration, but for single-host deployments and small clusters, these patterns get you surprisingly far.