Override Docker Compose Configs per Environment
How to use Docker Compose override files for environment-specific configurations, covering dev, test, staging, production, profiles, and secrets.
Overview
Docker Compose merges two or more files into a single config. Everything starts from the base docker-compose.yml, which holds the service structure. Override files customize it for each environment: different ports or volumes in dev, resource limits in prod, maybe a Mailhog service that only exists locally. Compose merges files in the order you pass them — later files override or extend earlier ones — so one base config covers dev, test, staging, and production without copy-pasting the whole thing four times.
If what you need is two completely separate Compose stacks for dev and prod rather than one base plus deltas, the Docker Compose dev/prod split recipe covers that approach — override files work best when environments share most of the config and differ in details.
When to Use
- Local development with hot reload and debug tools
- Running tests in isolation with test-specific configs
- Staging/production with production-like settings (resource limits, no debug)
- When you need different services per environment (e.g., Mailhog in dev, SES in prod)
- Managing multi-service applications across environments
When NOT to Use
- Single environment — a single
docker-compose.ymlis enough - Production deployments — use Kubernetes or ECS, not Docker Compose
- When configs differ drastically — separate files are clearer than overrides
- When you need complex orchestration — Compose is for dev/test, not production
Solution
Base compose file
# docker-compose.yml — Base configuration
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- LOG_LEVEL=info
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
db-data:
Development override
# docker-compose.override.yml — Used by default with `docker compose up`
services:
app:
build:
target: dev # Multi-stage build dev target
ports:
- "3000:3000"
- "9229:9229" # Node.js debugger
environment:
- NODE_ENV=development
- LOG_LEVEL=debug
volumes:
- .:/app # Hot reload
- /app/node_modules # Prevent host overwrite
command: npm run dev # Override entrypoint
db:
ports:
- "5432:5432" # Expose for local tools
environment:
POSTGRES_PASSWORD: devpassword
# Dev-only services
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
adminer:
image: adminer:latest
ports:
- "8080:8080"
depends_on:
- db
Production override
# docker-compose.prod.yml
services:
app:
build:
target: production
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- LOG_LEVEL=warn
- DATABASE_URL=postgres://myapp:${DB_PASSWORD}@db:5432/myapp
deploy:
replicas: 3
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 256M
restart_policy:
condition: on-failure
max_attempts: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
db:
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
redis:
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
Test override
# docker-compose.test.yml
services:
app:
build:
target: test
environment:
- NODE_ENV=test
- DATABASE_URL=postgres://myapp:testpass@db:5432/testdb
command: npm test
depends_on:
db:
condition: service_healthy
db:
environment:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: testpass
# Drop the persistent volume — tests get an ephemeral database
volumes: !reset []
# redis doesn't exist in the test environment
redis: !reset null
Running with different overrides
# Development (default — uses docker-compose.yml + docker-compose.override.yml)
docker compose up
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Test
docker compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit
# Build production images
docker compose -f docker-compose.yml -f docker-compose.prod.yml build
# Run specific service
docker compose -f docker-compose.yml -f docker-compose.prod.yml up app
# View merged config
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
Using COMPOSE_FILE environment variable
# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml
# Now `docker compose up` uses both files
docker compose up -d
Compose profiles
# docker-compose.yml
services:
app:
# Always started
image: my-app
db:
# Always started
image: postgres:16
debug-tools:
# Only started with --profile debug
profiles:
- debug
image: nicolaka/netshoot
network_mode: "service:app"
load-test:
# Only started with --profile loadtest
profiles:
- loadtest
image: grafana/k6
volumes:
- ./tests:/scripts
# Start without profiles
docker compose up -d # Only app and db
# Start with debug profile
docker compose --profile debug up -d
# Start with load test profile
docker compose --profile loadtest up
# Start with multiple profiles
docker compose --profile debug --profile loadtest up
Secrets management
# docker-compose.prod.yml
services:
app:
secrets:
- db-password
- api-key
environment:
- DB_PASSWORD_FILE=/run/secrets/db-password
- API_KEY_FILE=/run/secrets/api-key
db:
secrets:
- db-password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db-password
secrets:
db-password:
file: ./secrets/db-password.txt
api-key:
file: ./secrets/api-key.txt
Environment variables from .env
# .env
DB_PASSWORD=supersecret
REDIS_URL=redis://redis:6379
JWT_SECRET=my-jwt-secret
# docker-compose.yml
services:
app:
environment:
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_URL=${REDIS_URL}
- JWT_SECRET=${JWT_SECRET}
Per-environment .env files
# .env.dev
DB_PASSWORD=devpassword
LOG_LEVEL=debug
# .env.prod
DB_PASSWORD=supersecret
LOG_LEVEL=warn
# Pick the env file to load — pairs naturally with -f overrides
docker compose --env-file .env.dev up
docker compose --env-file .env.prod -f docker-compose.yml -f docker-compose.prod.yml up -d
# Or pin an env file per service — the file is part of the config,
# so an override can point it somewhere else per environment
services:
app:
env_file:
- .env.dev
Multi-file merge behavior
# docker-compose.yml (base)
services:
app:
image: my-app:latest
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- LOG_LEVEL=info
# docker-compose.override.yml
services:
app:
# Ports are merged (not replaced)
ports:
- "9229:9229" # Added to 3000:3000
# Environment is merged
environment:
- NODE_ENV=development # Overrides production
- DEBUG=true # Added
How the Merge Works
Compose merges files left to right, following different rules per attribute type:
| Attribute type | Merge behavior | Example |
|---|---|---|
Sequences (ports, volumes, secrets) | Concatenated — override adds, doesn’t replace | Base 3000:3000 + override 9229:9229 → both |
Maps (environment, labels, sysctls) | Merged by key — later value wins for the same key | NODE_ENV replaced, DEBUG added |
Scalars (image, command, restart) | Replaced entirely by the latest file | command: npm run dev wins |
depends_on, networks | Merged by service/network name | Override adds conditions |
Two escape hatches from the Compose spec change these rules:
!reset <value>— removes the inherited value entirely.volumes: !reset []drops all base volumes;redis: !reset nullremoves the service from the merged config (this is how the test override above drops redis, cleaner than a fake profile).!override— replaces an attribute wholesale instead of merging, useful forportswhen you don’t want the base mappings to leak into prod.
Three details that bite people:
- All relative paths resolve against the base file’s directory, not the override’s. If your override lives in another folder, keep paths relative to the base or use absolute paths.
- Watch your file names if you rely on auto-loading. Compose v2 finds
compose.yaml,compose.yml,docker-compose.yaml, ordocker-compose.ymlon its own, plus the.overridefile that matches whichever it found. Thedocker-compose.*names still work everywhere;compose.*is the newer convention. Anything else only loads through explicit-fflags. - Order is the override order.
-f a.yml -f b.ymlmeans b wins on conflicts, which is why people usually stack base → environment → a local untracked file — personal tweaks land last every time.
Verify what Compose actually merged before debugging anything else:
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
That prints the resolved config — the single source of truth for “why is this port/env var/volume here”.
Variants
Using extends to reuse service definitions
# docker-compose.yml
services:
web:
extends:
file: docker-compose.base.yml
service: app
environment:
- APP_ROLE=web
worker:
extends:
file: docker-compose.base.yml
service: app
environment:
- APP_ROLE=worker
ports: [] # No ports for worker
Docker Compose with healthcheck dependencies
services:
app:
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
Watch mode for development
# docker-compose.override.yml
services:
app:
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: package.json
docker compose watch # Auto-sync changes and rebuild
Best Practices
- Keep
docker-compose.ymlas the base — don’t put environment-specific config in it - Use
docker-compose.override.ymlfor dev — Compose picks it up automatically - Reach for explicit
-fflags on every non-dev environment — prevents accidental dev overrides - Store secrets in
.envor Docker secrets, never hardcoded in YAML - Mark optional services with
profilessodocker compose updoesn’t start the world - Add
depends_onwithconditionplus healthchecks — the app connects only when the DB is actually ready - Run
docker compose configto verify merged output — catches merge issues before they reach a running stack
Common Mistakes
- Forgetting override.yml is automatic:
docker compose upmergesdocker-compose.override.ymlwhenever it exists — reach for-fflags when you want control over what loads. - Hardcoding secrets in YAML: passwords written into
docker-compose.ymlend up in git. Keep them in.envor Docker secrets. - Not using healthchecks: the app boots before the DB is ready and connection errors follow. Put healthchecks on anything your services wait for.
- Using Compose for production: Compose is designed for dev/test — real production orchestration means Kubernetes, ECS, or Swarm.
- Not cleaning up volumes:
docker compose downkeeps volumes around. If you want the data gone, that’sdocker compose down -v.
Further Reading
- Working with multiple Compose files — Docker docs — official guide to merges, overrides, and the
-fflag. - Merge and override — Compose Specification — the canonical merge rules including
!resetand!override. - Using profiles with Compose — Docker docs — auto-start and optional services.
- Environment variables precedence — Docker docs — where
.env,env_file, and--env-filefit.
Frequently Asked Questions
How does Docker Compose merge files?
It deep-merges: lists (ports, volumes) are concatenated, maps (environment, labels) are merged with later values overriding earlier ones, and scalars (image, command) are replaced.
What is the default override file?
docker-compose.override.yml. If it exists in the same directory, Compose merges it automatically with docker-compose.yml when you run docker compose up.
Can I use multiple override files?
Yes. Chain -f flags — later files win on conflicts: docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.local.yml up. A typical order is base → environment → untracked local file.
What is the difference between compose.yaml and docker-compose.yml?
Compose v2 searches for compose.yaml first, then compose.yml, docker-compose.yaml, and finally docker-compose.yml — plus the matching .override file for whichever it finds. All four names work; compose.yaml is the newer convention. Files with any other name only load via explicit -f flags.
How do I remove a key or service that the base file defines?
Use !reset in the override: volumes: !reset [] clears an inherited list, and service_name: !reset null drops a whole service from the merged config. For sequence fields you want to replace rather than append to, !override swaps the whole attribute instead of merging.
How do I use a different .env file per environment?
Pass --env-file .env.prod on the command line, or set env_file: inside the service so an override file can repoint it. Without either, Compose reads .env in the working directory — one file for every environment.
What are Compose profiles?
Profiles mark services as optional: a service with a profiles list only starts when you pass --profile <name>. Your debug and test tools can live in the same file without booting every time you run the stack.
How do I pass environment variables?
Drop a .env file next to your compose file — Compose picks it up on its own. Then reference the variables with ${VAR_NAME} inside the YAML.
Related Resources
Docker Compose Dev/Prod Split: Separate Environments
Separate development and production Docker Compose configs with overrides
RecipeSlim Production Images with Multi-Stage Docker Builds
How to build minimal production Docker images using multi-stage builds with distroless base images, covering Go, Node.js, Python, and Java examples with image size reduction.
RecipeMount Configs and Secrets into Kubernetes Pods
How to mount ConfigMaps and Secrets into Kubernetes pods using env vars, volumes, projected volumes, and secret management with external secrets.
RecipeShare Workflow Logic with GitHub Actions Reusable Workflows
How to create and consume reusable workflows in GitHub Actions, covering inputs, secrets, conditional jobs, matrix strategy, and organization-wide sharing.
RecipeAcross Multiple OS and Language Versions with GitHub
How to use GitHub Actions matrix strategy to test across multiple operating systems, language versions, and configurations with include, exclude, and dynamic matrices.