"Override" sounds like one consistent operation — the new value replaces the old one. In Docker Compose, it isn't: what "override" actually means depends entirely on the YAML type of the field being merged, and assuming it always means "replace" is exactly how a port binding quietly duplicates instead of changing, or a volume mount that should've been removed stays put.
The same pattern means two different things
Compose file merging (whether via the auto-loaded docker-compose.override.yml or explicit -f base.yml -f prod.yml chaining) follows the field's YAML shape, and the two shapes that matter most behave completely differently:
The identical override.yml pattern merges environment: by key, but appends to ports: instead of replacing it — a port collision or duplicate binding is what that difference looks like in practice.
Three shapes, three behaviors
- Scalars — a single value like
image:,container_name:, orcommand:(even whencommand:is written in exec-array form, it's still one field value, not a list to append to) is replaced wholesale by whatever the later file sets. - Mappings —
environment:,labels:, andbuild.argsare merged key-by-key: a key the override file doesn't mention survives untouched from the base file, and a key it does mention gets overwritten. - Lists — fields like
ports:are concatenated: the override file's entries are appended to the base file's, with nothing removed automatically.volumes:anddevices:are the notable, more forgiving exception — they merge with some awareness of the mount target rather than blindly concatenating, which is worth confirming directly against the current compose-spec docs for a specific case rather than assuming it behaves exactly likeports:.
The genuinely surprising part is that environment: gets mapping behavior even when it's written as a list (- KEY=value entries instead of KEY: value) — Compose normalizes both syntaxes to the same internal representation before merging, so a base file's - A=1 and an override file's - B=2 both end up set, merged by key, not concatenated into a four-line list the way ports: would be.
Forcing a real replace: !override and !reset
For the cases where the default merge behavior genuinely isn't what's needed, compose-spec supports two YAML tags specifically for this:
services:
web:
ports: !override
- "3000:4000" # replaces the base list entirely, doesn't append
environment: !reset # clears every key the base file set for this service!override forces wholesale replacement on a field that would normally merge or concatenate; !reset clears a field entirely rather than merging anything into it. Both exist because "I want this list to actually be replaced, not appended to" is common enough to need a real answer instead of a workaround.
Which files get merged, and in what order
With no -f flags at all, Compose auto-loads docker-compose.yml plus docker-compose.override.yml if it exists in the same directory, merged in that order. Explicit -f base.yml -f prod.yml -f prod.secrets.yml up merges left to right — order is not cosmetic, the last file listed wins any conflict, so listing files in the wrong order silently produces the opposite of the intended result.
Docker Compose Environment Variables Explained covers the project .env file's main job — substituting ${VAR} placeholders — but that same file has a second, distinct role here: special COMPOSE_-prefixed variables inside it (COMPOSE_FILE, COMPOSE_PROJECT_NAME, COMPOSE_PROFILES) configure the Compose CLI's own behavior. Setting COMPOSE_FILE=base.yml:prod.yml doesn't add to the default auto-load list — it replaces it, meaning Compose stops looking for docker-compose.override.yml automatically and uses exactly the files named, in the order given.
Overrides vs. include:
It's worth keeping these conceptually separate even though both involve more than one file: an override chain is the same stack, layered with environment-specific tweaks (dev vs. prod). The newer include: top-level directive is for modular composition instead — splitting one stack's services across multiple files for organization, combined once rather than layered as successive overrides. Reach for overrides when the question is "same services, different environment"; reach for include: when it's "these files together make up one stack."
Try it yourself
The most reliable way to check what a merge actually produced is to stop guessing and look: docker compose -f base.yml -f prod.yml config prints the fully resolved, merged configuration Compose would actually run. Docker Compose Validator and Docker Compose Visualizer both work against that same resolved shape, which is the version of the file that actually matters once more than one compose file is involved.
FAQ
Does docker-compose.override.yml only need to list what's different?
For mapping fields, yes — an untouched key survives from the base file automatically. For list fields like ports:, no: anything the override file lists is added to the base file's list, not swapped in for it, so a genuine replacement needs the !override tag instead of just restating the field.
Can I have more than one override file?
Yes — any number of -f flags chain together, merged strictly left to right, with each subsequent file layered on top of the merged result of everything before it.
Why did my ports: end up with a duplicate or conflicting entry after merging files?
Almost certainly this exact mechanism — an override file's ports: entry was appended to the base file's rather than replacing it. Run docker compose config to see the actual merged list, and use !override on the field if the intent was genuinely to replace it.