Why Docker Compose Override Files Matter in a Homelab
You have a Docker Compose stack that works great in development — bind volumes for live code reload, relaxed resource limits, ports exposed for debugging. Then you want to run the same stack in “production” behind Traefik, with resource constraints, restart policies, and read-only filesystems. Do you maintain two separate docker-compose.yml files? Branch the repo? Use profiles?
None of those scale well. Override files are the clean solution Docker Compose provides for exactly this scenario.
Docker Compose lets you merge multiple compose files together. A base file defines the shared structure — services, networks, volumes. Override files layer on environment-specific changes: different port mappings, volumes, resource limits, and security settings. The same config drives dev, staging, and prod without duplication.
In my homelab, I run a web application stack across three environments: an LXC container for development on my Proxmox host, a staging VM for integration testing, and a production VM behind Traefik. Override files keep all three in one directory.
How Docker Compose File Merging Works
Docker Compose merges multiple YAML files into a single configuration before applying it. The merge follows these rules:
- Maps (dictionaries) are merged recursively — if the base file defines
environmentkeys and the override definesenvironmentkeys, both sets are combined. - Arrays (sequences) are fully replaced — if the base file has a
portsarray and the override has aportsarray, the override wins entirely. - Scalar values from the override replace the base.
This behaviour matters. A common mistake is putting override ports alongside base ports and wondering why both appear.
Default Override File
When you run docker compose up -d without the -f flag, Docker Compose automatically loads docker-compose.override.yml if it exists alongside docker-compose.yml. This makes docker-compose.override.yml ideal for local development overrides — checked-into-git structures, secrets in .env.
Explicit Named Override Files
For staging, production, or other environments, use the -f flag to specify exactly which files to merge:
|
|
The later file wins in conflicts. The project name defaults to the directory name. Override it with -p:
|
|
Base Configuration — Shared docker-compose.yml
Start with a base file that defines the common structure for all environments. Use YAML extension fields (x- prefixed keys) to keep it DRY:
|
|
Key points: services use expose (internal only, no host ports), extension fields keep configs consistent, and APP_ENV defaults to development.
Development Override — docker-compose.override.yml
This file loads automatically when you run docker compose up -d in the project directory:
|
|
What the dev override does:
- Binds service ports to
127.0.0.1only — accessible from the Docker host but not the network. - Bind-mounts source code into
appandwebfor live reload. - Enables debug mode and Xdebug for the PHP/Node.js app service.
- Sets a simple dev password in the environment (never in the base file).
- Uses a separate named volume for dev database data so it doesn’t pollute production volumes.
Production Override — docker-compose.prod.yml
For production, add resource limits, healthchecks, read-only filesystems, and reverse proxy integration:
|
|
Key production hardening:
read_only: trueprevents any container filesystem writes.security_opt: no-new-privileges:trueprevents privilege escalation inside the container.cap_drop: ALLfollowed by minimalcap_addimplements least-privilege.deploy.resources.limitscaps CPU and memory — essential on a shared homelab host.deploy.resources.reservationsguarantees minimum resources.logging.driver: journaldsends logs to the host’s systemd journal, centralised with Loki or your log aggregator.- Traefik labels route traffic to the web service. The
ports: []in the app service ensures no direct host port exposure. - Separate named volumes for production data prevent accidental data overwrite.
Staging Override — docker-compose.staging.yml
Staging sits between dev and prod — similar structure to prod but relaxed limits and a different DNS name:
|
|
Running Each Environment
With all files in place, the workflow is clean:
|
|
Shell Aliases for Convenience
Add these to your .bashrc or .zshrc:
|
|
Gitignore Strategy
Keep the override structure clean in version control:
# .gitignore
.env
# Ignore the auto-loaded dev override — it's personal
docker-compose.override.yml
# Track named overrides — they define environment configs
# docker-compose.prod.yml
# docker-compose.staging.yml
The auto-loaded docker-compose.override.yml is ignored because it contains personal dev preferences and possibly secrets. Named overrides (prod.yml, staging.yml) are tracked in git — they are infrastructure definitions, not secrets.
Advanced Patterns: Extension Fields with YAML Anchors
Extension fields (x-*) let you reuse YAML blocks across services and override files:
|
|
In production override, swap the logging driver:
|
|
Override files merge YAML anchors just like any other map key.
Security Considerations for Override Deployments
-
Never put secrets in override files tracked in git. Use
.envfiles with.gitignorefor passwords, API keys, and tokens. Override files are for structure and config — not secrets. -
Dev overrides should bind to 127.0.0.1, not 0.0.0.0, to prevent accidental network exposure on your homelab.
-
Production overrides should strip all dev ports. Use
ports: []orexposeonly. If you need host access, route through your reverse proxy exclusively. -
Resource limits prevent noisy-neighbour problems. Without
deploy.resources.limits, one container can starve others on the same Docker host. -
Read-only root filesystem + no-new-privileges should be default for production containers. Override files make this easy — define it once in the override, not in every base service.
Conclusion
Docker Compose override files solve the multi-environment problem without branching, template engines, or duplicate compose files. A single docker-compose.yml holds the shared structure. docker-compose.override.yml provides dev defaults automatically. Named override files (docker-compose.prod.yml, docker-compose.staging.yml) layer on environment-specific hardening, resources, and network config.
In my homelab, this pattern eliminated config drift between environments. Port mapping, volume mounts, resource limits, and security settings each live in the right layer. Using extension fields and YAML anchors keeps everything DRY. One directory, three environments, zero duplication.