Docker Compose Override File Not Working? Merge Rules Tested
You add a compose.override.yaml next to your compose.yaml, change a port or an
image tag in it, run Compose, and the change isn’t there. Or the opposite: you meant
to fully replace a list of volumes and instead got both the old and the new ones. Or
you added -f to point at a staging file and your local override silently stopped
applying. None of these throw an error — Compose just quietly does something other
than what you expected.
I tested the load order and merge rules on Docker Compose v5.5.1 using docker compose config (and docker compose config --format json), which resolves every
file, override, and merge exactly like up does, without needing a running daemon.
I deliberately didn’t start Docker Desktop or run up/run — every command and
output below is copied from a real terminal.
Short answer (Docker Compose v5.5.1, tested 2026-09-29):
| Symptom | Cause | Fix |
|---|---|---|
| Override edits don’t show up at all | The moment you pass any -f, Compose stops auto-loading compose.override.yaml | Add the override back with its own -f, or drop -f and let auto-discovery run |
| You have both naming styles and the wrong one wins | compose.yaml/compose.override.yaml beat the legacy docker-compose.yml/docker-compose.override.yml names — silently, except for a warning line | Pick one naming style per project |
ports:/volumes:/environment: in the override didn’t fully replace the base | Only single-value fields (image, command) get replaced; lists are appended, and some are merged by key or mount path | Use the !override YAML tag to force a full replace |
| You tried to remove something by just leaving it out of the override | Omitting a key doesn’t delete it — the base value stays | Set it to !reset (or !reset [] for a list) |
A relative path in your second -f file points at the wrong directory | Every relative path in every -f file resolves against the first -f file’s directory, not its own | Use paths relative to the first file, or make them absolute |
COMPOSE_FILE doesn’t seem to matter | Any -f on the command line replaces COMPOSE_FILE entirely — it doesn’t add to it | Don’t mix -f and COMPOSE_FILE in the same invocation |
First: see exactly what Compose merged
$ docker compose config
This resolves every file Compose would load (auto-discovered or via -f), applies
every merge rule, and prints the result. If your override’s change isn’t in this
output, it never reached up either. Everything below was checked with this command.
Cause 1: -f turns off automatic override loading
With just compose.yaml and compose.override.yaml in a directory, no flags needed:
$ docker compose config
image: nginx:overridden
Add an explicit -f pointing at the same base file, and the override is gone:
$ docker compose -f compose.yaml config
image: nginx:base
Nothing warns you about this. If you’re used to running plain docker compose up
locally (where the override applies) and then add -f compose.yaml -f compose.prod.yaml for a deploy script, you don’t get “base + override + prod” — you
get “base + prod,” and whatever your override was doing (usually dev conveniences:
bind-mounting source code, exposing a debug port) silently disappears.
Cause 2: with both naming styles present, the new names win — with a warning
Compose accepts two family names: compose.yaml/compose.override.yaml (current)
and docker-compose.yml/docker-compose.override.yml (legacy). Put all four in one
directory and run with no -f:
$ docker compose config
time="…" level=warning msg="Found multiple config files with supported names: …/compose.yaml, …/docker-compose.yml"
time="…" level=warning msg="Using …/compose.yaml"
time="…" level=warning msg="Found multiple override files with supported names: …/compose.override.yaml, …/docker-compose.override.yml"
time="…" level=warning msg="Using …/compose.override.yaml"
image: nginx:from-compose-override-yaml
Both pairs resolved to the new-style names, and Compose was explicit about it in
stderr — but only as a warning, so it’s easy to miss in a script that swallows
stderr or in CI logs nobody reads until something’s wrong. I also tried a mixed
pair — compose.yaml (new) with only docker-compose.override.yml (legacy, no
compose.override.yaml present) — and that still merged correctly; the rule is
“use the best-matching name for each role that actually exists,” not “all names
must match one family.”
Cause 3: lists merge (append/dedupe), they don’t replace — only scalars replace
This is the one most likely to bite you if you’re assuming “override” means “replace.”
image and command (single-value fields) get fully replaced:
# compose.yaml
command: ["nginx", "-g", "daemon off;"]
# compose.override.yaml
command: ["echo", "overridden"]
$ docker compose config
command:
- echo
- overridden
ports and expose (lists) get appended, not replaced:
# compose.yaml
ports: ["8080:80"]
expose: ["3000"]
# compose.override.yaml
ports: ["9090:90"]
expose: ["4000"]
$ docker compose config
expose:
- "3000"
- "4000"
ports:
- published: "8080"
target: 80
- published: "9090"
target: 90
Both ports are there — if you wanted the override to replace the base port
mapping, this leaves the old one running too. If the two files declare the exact
same port entry, Compose does dedupe it down to one instead of listing it twice —
I confirmed this with two files both containing "8080:80": the merged output has
a single 8080:80 entry, not two.
volumes merge by the container-side path, not the whole entry:
# compose.yaml
volumes:
- ./base-data:/data
- ./base-logs:/var/log/nginx
# compose.override.yaml
volumes:
- ./override-data:/data
- ./extra:/extra
$ docker compose config
volumes:
- source: …/override-data
target: /data
- source: …/base-logs
target: /var/log/nginx
- source: …/extra
target: /extra
/data came from the override (same container path, override wins); /var/log/nginx
and /extra don’t conflict, so both survive. This is the merge rule that most
resembles what people expect an “override” to do, and it’s exactly why the ports
behavior above (which appends instead of replacing by container port) surprises
people coming from the volumes case.
environment and labels merge by key, same idea:
# compose.yaml
environment: { COLOR: red, SIZE: small }
labels: ["com.example.team=infra", "com.example.tier=base"]
# compose.override.yaml
environment: { COLOR: blue, EXTRA: added }
labels: ["com.example.tier=override", "com.example.new=yes"]
$ docker compose config
environment:
COLOR: blue
EXTRA: added
SIZE: small
labels:
com.example.new: "yes"
com.example.team: infra
com.example.tier: override
COLOR and tier were overridden, SIZE and team were kept, EXTRA and new
were added — a per-key merge, not a whole-block replace. Note labels also gets
normalized from the list form (key=value strings) to a map in the resolved output,
regardless of which form either file used.
Cause 4: to actually remove or fully replace something, use !reset / !override
Both of these YAML tags worked as documented on this version.
!reset clears a field entirely, whether it’s a map or a list — just leaving a
key out of the override does not remove it, it leaves the base value in place:
# compose.override.yaml
services:
web:
environment: !reset
ports: !reset []
$ docker compose config
image: nginx:base
No environment: or ports: block at all in the output — both fields, which had
values in the base file, came back empty. I also confirmed !reset [] clears a
labels: list the same way.
!override forces a full replace instead of the per-key/per-path merge from
Cause 3:
# compose.yaml
environment: { COLOR: red, SIZE: small }
ports: ["8080:80", "8081:81"]
# compose.override.yaml
services:
web:
environment: !override
COLOR: blue
ports: !override
- "9090:90"
$ docker compose config
environment:
COLOR: blue
ports:
- published: "9090"
target: 90
SIZE and both original ports are gone — !override bypassed the merge and used
only what’s in the override file. This is the tag to reach for when Cause 3’s
append/merge behavior isn’t what you want.
Cause 5: relative paths in a second/third -f file resolve against the first file’s directory, not their own
I put a compose file in dirA/ with a bind mount ./data-a:/data-a, and a second
compose file in dirB/ with ./data-b:/data-b, then ran Compose from a third
directory:
$ (cd runfrom && docker compose -f ../dirA/a.yaml -f ../dirB/b.yaml config)
name: dira
volumes:
- source: …/dirA/data-a
target: /data-a
- source: …/dirA/data-b # not dirB!
target: /data-b
data-b’s relative path resolved inside dirA/, not dirB/ where the file that
declared it actually lives. This matches what Docker’s merge documentation says
(checked 2026-09-29): every relative path across every -f file is evaluated
against the base file — the first file passed with -f, or compose.yaml in
the current directory if you don’t use -f. It’s easy to assume each file’s own
relative paths resolve against its own folder (that’s how env_file: works — see
the linked article below), but for merge-time paths like volumes:, they don’t.
Cause 6: order matters for -f, and COMPOSE_FILE gets replaced (not merged) by -f
Order, not filename, decides who wins. With three files each setting a
different image, whichever is listed last on the command line wins, regardless of
which one you’d call “the override”:
$ docker compose -f a.yaml -f b.yaml -f c.yaml config | grep image
image: nginx:from-c
$ docker compose -f c.yaml -f a.yaml -f b.yaml config | grep image
image: nginx:from-b
COMPOSE_FILE (colon-separated on macOS/Linux) works the same way as listing
multiple -f flags:
$ COMPOSE_FILE="base.yaml:ov.yaml" docker compose config | grep image
image: nginx:from-ov
It can also be set inside .env instead of the shell, and — consistent with how
.env variables generally lose to shell variables — a shell-set COMPOSE_FILE
wins over one set in .env:
$ cat .env
COMPOSE_FILE=base.yaml:ov.yaml
$ COMPOSE_FILE="base.yaml:ov2.yaml" docker compose config | grep image
image: nginx:from-shell-var
And an explicit -f on the command line doesn’t add to COMPOSE_FILE, it
replaces it completely:
$ COMPOSE_FILE="base.yaml:ov.yaml" docker compose -f base.yaml config | grep image
image: nginx:from-base
If a wrapper script sets COMPOSE_FILE for you and you add your own -f on top
“just to be safe,” you’re not layering on top of the script’s files — you’re
throwing them away.
extends: and include: use the same merge rules, but aren’t affected by -f
Two other multi-file mechanisms, tested for how they relate to the override behavior above:
extends: pulls one service’s definition from another file and merges it with
the same per-key rules as Cause 3 — I set environment: { COLOR: red, SIZE: small }
on the extended service and environment: { COLOR: blue } on the extending one, and
got COLOR: blue, SIZE: small back, the identical merge you’d get from an override
file, just scoped to one service instead of the whole file.
include: pulls in another file’s services as siblings, and — unlike
compose.override.yaml — it is not turned off by using -f. I ran
docker compose -f compose2.yaml config (an explicit -f, which per Cause 1 kills
auto-loaded overrides) and the include: directive inside compose2.yaml still
pulled in the other file’s api service. I also redefined api partially in the
top-level file alongside the include:, and it merged into the included service
by the same per-key rule (environment key overridden, image kept from the
included file) — so include: behaves like “this file’s content is folded into the
mix,” and once it’s folded in, ordinary merge rules apply to it same as anything
declared directly.
What I’d check, in order
- Run
docker compose configwith the exact invocation you actually use (same-fflags, sameCOMPOSE_FILE, same working directory) and confirm your override’s value is actually in the output. - Did you pass
-f? If so,compose.override.yamlis not auto-loaded — you have to list it explicitly. - Is the field you’re overriding a scalar (replaced) or a list/map (merged)? If you
wanted a list fully replaced, you need
!override. - Trying to remove something? Leaving it out of the override doesn’t do it — use
!reset. - Multiple
-ffiles: check the order, and check whether any relative paths in a non-first file are actually being resolved against the first file’s directory.
Test setup and what this doesn’t cover
Docker Compose v5.5.1 (docker compose version), Docker CLI 29.8.0, on
macOS 15.3.1, tested 2026-09-29. Everything above was checked with docker compose config / docker compose config --format json, which resolves files and
merge rules exactly like up does, without a running daemon. I deliberately didn’t
run docker compose up, docker compose run, or start Docker Desktop.
Not tested: Windows and Linux hosts (path separators for COMPOSE_FILE differ —
; on Windows per Docker’s docs, not verified here), older Compose versions (v1,
early v2), COMPOSE_PATH_SEPARATOR, and include: pulling from a git/remote
source rather than a local file.
Official docs checked 2026-09-29: Docker’s multiple-compose-files/merge and
compose-file/merge reference pages. Both confirm the merge rules in Cause 3–5
(single-value replace, sequence append with a uniqueness/mount-path exception,
paths resolved against the base file) and the !reset/!override tags in Cause 4.
Neither page documents the filename-precedence behavior in Cause 2 (compose.yaml
beating docker-compose.yml, with a warning) or explicitly states that -f turns
off auto-loading the override file (Cause 1) — both are things I only confirmed by
running the commands, not something the docs spell out.
For .env files and variable interpolation specifically — a different set of
problems from the file-merging ones on this page — see
Docker Compose .env Not Loading? 6 Real Causes, Tested.