Docker Compose Overrides
Docker Compose Overrides
I run a handful of Python services in Docker, and for a while I had a single compose.yaml that defined the whole stack. Once I needed separate configs for local dev, CI, staging, and production, maintaining copies of that file turned into a real headache.
Compose has an override system that fixes this. You stack multiple compose files on top of each other, and later files win. Your base config stays clean, and each environment only defines what it changes.
Here’s what my project structure looks like now:
project/
compose.yaml # production baseline
compose.override.yaml # local dev extras (gitignored)
compose.ci.yaml # CI pipeline
compose.prod.yaml # production deploy overrides
The Default Override File
By default, Compose reads two files: a compose.yaml and, if it exists, a compose.override.yaml. If a service is defined in both, Compose merges the configurations automatically when you run docker compose up.
So my base file has the production-ready definitions:
# compose.yaml
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgres://db:5432/app
And the override adds dev conveniences without touching the base:
# compose.override.yaml
services:
web:
volumes:
- .:/app
ports:
- "5678:5678"
environment:
- DEBUG=true
Volume mounts for live-reloading my Python code, an exposed debug port, DEBUG=true. All of that merges in automatically.
Stacking Files with -f
For more than two layers, you can use the -f flag to specify compose files in order:
docker compose -f compose.yaml -f compose.prod.yaml up -d
This lets you maintain named environment files like compose.staging.yaml or compose.ci.yaml. Compose merges files in the order they’re specified on the command line, so values in later files take precedence.
Watch out for paths, though. They all resolve relative to the first compose file you pass with -f. You can verify the merged result at any time with docker compose config. I run this whenever I change an override, just to make sure the merge looks right.
Reusing Services with extends
There’s also an extends keyword for reusing service definitions across files. I use this when services overlap. My web app and Celery worker share the same build context, env vars, and volume mounts. The only difference is the entrypoint command.
# common.yaml
services:
base-app:
build: .
environment:
- DATABASE_URL=postgres://db:5432/app
volumes:
- .:/app
# compose.yaml
services:
web:
extends:
file: common.yaml
service: base-app
command: gunicorn app:main
worker:
extends:
file: common.yaml
service: base-app
command: celery -A tasks worker
The shared config lives in one place, and each service overrides only what differs.
Isolating with include
include is built for bigger repos where multiple teams each own a service. Each path you list loads as its own Compose application with its own project directory.
# compose.yaml
include:
- ./api/compose.yaml
- ./frontend/compose.yaml
- ./worker/compose.yaml
Relative paths resolve independently, which sidesteps the path headaches you hit with extends.
I’d reach for include over extends when each service has its own directory and its own team maintaining it. extends works better when services live in the same project and just share config.
Keeping It Manageable
You can set the COMPOSE_FILE environment variable (e.g., in a .env or your shell profile) to avoid typing -f flags repeatedly. I keep mine in .env so the whole team uses the same file order without thinking about it.