Docker Compose is often treated as a development tool, but with the right patterns it handles production workloads reliably. This guide covers the production patterns that survive restarts, handle logging, manage secrets, and keep databases healthy.
Production file structure
Keep production and development configs separate:
project/
├── docker-compose.yml # Base config
├── docker-compose.prod.yml # Production overrides
├── .env.prod # Production environment
├── data/ # Persistent volumes
└── config/ # Mounted config files
The base compose file
# docker-compose.yml
services:
app:
build:
context: .
target: runner
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
environment:
- NODE_ENV=production
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
db:
image: mariadb:11.4
restart: unless-stopped
volumes:
- db_data:/var/lib/mysql
environment:
- MYSQL_ROOT_PASSWORD_FILE=/run/secrets/db_root_password
secrets:
- db_root_password
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes:
db_data:
secrets:
db_root_password:
file: ./secrets/db_root_password.txt
Production overrides
# docker-compose.prod.yml
services:
app:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 256M
db:
logging:
driver: json-file
options:
max-size: "100m"
max-file: "3"
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
cpus: "1.0"
memory: 512M
Run with both files:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Resource limits are not optional
Without resource limits, one container can consume all CPU or memory and starve others. Always set limits on production services:
deploy:
resources:
limits:
cpus: "1.0" # 1 CPU core maximum
memory: 512M # 512 MB maximum
reservations:
cpus: "0.25" # Guaranteed 0.25 cores
memory: 128M # Guaranteed 128 MB
Health checks
Every service needs a health check. Without one, Docker cannot know if your app is actually running — it only knows the process exists.
For web apps, use an HTTP health endpoint. For databases, use the vendor check command. For everything else, create a small health check script.
# WordPress PHP-FPM health check pattern
healthcheck:
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
Logging
Default Docker logging stores everything on disk indefinitely. Production services need log rotation:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
For multi-server setups, use a logging driver like fluentd or gelf to ship logs to a central location. But for single-server setups, json-file with rotation is sufficient.
Database volumes
Always use named volumes for databases — never bind mounts:
volumes:
- db_data:/var/lib/mysql # Correct: named volume
# - ./data:/var/lib/mysql # Wrong: bind mount risks permission issues
Named volumes are managed by Docker and survive prune operations. They also perform better on most filesystems.
Backup a named volume
# Backup
docker run --rm -v db_data:/data -v $(pwd):/backup alpine tar czf /backup/db_backup.tar.gz -C /data .
# Restore
docker run --rm -v db_data:/data -v $(pwd):/backup alpine tar xzf /backup/db_backup.tar.gz -C /data
Secrets management
Do not put passwords in environment variables — they show up in docker inspect and logs. Use Docker secrets:
secrets:
db_password:
file: ./secrets/db_password.txt
services:
app:
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
The app reads the password from the file path in DB_PASSWORD_FILE. This is more secure than exposing it as an env var.
Restart policies
| Policy | Use case |
|---|---|
unless-stopped | Production services — restart on crash, survive Docker daemon restart |
always | Critical services — same as above but also restart after manual stop |
on-failure | Batch jobs that should only retry on error |
no | One-off tasks |
Use unless-stopped for most services. Use always only if the service must run at all times (databases, proxies).
Networking
Put services that do not need external access on an internal network:
networks:
frontend:
# Exposed to the internet via reverse proxy
backend:
internal: true
# Database and internal services only
services:
proxy:
networks:
- frontend
- backend
app:
networks:
- backend
db:
networks:
- backend
Only the proxy (nginx/traefik) needs to be on both networks. The app and database are isolated from direct internet access.
Automatic updates
For non-critical services, watchtower can auto-update containers:
services:
watchtower:
image: containrrr/watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --interval 86400 --cleanup
environment:
- WATCHTOWER_NOTIFICATIONS=email
For databases: never auto-update. For app containers with CI/CD: deploy intentionally. Watchtower is best for sidecar services (log shippers, monitoring agents).
The pre-deployment checklist
- Resource limits set on every container
- Health checks defined for every service
- Log rotation configured
- Database uses named volumes (not bind mounts)
- Restart policy is
unless-stopped - Secrets use Docker secrets or files, not env vars
- Internal services are on internal networks
- Backup strategy covers named volumes
-
docker compose configpasses without warnings