Skip to content
Back to Blog
DevOps

Docker Compose for Production: Best Practices

Use Docker Compose in production with health checks, resource limits, secrets management, logging drivers, and rolling updates.

Oct 2025
12 min read

Introduction

Docker Compose is excellent for local development, but running it in production requires additional considerations: high availability, log management, secrets handling, health checks, and restart policies. This guide teaches you to configure Docker Compose for production workloads and understand when to move to Kubernetes or Docker Swarm instead.

Production vs Development Compose Files

Use multiple compose files with override pattern:

YAML
# docker-compose.yml (base - shared between dev and prod)
version: '3.8'

services:
  app:
    image: company/myapp:${APP_VERSION:-latest}
    environment:
      - DATABASE_URL
      - REDIS_URL
    networks:
      - backend
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  postgres:
    image: postgres:15
    environment:
      - POSTGRES_DB
      - POSTGRES_USER
      - POSTGRES_PASSWORD
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    networks:
      - backend

volumes:
  postgres-data:

networks:
  backend:
    driver: bridge
YAML
# docker-compose.prod.yml (production overrides)
version: '3.8'

services:
  app:
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 2G
        reservations:
          cpus: '0.5'
          memory: 512M
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`app.company.com`)"
      - "traefik.http.routers.app.tls.certresolver=letsencrypt"

  postgres:
    restart: unless-stopped
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf
    command: postgres -c config_file=/etc/postgresql/postgresql.conf
    deploy:
      resources:
        limits:
          cpus: '4.0'
          memory: 4G

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./ssl:/etc/ssl/certs
      - /var/log/nginx:/var/log/nginx
    depends_on:
      - app

volumes:
  postgres-data:
    driver: local
    driver_opts:
      type: none
      device: /data/postgres    # Mount to fast SSD
      o: bind
BASH
# Run in production (merges both files)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Secrets Management

Never put secrets in docker-compose.yml directly:

BASH
# Create .env file (never commit to git!)
cat > /opt/app/.env << 'EOF'
APP_VERSION=v3.5.2
DATABASE_URL=postgresql://appuser:Str0ngP@ss!@postgres:5432/appdb
POSTGRES_DB=appdb
POSTGRES_USER=appuser
POSTGRES_PASSWORD=Str0ngP@ss!
REDIS_URL=redis://:RedisP@ss!@redis:6379
REDIS_PASSWORD=RedisP@ss!
EOF

chmod 600 /opt/app/.env

# Run with env file
docker compose --env-file /opt/app/.env up -d

Using Docker secrets (Swarm mode):

BASH
# Create secrets
echo "Str0ngP@ss!" | docker secret create postgres_password -
echo "RedisP@ss!" | docker secret create redis_password -

# Reference in compose (Swarm only)
version: '3.8'
services:
  postgres:
    secrets:
      - postgres_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password

secrets:
  postgres_password:
    external: true

Health Checks and Dependencies

YAML
services:
  app:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s    # Grace period during startup

  # App only starts after DB is healthy
  depends_on:
    postgres:
      condition: service_healthy
    redis:
      condition: service_healthy

  # Entrypoint waits for dependencies
  entrypoint: >
    sh -c "
      echo 'Waiting for postgres...'
      until pg_isready -h postgres -U app; do sleep 1; done
      echo 'Running migrations...'
      python manage.py migrate
      echo 'Starting server...'
      gunicorn app:app -w 4 -b 0.0.0.0:8080
    "

Logging Strategy

YAML
# Centralized logging with Loki
services:
  app:
    logging:
      driver: loki
      options:
        loki-url: "http://loki:3100/loki/api/v1/push"
        loki-labels: "job=app,environment=production"
        loki-retries: "5"
        loki-batch-size: "400"
        max-size: "50m"         # Local backup in case Loki is down

  # OR: Use json-file and ship with Promtail sidecar
  app:
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "10"
        tag: "{{.Name}}/{{.ID}}"

  promtail:
    image: grafana/promtail:latest
    volumes:
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - ./promtail-config.yaml:/etc/promtail/config.yaml
    command: -config.file=/etc/promtail/config.yaml

Deployment Script

BASH
#!/bin/bash
# deploy.sh - Zero-downtime production deployment

set -euo pipefail

APP_DIR="/opt/myapp"
COMPOSE_CMD="docker compose -f docker-compose.yml -f docker-compose.prod.yml"

echo "=== Deploying $(APP_VERSION) to production ==="

cd "$APP_DIR"

# Pull new images first
$COMPOSE_CMD pull app

# Rolling restart (if using multiple replicas via nginx)
# Scale up with new version, then scale down old
$COMPOSE_CMD up -d --no-deps --scale app=4 app
sleep 15  # Wait for new instances to be healthy

# Verify health
if ! curl -sf http://localhost/health > /dev/null; then
    echo "Health check failed! Rolling back..."
    $COMPOSE_CMD up -d --no-deps --scale app=2 app
    exit 1
fi

# Scale back to normal
$COMPOSE_CMD up -d --no-deps --scale app=2 app

echo "=== Deployment complete ==="

Monitoring Container Resource Usage

BASH
# Live stats for all containers
docker stats

# Stats in specific format for monitoring
docker stats --format "table {{.Name}}	{{.CPUPerc}}	{{.MemUsage}}	{{.NetIO}}	{{.BlockIO}}"

# Check logs with timestamps
docker compose logs -f --timestamps app

# Container events
docker events --filter 'type=container'

Backup Strategy for Compose Services

BASH
#!/bin/bash
# backup.sh - Backup production database

BACKUP_DIR="/backup/postgres"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"

# Dump database from running container
docker compose exec -T postgres   pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"   | gzip > "$BACKUP_DIR/backup_${DATE}.sql.gz"

# Keep only last 30 days
find "$BACKUP_DIR" -name "backup_*.sql.gz" -mtime +30 -delete

# Upload to S3
aws s3 cp "$BACKUP_DIR/backup_${DATE}.sql.gz"   "s3://company-backups/postgres/${DATE}.sql.gz"

Docker Compose in production works well for small to medium deployments (single server or few servers). When you need multi-host scheduling, auto-healing, or fine-grained resource management across a large cluster, graduate to Kubernetes. But for many teams, a well-configured Compose setup is simpler to operate and debug.