Override Compose Config
Learn to override Docker Compose configurations using multiple files for environment-specific settings.
Focus: override compose config with multiple files
You've nailed your docker-compose.yml for a single environment. But when you need to spin up a different database for local development, use higher log verbosity for staging, or lock down port bindings in production — do you really copy and paste the whole file? Copy-and-paste creates a maintenance nightmare where one tiny change forces you to update three identical clones. Docker Compose solves this with override files — a powerful pattern that lets you layer configuration fragments on top of a base file, so you keep your core services clean and environment-specific tweaks neatly separated.
The problem this lesson solves
A single docker-compose.yml is brittle. Your development setup needs debug mode on, a local PostgreSQL dump loaded, and port 3000 exposed. Staging wants verbose logging and a connection to a shared database. Production must strip all debug ports, mount secrets read-only, and scale a web service to 3 replicas. Without multiple files, you either:
- Duplicate the entire Compose file per environment — a recipe for drift and bugs.
- Abuse environment variables to toggle dozens of random settings, making the file unreadable.
- Use a single massive file with conditional
profilesorextends— fragile and hard to debug.
The pain is real: every developer I've mentored who tried to manage three environments with one file eventually broke a service or tainted production with dev settings. It’s time to learn the multi-file override pattern.
Core concept / mental model
Think of Docker Compose files as layered patches, not standalone blueprints. The base file (docker-compose.yml) defines your canonical services, networks, and volumes. Override files (docker-compose.override.yml, docker-compose.prod.yml, etc.) are layered on top — they add, change, or remove properties without touching the original.
Key insight: Compose merges files in the order you specify. Later files override earlier ones. By default, Compose reads
docker-compose.ymlfirst, thendocker-compose.override.yml— unless you pass-fflags.
How merging works
- Scalars (strings, numbers): replaced entirely.
- Maps (dictionaries like
environmentorvolumes): merged recursively. Keys in the override replace or add to the base. - Lists (arrays like
portsordepends_on): replaced entirely — not appended. If you need to add a port, you must repeat the original ports plus the new one.
This granular merging lets you isolate environment-specific details while keeping the core service definition immutable.
How it works step by step
- Create a base file —
docker-compose.yml— with your canonical services, networks, volumes, and default settings. - Create an override file — e.g.,
docker-compose.override.ymlfor local development — that tweaks only what's different. - Run Compose —
docker compose upautomatically loadsdocker-compose.yml+docker-compose.override.yml. - For other environments, use explicit
-fflags:docker compose -f docker-compose.yml -f docker-compose.prod.yml up. - Chain more files for staging, CI, or testing:
-f base.yml -f common.yml -f staging.yml.
Pro tip: Never commit
docker-compose.override.ymlto version control? Actually, it's common to commit a template override file (e.g.,docker-compose.override.yml.example) to document local dev defaults, then let each developer copy and customize their own uncommitted copy.
Where override files live
By default, Compose looks for:
- docker-compose.yml (or docker-compose.yaml)
- docker-compose.override.yml (or .yaml) in the same directory.
If you use an explicit -f list, the default override file is ignored — you must include it manually if needed.
Hands-on walkthrough
Let's build a web + Redis stack and prepare it for development, staging, and production.
Step 1: Base file (docker-compose.yml)
version: '3.8'
services:
web:
image: mywebapp:latest
ports:
- "80:80"
environment:
- LOG_LEVEL=info
volumes:
- ./app:/app
depends_on:
- redis
redis:
image: redis:7-alpine
ports:
- "6379:6379"
Step 2: Development override (docker-compose.override.yml)
services:
web:
ports:
- "3000:80" # change host port from 80 to 3000
- "9229:9229" # add debugger port
environment:
- LOG_LEVEL=debug
- NODE_ENV=development
volumes:
- ./app:/app
- ./node_modules:/app/node_modules:cached # speed up npm
redis:
environment:
- REDIS_PASSWORD=devpassword
Now run: docker compose up — Compose automatically merges the override. Notice the web service now exposes port 3000 and 9229, redis uses a dev password.
Step 3: Production override (docker-compose.prod.yml)
services:
web:
ports:
- "80:80"
- "443:443"
environment:
- LOG_LEVEL=warn
- NODE_ENV=production
volumes:
- app_data:/app
deploy:
replicas: 3
redis:
environment:
- REDIS_PASSWORD_FILE=/run/secrets/redis_password
secrets:
- redis_password
secrets:
redis_password:
file: ./secrets/redis_password.txt
Launch production with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Step 4: Verify the merge
Check what services actually got: docker compose -f docker-compose.yml -f docker-compose.prod.yml config
This prints the resolved composition. It's your best debugging friend.
Compare options / when to choose what
| Approach | Best for | Trade-off |
|---|---|---|
| Single file + env vars | Very simple apps with few env diffs | Pollutes Compose file; hard to add/remove volumes or ports |
| Multiple override files | Most projects (dev, staging, prod) | Need to remember -f order; list replacement can surprise beginners |
| Profiles | Toggling optional services (e.g., mailcatcher) | Not for changing existing service properties; only profiles attribute |
extends (deprecated) |
Legacy Compose v2 | Merging is shallow; removed in newer Compose specs |
Recommendation: Use multiple override files for service-level differences (ports, env, volumes). Use profiles for turning extra services on/off (e.g., a Redis insighter).
List replacement — common gotcha
If your base file has:
ports:
- "80:80"
And your override has:
ports:
- "3000:80"
Result: only 3000:80 is exposed. To expose both, repeat the original:
ports:
- "80:80"
- "3000:80"
Troubleshooting & edge cases
1. Override file not applied
Symptom: Changes in docker-compose.override.yml don't show up.
Fix: Run with explicit -f flags, or ensure the override file is in the same directory and named exactly docker-compose.override.yml. If you run docker compose -f prod.yml up alone, the default override is skipped.
2. Unexpected merge of volumes
Scenario: Base has volumes: [./app:/app]. Override has volumes: [./src:/src]. You expect both mounts. But Compose merges the list — wait, no: for volumes, it does merge because volumes are a list of maps (each entry has type and source), but simple string-based volume syntax is treated as a list of scalars → replaced entirely.
Workaround: Use long syntax:
volumes:
- type: bind
source: ./app
target: /app
- type: bind
source: ./src
target: /src
When using long syntax, each volume entry is a map, so they get merged.
3. Duplicate service names across files
If two override files define the same service, last one wins for scalar fields, but maps are merged. To avoid confusion, always pass files in dependency order: base → common → specific.
4. Relative paths in volumes break when running from different directory
Always use ${PWD} or $CWD in scripts that invoke docker compose -f. Better yet, keep all Compose files in the project root.
What you learned & what's next
You can now override Compose config with multiple files — a production-grade pattern for managing Docker environments without duplication. You've seen:
- How merging works for scalars, maps, and lists
- How to create environment-specific override files (dev, staging, production)
- How to verify the merged config using
docker compose config - Common pitfalls: list replacement, missing override files, and relative path issues
Next step: Take this lesson's production override file and add a reverse proxy (nginx) on top of your web service. Then learn how to extend services with the extends keyword (deprecated but still used in some projects) or move to Docker Compose profiles for toggling optional dependencies.
Practice recap
Create a docker-compose.yml with a web service (nginx:alpine) exposing port 80 and a hello-world container. Add a docker-compose.override.yml that changes the web port to 8080 and sets an environment variable MODE=development. Run docker compose config to see the merged result. Then add a production override that mounts a custom nginx config and sets MODE=production — chain all three files and verify with docker compose -f ... config.
Common mistakes
- Forgetting that lists (like ports and environment) are replaced entirely — not appended — in the override file. You must repeat existing elements to keep them.
- Running
docker compose upwithout explicit-fflags when you want a custom override; the defaultdocker-compose.override.ymlis ignored if you use any-f. - Assuming
depends_onmerges — it's a list and gets replaced. Combine dependencies across files explicitly. - Using short-volume syntax in both base and override and expecting both mounts; short syntax creates scalar strings, not maps, so the override replaces the entire list.
Variations
- Use environment-specific Compose files (e.g.,
compose.dev.yml,compose.prod.yml) and always invoke with-f. - Leverage YAML anchors and aliases in a single file instead of multiple files for smaller projects.
- Combine multiple overrides for complex pipelines:
docker compose -f base.yml -f common.yml -f staging.yml -f ci.yml up
Real-world use cases
- Local development team uses an override to mount live code and expose debug ports without touching the shared base file.
- CI/CD pipeline applies a CI override that injects test secrets, disables external volume mounts, and sets environment to 'testing'.
- Production deployment uses a separate override file to scale services, mount secrets as files, and strip debug endpoints — all without modifying the canonical base.
Key takeaways
- Override files layer on top of the base file in order: scalars replaced, maps merged, lists replaced.
- Use
docker compose configto validate the merged output before running containers. - Never commit sensitive overrides; commit a template override file instead.
- For production, always use explicit
-fflags and never rely on the defaultdocker-compose.override.yml. - List-level merging requires the long syntax (maps) for volumes and environment arrays; short syntax replaces the entire list.
- Multiple override files scale gracefully from dev → staging → prod without duplicating service definitions.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.