You've mastered Docker Compose for local development. Your docker-compose.yml spins up your app, database, and cache in seconds. But when it's time to deploy to production, everything changes.
Production is unforgiving. Containers crash. Databases lose connections. Memory leaks go unnoticed until 3 AM. Traffic spikes take your app offline.
This guide bridges the gap between "it works on my machine" and a reliable, production-grade Docker Compose deployment that you can trust with real users and real money.
Why Docker Compose in Production?
Before we dive in, let's address the elephant in the room: "Should I even use Docker Compose in production?"
The answer depends on your scale:
| Scale | Best Tool | Why |
|---|---|---|
| 1-3 servers, <50k users/month | Docker Compose | Simple, fast, low overhead |
| 3-10 servers, 50k-500k users | Docker Swarm | Built-in orchestration, easy upgrade from Compose |
| 10+ servers, 500k+ users | Kubernetes | Full orchestration, auto-scaling, enterprise features |
For most small-to-medium projects — SaaS apps, eCommerce sites, APIs, internal tools — Docker Compose is more than enough. And it's dramatically simpler to manage than Kubernetes.
Step 1: Production-Ready docker-compose.yml
Here's a real-world production Compose file for a web application with PostgreSQL, Redis, and NGINX reverse proxy:
# docker-compose.prod.yml
version: "3.8"
services:
app:
image: registry.yourdomain.com/myapp:${APP_VERSION:-latest}
restart: unless-stopped
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
cpus: "0.5"
memory: 256M
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
- REDIS_URL=redis://cache:6379
- SESSION_SECRET=${SESSION_SECRET}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
networks:
- backend
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backups:/backups
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 2G
networks:
- backend
shm_size: 256mb
cache:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASS} --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASS}", "ping"]
interval: 10s
timeout: 5s
retries: 3
networks:
- backend
nginx:
image: nginx:1.25-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- app
networks:
- backend
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
backend:
driver: bridge
Key Production Differences
- Resource limits — Prevent any container from consuming all server resources
- Health checks — Docker automatically restarts unhealthy containers
- Logging limits — Prevent log files from filling your disk
- Restart policy —
unless-stoppedensures containers survive server reboots - No build directives — Production uses pre-built images from a registry
- Environment variables — Never hardcode secrets in the Compose file
Step 2: Secrets Management
Never put passwords in your docker-compose.yml. Use a .env file that is never committed to git:
# .env (add to .gitignore!)
APP_VERSION=1.4.2
DB_NAME=myapp_production
DB_USER=myapp
DB_PASS=super_secure_random_password_here
REDIS_PASS=another_secure_password
SESSION_SECRET=64_char_random_string_here
# Generate secure passwords
openssl rand -base64 32 # For DB_PASS
openssl rand -hex 32 # For SESSION_SECRET
File permissions matter:
# Restrict .env file access
chmod 600 .env
chown root:root .env
Step 3: Zero-Downtime Deployments
The biggest challenge in production: updating your application without users noticing.
#!/bin/bash
# deploy.sh — Zero-downtime deployment script
set -euo pipefail
APP_VERSION="$1"
COMPOSE_FILE="docker-compose.prod.yml"
echo "[$(date)] Starting deployment of version $APP_VERSION"
# Pull new image
export APP_VERSION
docker compose -f $COMPOSE_FILE pull app
# Rolling update: start new container, then stop old one
docker compose -f $COMPOSE_FILE up -d --no-deps --build app
# Wait for health check to pass
echo "Waiting for health check..."
for i in {1..30}; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' \
$(docker compose -f $COMPOSE_FILE ps -q app) 2>/dev/null || echo "starting")
if [ "$STATUS" = "healthy" ]; then
echo "[$(date)] Deployment successful! Version: $APP_VERSION"
exit 0
fi
echo " Health check attempt $i/30: $STATUS"
sleep 5
done
echo "[$(date)] ERROR: Health check failed! Rolling back..."
docker compose -f $COMPOSE_FILE rollback app
exit 1
Step 4: Database Backups
Your database is the most valuable thing in production. Automate backups:
#!/bin/bash
# db-backup.sh — Automated PostgreSQL backup from Docker
# Schedule: 0 */6 * * * (every 6 hours)
BACKUP_DIR="./backups"
RETENTION_DAYS=14
DATE=$(date "+%Y%m%d_%H%M%S")
CONTAINER=$(docker compose -f docker-compose.prod.yml ps -q db)
mkdir -p "$BACKUP_DIR"
# Create compressed backup
docker exec "$CONTAINER" pg_dump -U "$DB_USER" -Fc "$DB_NAME" \
> "${BACKUP_DIR}/db_${DATE}.dump"
# Verify backup integrity
if docker exec "$CONTAINER" pg_restore --list "${BACKUP_DIR}/db_${DATE}.dump" > /dev/null 2>&1; then
SIZE=$(du -h "${BACKUP_DIR}/db_${DATE}.dump" | cut -f1)
echo "[$(date)] Backup OK: ${SIZE}"
else
echo "[$(date)] WARNING: Backup verification failed!"
fi
# Remove old backups
find "$BACKUP_DIR" -name "*.dump" -mtime +${RETENTION_DAYS} -delete
For comprehensive backup strategies including point-in-time recovery and off-site replication, check out Linux Backup Strategies by Miles Everhart.
Step 5: Monitoring and Alerts
You can't fix what you can't see. Add monitoring to your stack:
# Add to docker-compose.prod.yml
monitoring:
image: prom/prometheus:latest
restart: unless-stopped
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
ports:
- "9090:9090"
networks:
- backend
grafana:
image: grafana/grafana:latest
restart: unless-stopped
volumes:
- grafana_data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASS}
ports:
- "3001:3000"
networks:
- backend
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
restart: unless-stopped
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
networks:
- backend
Step 6: NGINX Production Configuration
# nginx/conf.d/app.conf
upstream app_servers {
server app:3000;
keepalive 32;
}
server {
listen 80;
server_name yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=63072000" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1000;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
location / {
proxy_pass http://app_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app_servers;
}
}
Step 7: Security Hardening
# Run containers as non-root
app:
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=100M
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
For a deep dive into securing your entire Linux server (not just Docker), see our Linux Security Hardening guide and the advanced Linux System Hardening eBook.
Production Checklist
| Item | Status |
|---|---|
| Resource limits on all containers | ☐ |
| Health checks for every service | ☐ |
| Secrets in .env file (not in compose) | ☐ |
| Automated database backups | ☐ |
| Log rotation configured | ☐ |
| SSL/TLS termination via NGINX | ☐ |
| Monitoring (Prometheus/Grafana) | ☐ |
| Zero-downtime deploy script | ☐ |
| Non-root containers | ☐ |
| Firewall rules (only expose 80/443) | ☐ |
Common Mistakes to Avoid
- Using
docker compose up -dwith build in production — Always use pre-built images - No backup strategy — If your volume disappears, your data is gone forever
- Exposing database ports — PostgreSQL should never be reachable from outside
- Ignoring disk space — Docker images and logs fill disks fast
- No monitoring — You'll find out about problems from your users
Next Steps
Ready to level up your Docker skills? Here's your reading path:
- Docker Fundamentals — Master the core concepts
- Docker Compose & Multi-Container Applications — Deep dive into Compose
- Microservices with Docker and Kubernetes — Scale beyond Compose
- Linux Backup Strategies — Protect your production data
Master Docker in Production
Get our complete Docker eBook collection and deploy with confidence.
Browse Docker Books