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:

  • Health checks on all services
  • Resource limits set
  • Restart policies configured
  • Logging rotation enabled
  • Secrets externalized (not in compose file)
  • Networks isolated appropriately
  • Volumes backed up
  • stop_grace_period set for stateful services
  • depends_on with condition: service_healthy

Docker Compose won’t replace Kubernetes for large-scale orchestration, but for single-host deployments and small clusters, these patterns get you surprisingly far.