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

PolicyUse case
unless-stoppedProduction services — restart on crash, survive Docker daemon restart
alwaysCritical services — same as above but also restart after manual stop
on-failureBatch jobs that should only retry on error
noOne-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 config passes without warnings