Featured image of post Designing Reusable Docker Compose Configs for Dev and Prod Featured image of post Designing Reusable Docker Compose Configs for Dev and Prod

Designing Reusable Docker Compose Configs for Dev and Prod

Maintain dry compose files by chaining config overlays, managing ports, volume shares, and env files.

Designing Reusable Docker Compose Configs for Dev and Prod

Docker Compose is the standard tool for defining and running multi-container applications. But many teams fall into the trap of maintaining separate docker-compose.yml files for development, staging, and production — leading to duplication, drift, and configuration bugs.

A better approach uses compose file overlays, profiles, and environment variables to keep a single source of truth that adapts to each environment.


The Base compose.yml Pattern

Start with a single compose.yml that defines the shared service definitions — the image, command, ports, and environment that remain the same across environments.

# compose.yml
services:
  app:
    image: myapp:${APP_VERSION:-latest}
    build:
      context: .
      target: ${BUILD_TARGET:-development}
    ports:
      - "${APP_PORT:-3000}:3000"
    environment:
      - NODE_ENV=${NODE_ENV:-development}
      - DATABASE_URL=${DATABASE_URL}
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: ${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

Use environment variables with defaults (${VAR:-default}) to keep the base file generic.


Environment Overrides with compose.override.yml

Docker Compose automatically reads compose.override.yml if it exists. Use this for development-only settings — never commit an override that would affect production.

# compose.override.yml (excluded from production deployments)
services:
  app:
    build:
      target: development
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "9229:9229" # debugger
    environment:
      - NODE_ENV=development
      - DEBUG=app:*
    command: npm run dev

  db:
    ports:
      - "5432:5432" # expose for local tools
    volumes:
      - ./scripts/seed:/docker-entrypoint-initdb.d

For production, use a separate override file:

# compose.prod.yml
services:
  app:
    build:
      target: production
    restart: always
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 1G
        reservations:
          cpus: '0.5'
          memory: 256M
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

Deploy with: docker compose -f compose.yml -f compose.prod.yml up -d


Using Profiles for Environment Variants

Profiles (Docker Compose 2.0+) let you conditionally enable services based on the environment.

services:
  mailhog:
    image: mailhog/mailhog
    profiles: ["development", "staging"]
    ports:
      - "8025:8025"

  redis:
    image: redis:7-alpine
    profiles: ["development", "staging", "production"]

  redis-commander:
    image: rediscommander/redis-commander
    profiles: ["development"]
    ports:
      - "8081:8081"

Run docker compose --profile development up to start only the services relevant to development.


Health Checks and Restart Policies

Always define health checks for every service that supports them. Combine with restart policies for self-healing deployments.

services:
  app:
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 40s

  worker:
    restart: on-failure:5
    depends_on:
      app:
        condition: service_healthy

The depends_on.condition: service_healthy ensures the worker only starts after the app passes its health check.


Resource Limits and Scaling

Prevent a runaway container from taking down the host by setting resource limits.

services:
  app:
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 128M

  worker:
    deploy:
      mode: replicated
      replicas: 3
      resources:
        limits:
          cpus: '2'
          memory: 1G

Note: deploy settings apply to Docker Swarm and Compose v3 stacks. For plain docker compose, use --compatibility flag or platform-specific limits.


Secrets Management

Never hardcode secrets in compose files. Use Docker secrets or environment files with restricted permissions.

# Using Docker secrets (Swarm mode)
secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  app:
    secrets:
      - db_password
    environment:
      - DATABASE_URL=postgres://user:${DB_PASSWORD}@db:5432/myapp

For non-Swarm setups, use .env files:

# .env (never committed to version control)
DB_USER=myapp
DB_PASS=s3cret!
DB_NAME=myapp
APP_PORT=3000

Logging Configuration

Centralized logging prevents container log spam from filling your disk.

services:
  app:
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  # Production logging to a remote service
  log-collector:
    logging:
      driver: "fluentd"
      options:
        fluentd-address: "localhost:24224"
        tag: "docker.{{.Name}}"

Conclusion

Designing reusable Docker Compose configurations is about separation of concerns: a base compose.yml for shared definitions, override files for environment-specific settings, profiles for conditional services, and environment variables for secrets and configuration.

This layered approach eliminates duplication, reduces configuration drift, and makes your deployment pipeline predictable across development, staging, and production. Adopt these patterns to ship with confidence.