Skip to main content

Multi-Environment Overrides

Most applications need different settings for development, staging, and production -- different ports, volumes, resource limits, and image tags. Compose handles this with override files that layer on top of a base configuration.

How It Works

flowchart LR
Base["compose.yaml<br/>(shared architecture)"] --> Override["compose.override.yaml<br/>(local dev defaults)"]
Base --> Prod["compose.prod.yaml<br/>(production hardening)"]
Base --> Staging["compose.staging.yaml<br/>(staging tweaks)"]

Override -->|"docker compose up"| Dev["Dev Stack"]
Prod -->|"docker compose -f ... -f ... up"| ProdStack["Production Stack"]

style Base fill:#e3f2fd,stroke:#1565c0
style Dev fill:#e8f5e9,stroke:#2e7d32
style ProdStack fill:#fff3e0,stroke:#ef6c00

Compose automatically merges compose.override.yaml with the base file. For other environments, you specify files explicitly.

File Layout

project/
├── compose.yaml # Base: services, networks, volumes
├── compose.override.yaml # Auto-loaded for local dev
├── compose.prod.yaml # Production overrides
├── .env.example # Template for env vars
└── .env # Local env vars (gitignored)

What Goes Where

SettingBase FileDev OverrideProduction Override
Service definitions
Network topology
Named volumes
Source code bind mounts
Debug ports (127.0.0.1)
Pinned image tags
Resource limits
Strict restart policies
Public port bindings

Example: Base File

# compose.yaml -- shared architecture
services:
api:
image: registry.example.com/api:${API_TAG:-latest}
networks: [back]
depends_on:
db: { condition: service_healthy }

db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 5
networks: [back]

networks:
back: {}

volumes:
pgdata: {}

Example: Dev Override

# compose.override.yaml -- auto-loaded for local development
services:
api:
build: ./api # Build from source instead of pulling image
volumes:
- ./api/src:/app/src # Live code reload
ports:
- "127.0.0.1:3000:3000" # Local debug access
environment:
LOG_LEVEL: debug

Example: Production Override

# compose.prod.yaml -- explicit production hardening
services:
api:
image: registry.example.com/api:1.5.2 # Pinned tag
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"

db:
restart: unless-stopped
deploy:
resources:
limits:
memory: 1G

Deploying Each Environment

# Local development (auto-merges compose.override.yaml)
docker compose up -d

# Production (explicit file selection)
docker compose -f compose.yaml -f compose.prod.yaml up -d

# Staging
docker compose -f compose.yaml -f compose.staging.yaml up -d

Always Validate Before Deploy

Render the merged configuration and review it:

# See exactly what will be deployed in production
docker compose -f compose.yaml -f compose.prod.yaml config

This catches merge issues, missing variables, and unexpected overrides before they cause problems.

Key Takeaways

  • Keep the base file environment-agnostic with shared service definitions, networks, and volumes.
  • Use compose.override.yaml for local dev conveniences (bind mounts, debug ports, build from source). It is merged automatically.
  • Use compose.prod.yaml for production hardening (pinned tags, resource limits, restart policies). Specify explicitly with -f.
  • Always run docker compose config with your file flags to see the merged result before deploying.
  • Do not duplicate entire service definitions across override files -- only include the keys you are changing.

What's Next