Docker Compose started as a development tool but has grown into something usable for small production deployments. Here are patterns that make it work well in both contexts.

Environment-Specific Overrides

Base configuration with environment-specific overrides:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# docker-compose.yml (base)
services:
  app:
    image: myapp:${VERSION:-latest}
    environment:
      - DATABASE_URL
    depends_on:
      - db
      
  db:
    image: postgres:15
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# docker-compose.override.yml (auto-loaded in dev)
services:
  app:
    build: .
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - DEBUG=true
    ports:
      - "3000:3000"
      
  db:
    ports:
      - "5432:5432"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# docker-compose.prod.yml
services:
  app:
    restart: always
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
        
  db:
    restart: always

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 Checks

Don’t assume containers are ready just because they started:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
services:
  app:
    image: myapp:latest
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
      
  db:
    image: postgres:15
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
      
  redis:
    image: redis:7
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

Named Volumes vs Bind Mounts

Named Volumes (Production)

1
2
3
4
5
6
7
8
9
services:
  db:
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
    # Optional: external volume managed outside compose
    # external: true

Pros: Docker manages the data, portable, better performance on Mac/Windows.

Bind Mounts (Development)

1
2
3
4
5
services:
  app:
    volumes:
      - ./src:/app/src          # Code for hot reload
      - /app/node_modules       # Anonymous volume for deps

Pros: Direct filesystem access, instant code changes.

Hybrid Pattern

1
2
3
4
5
services:
  app:
    volumes:
      - app_cache:/app/.cache   # Named volume for build cache
      - ./src:/app/src          # Bind mount for source

Secrets Management

Environment Files

1
2
3
4
5
services:
  app:
    env_file:
      - .env
      - .env.local  # Overrides, gitignored
1
2
3
4
5
6
7
# .env
DATABASE_HOST=db
LOG_LEVEL=info

# .env.local (gitignored)
DATABASE_PASSWORD=secret123
API_KEY=abc123

Docker Secrets (Swarm mode)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
services:
  app:
    secrets:
      - db_password
      - api_key
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    external: true  # Created via `docker secret create`

Networking Patterns

Internal Networks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
services:
  app:
    networks:
      - frontend
      - backend
      
  db:
    networks:
      - backend  # Not exposed to frontend
      
  nginx:
    networks:
      - frontend
    ports:
      - "80:80"

networks:
  frontend:
  backend:
    internal: true  # No external access

Static IPs (When Needed)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:
  app:
    networks:
      backend:
        ipv4_address: 172.20.0.10

networks:
  backend:
    ipam:
      config:
        - subnet: 172.20.0.0/24

Resource Limits

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services:
  app:
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 256M
        reservations:
          cpus: '0.25'
          memory: 128M

Note: deploy section works in Swarm mode or with docker compose --compatibility.

For non-Swarm:

1
2
3
4
services:
  app:
    mem_limit: 256m
    cpus: 0.5

Logging

JSON File with Rotation

1
2
3
4
5
6
7
8
services:
  app:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
        compress: "true"

Centralized Logging

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
services:
  app:
    logging:
      driver: syslog
      options:
        syslog-address: "tcp://logstash:5000"
        tag: "myapp"
        
  # Or use Fluentd
  app:
    logging:
      driver: fluentd
      options:
        fluentd-address: "localhost:24224"
        tag: "docker.{{.Name}}"

Init Containers Pattern

Run setup before the main service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
services:
  migrate:
    image: myapp:latest
    command: ["./migrate.sh"]
    depends_on:
      db:
        condition: service_healthy
        
  app:
    image: myapp:latest
    depends_on:
      migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy

Sidecar Pattern

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
services:
  app:
    image: myapp:latest
    volumes:
      - app_logs:/var/log/app
      
  log-shipper:
    image: fluent/fluent-bit:latest
    volumes:
      - app_logs:/var/log/app:ro
      - ./fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf
    depends_on:
      - app

volumes:
  app_logs:

Graceful Shutdown

1
2
3
4
services:
  app:
    stop_grace_period: 30s
    stop_signal: SIGTERM

Your app should handle SIGTERM and finish in-flight requests.

Development Helpers

Watch Mode (Compose 2.22+)

1
2
3
4
5
6
7
8
9
services:
  app:
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json
1
docker compose watch

Debug Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# docker-compose.debug.yml
services:
  app:
    build:
      target: development
    ports:
      - "9229:9229"  # Node.js debugger
    environment:
      - NODE_OPTIONS=--inspect=0.0.0.0:9229
    command: ["npm", "run", "dev"]

Project Organization

myprodddd...sjooooeeeeeccccnnnrckkkkvvvvteeee..iaw/rrrrlpcpo----oreprcccccoskooooadDeDmmmmlorppppccooookksssseeeeeerr....ffyoptiimvrellleoseerdtr..iyydmmell.yml#######BDPTDLPaereeorsvosfcoedtaadduulucecelcoftnttnaivvifuoieeoilnrnrngtovrsnivvmvda(eeaergrnrssirtsti((idgggeiinsttoiirggenndoorroeerdd)noort)invault)

Common Gotchas

  1. Volume permissions: Container user might not match host user

    1
    2
    3
    
    services:
      app:
        user: "${UID:-1000}:${GID:-1000}"
    
  2. Build context too large: Use .dockerignore

  3. Compose version confusion: Modern Compose doesn’t need version: field

  4. DNS resolution timing: Use health checks, not sleep

  5. Data loss on down -v: Named volumes are deleted with -v flag


Docker Compose is powerful enough for production in many cases. The key is treating your compose files like code: version them, review changes, and use environment-specific overrides to keep things clean.