How do you manage environment-specific Docker Compose configs for dev, staging, and production, and what actually happens when you layer multiple compose files together?
Quick Answer
Docker Compose merges multiple YAML files in the order you pass them with -f, with later files overriding matching keys in earlier ones rather than replacing the whole service block. Teams keep a base docker-compose.yml with shared service definitions and thin override files (docker-compose.prod.yml) that only change what differs, like removing bind-mount volumes and adding restart policies and resource limits.
Detailed Answer
Think of it like a base employment contract with addendums. The base contract (docker-compose.yml) spells out your job title, salary band, and reporting line — the things true in every office. A regional addendum (docker-compose.prod.yml) doesn't rewrite the whole contract, it just amends specific clauses: different working hours here, a different bonus structure there. HR doesn't hand you two separate contracts to reconcile yourself; the system merges them into one effective document. Docker Compose's multi-file support works the same way — you keep one canonical description of your stack and layer small, environment-specific patches on top instead of maintaining three near-duplicate 200-line YAML files that drift out of sync.
Compose was designed this way because the alternative — full separate files per environment — is a maintenance trap. If checkout-worker gains a new environment variable in dev, someone has to remember to also add it to staging.yml and prod.yml, and inevitably one gets missed until a production incident reveals it. By making files additive, the base file is the single source of truth for topology (which services exist, how they're networked) while overrides only carry the deltas that are genuinely environment-specific, like credentials, replica counts, or removing a live-reload volume mount that should never exist in production.
Internally, when you run docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d, Compose reads each file in order and performs a deep merge on the resulting data structure. Scalar keys (like image or restart) get overwritten by the later file. List keys like ports and volumes are replaced entirely, not appended — this trips people up because they expect merging to be additive everywhere. Map keys like environment and labels are merged key-by-key. Compose resolves this into a single in-memory Compose model before it ever talks to the Docker Engine API, so what you see with docker compose config (which prints the fully merged, resolved YAML) is exactly what gets sent to create containers, networks, and volumes.
At production scale, teams also lean on profiles to conditionally include services — a debug profile might add a pgAdmin or Redis Commander container that only starts when you pass --profile debug, keeping the production compose file free of debugging tools by default. What to watch: config drift between the base file and overrides (catch this in CI by running docker compose config against each environment combination and diffing against a checked-in snapshot), and secrets accidentally left in an override file that gets committed to git. A common on-call scenario is someone updating docker-compose.yml for a new port mapping but forgetting the prod override still hard-codes the old port, causing a silent mismatch that only surfaces when the load balancer health check starts failing.
The non-obvious gotcha: because list-type keys are replaced wholesale rather than merged, if your base file defines volumes: [./data:/data, ./logs:/logs] and your override only needs to add a third volume, you must repeat all three in the override — Compose will not append your new entry to the base list. Engineers who assume Compose merges lists item-by-item (the way it merges maps) end up silently losing volume mounts in production, and the failure mode looks like a missing directory error deep inside the container rather than an obvious YAML problem.