Docker Compose with multiple files
How compose.override.yaml loads on its own, what file order really merges, the ports trap that keeps a port open, and include for a dev and prod split.
What Compose does with more than one file
Docker Compose can build one project from several files. It reads them in the order it receives them and merges them into a single model, so a later file wins on any value that conflicts. Two mechanisms do this from the command line: an override file Compose loads on its own, and the -f flag you pass by hand. A third lives inside the file itself, the include element, and it works differently from both.
The merge is not a plain overwrite. Mappings merge key by key, sequences append, and a small set of fields are replaced whole. That difference is where the surprises come from, and the ports list is the one that catches almost everybody.
Everything below assumes Compose v2, the docker compose plugin rather than the old docker-compose script. Run docker compose version to check. If you have not written a Compose file yet, start with the Docker Compose basics guide and come back.
The override file Compose loads without being told
Run docker compose up with no -f flag and Compose searches the working directory, then its parent directories, for compose.yaml or docker-compose.yaml. If an override file sits next to the base file, Compose loads that second, on its own.
ls compose.yaml compose.override.yaml
docker compose up -dWith both files present, that is the same as typing them out by hand.
docker compose -f compose.yaml -f compose.override.yaml up -dThe names Compose recognises are compose.override.yaml, compose.override.yml, and the older docker-compose.override.yml and docker-compose.override.yaml. Any other name, compose.dev.yaml for example, is loaded only when you name it with -f.
The moment you pass one -f, automatic loading stops. docker compose -f compose.yaml up reads exactly that one file and ignores the override, which is the property the dev and prod pattern later in this guide is built on.
This cuts both ways on a server. An override file left in the deploy directory is loaded by every bare docker compose command run from that directory, including the one your cron job runs. That is how a production stack ends up bind-mounting a source directory nobody meant to ship. Run docker compose config after any deploy and read what came out.
Ordering with -f, and where relative paths resolve
Compose builds the configuration in the order you supply the files, and subsequent files override and add to their predecessors. Left to right, last one wins.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -dEvery command in that project needs the same file list. Run up with two files and logs with one and you are talking to a different merged model, which is a fast way to get a service that Compose says does not exist. Set the list once instead, with the COMPOSE_FILE environment variable.
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose config
docker compose up -dThe separator is : on Linux, and COMPOSE_PATH_SEPARATOR changes it. COMPOSE_FILE can also live in the project .env file, which makes it part of the checkout rather than part of your shell history. Anything set explicitly on the command line beats the environment variable.
Now the rule that breaks bind mounts. When you use multiple files with -f, all relative paths in all of those files resolve against the directory of the first file, not against the file that contains them. Write ./data:/var/lib/postgresql/data inside deploy/prod/compose.prod.yaml and Compose still looks for ./data next to the base file. Docker then creates an empty directory at that wrong path and the container starts with nothing in it, which looks like data loss and is not. Pass --project-directory to set the base path yourself, or use include, which resolves each file against its own directory.
The project name comes from that same base directory, so changing which file is first can rename the project. A renamed project means new container names and new volume names, and the old volume is still on disk under the old name. Pin it instead with a top-level name: in the base file.
name: myappWhich fields merge, and which are replaced
Compose merges by the type of the value, not by the name of the field.
- Single-valued fields are replaced.
image,command,entrypointandmem_limittake the later value outright. You cannot append one argument to acommand, because the override rewrites the whole line. - Mappings merge key by key.
environment,labels,volumesanddeviceskeep every key from both files, and the later file wins on any key present in both. Forenvironmentandlabelsthe key is the variable or label name. Forvolumesanddevicesthe key is the container path. - Sequences append.
dns,dns_search,expose,tmpfsandexternal_linksare concatenated. A base holdingexpose: ["3000"]merged with an override holding["4000", "5000"]produces["3000", "4000", "5000"].
Four sequences carry an identity key, so entries matching on that key merge instead of appending. volumes, secrets and configs match on target. ports matches on the combination of ip, target, published and protocol.
Read that ports rule twice, because it is the trap. Two port entries are the same entry only when all four of those parts agree. Change any one of them and Compose sees a second, unrelated port, so it keeps both.
Why your port is still published after the override
A base file that publishes a service on every interface:
services:
web:
image: nginx:1.27
ports:
- "8080:80"An override written to bind it to localhost only, because a reverse proxy will sit in front of it:
services:
web:
ports:
- "127.0.0.1:8080:80"Check the result before assuming it worked.
docker compose -f compose.yaml -f compose.prod.yaml configBoth entries are in the output. The ip part differs, 0.0.0.0 against 127.0.0.1, so they are two different ports as far as the merge is concerned, and the public binding you tried to remove is still in the model. That matters more on Docker than elsewhere, because a published port is written into iptables ahead of your firewall rules. The mechanism is covered in why published Docker ports walk past ufw.
There are two fixes. The explicit one is the !override tag, which replaces the whole attribute and skips the merge rules:
services:
web:
ports: !override
- "127.0.0.1:8080:80"!override needs Compose v2.24.4 or newer. The portable fix needs no tag at all: keep ports out of the base file entirely and declare it only in the environment-specific files. Nothing to merge means nothing to leak. That is the pattern used in the worked example below.
Deleting a value the base file set
!reset removes an attribute, putting it back to its default or to null. It takes a value and ignores it, so write something valid and empty.
services:
web:
ports: !reset []
environment:
DEBUG: !reset null!reset needs Compose v2.24 or newer. Reach for it when the base file is not yours to edit, a vendor fragment you pull in for example.
include, for stacks assembled from parts
include pulls another Compose application into your model. It is a top-level element, not a flag.
include:
- path: ../commons/compose.yamlEach path in include is loaded as its own Compose application model, with its own project directory, so relative paths inside that file resolve against that file's own directory. That is the real difference from -f, and the reason include is the right tool when the fragment lives in another folder or another repository.
The long form takes sub-options.
include:
- path:
- ../monitoring/compose.yaml
- ../monitoring/compose.vps.yaml
project_directory: ../monitoring
env_file: ../monitoring/.envpath accepts a list, and those files are merged together with the normal rules before the result joins your model. project_directory sets the base path used to resolve relative paths in the included file. env_file gives the included file its own variables for interpolation, which stops a shared fragment from quietly reading your project's .env. include needs Compose v2.20.0 or newer.
Duplicate resource names between your file and an included file are reported as an error rather than merged quietly, and that is deliberate. To change something an included file declares, put the change in compose.override.yaml: the override is applied to the assembled model, so it can touch included resources without colliding with them.
The short version: include composes separate applications, -f layers configuration onto one application.
A dev and prod split on one VPS
Here is the whole pattern in three files. The base file declares what is true everywhere, and it publishes no ports at all.
name: myapp
services:
app:
image: ghcr.io/example/app:1.4.2
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
LOG_LEVEL: info
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:The depends_on condition is what makes the app wait for a database that answers rather than a container that merely exists, explained in healthchecks and depends_on conditions. POSTGRES_PASSWORD is interpolated from the project .env file, which never belongs in git. See env files and Compose secrets for the safer variants.
Next, compose.override.yaml, which Compose loads on its own. This is the developer's file.
services:
app:
build: .
command: npm run dev
environment:
LOG_LEVEL: debug
ports:
- "3000:3000"
volumes:
- ./src:/app/src
db:
ports:
- "127.0.0.1:5432:5432"On a laptop, a bare docker compose up merges those two files. command replaces the image default because it is single valued. LOG_LEVEL replaces info because environment merges by key. The bind mount and the two published ports are pure additions, and the database port is bound to localhost so a laptop on a shared network is not offering PostgreSQL to the room.
Last, compose.prod.yaml. Its name is not one Compose looks for, so it is never loaded by accident.
services:
app:
ports:
- "127.0.0.1:8000:3000"
deploy:
resources:
limits:
memory: 512MOn the VPS you name both files, and that act of naming is exactly what excludes the override.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml psps should list both services as running, with db showing (healthy). Because you passed -f, compose.override.yaml was not read, so the dev command, the source bind mount and the public port 3000 cannot reach production even though the file is sitting in the same directory. Port 8000 is on localhost only, ready for a proxy: see running several apps behind Traefik when you add the second service.
Set COMPOSE_FILE=compose.yaml:compose.prod.yaml in the server's .env and the rest of your commands go back to being plain docker compose logs -f app.
Read the merged model before you deploy
docker compose config prints the fully merged, fully interpolated model. It is not a preview. It is the exact input Compose will act on, so when the output disagrees with your expectation, the output is right.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml config --no-interpolate
docker compose -f compose.yaml -f compose.prod.yaml config --services--no-interpolate leaves ${VAR} unexpanded. Use it before pasting output anywhere, because plain config prints every resolved secret in clear text. --services lists only the service names, which is a quick way to confirm an include pulled in what you expected.
Failure modes, and what you will see
no configuration file provided: not found. Compose found nothing to read. You are outside the project directory, or COMPOSE_FILE names a path that does not exist. Compose searches parent directories for the default base file, but it does not search anywhere for a file you named yourself.
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string. Interpolation resolves against the project .env file and the shell environment, and the project directory here is the directory of the first -f file. Deploying from a different directory than the one holding .env gives you this warning and then a database that refuses every connection.
Your override edit does not show up in docker compose config. Either you passed -f, which turns automatic override loading off, or Compose found compose.yaml in a parent directory and your override file is not next to it. Running docker compose config with no other arguments tells you which model Compose is really building.
A bind mount is empty and Docker created a directory you did not ask for. The relative path resolved against the first file's directory. Fix the path, pass --project-directory, or move the fragment behind include.
Containers come back with new names and a volume looks blank. The project name changed, because the project name follows the first file's directory. Add a top-level name: to the base file and the naming stops moving. The old volume is still there under the old prefix, and docker volume ls will show it.
A port you removed in the override is still open. The ports merge appended instead of replacing. Confirm with docker compose config, then either use !override or move ports out of the base file.
FAQ
Does Compose load compose.override.yaml automatically?
Yes, when you run docker compose with no -f flag. Compose searches the working directory and its parents for compose.yaml or docker-compose.yaml, and if an override file sits beside it, that file is loaded second. The recognised names are compose.override.yaml, compose.override.yml, docker-compose.override.yml and docker-compose.override.yaml. Passing any -f disables this, so docker compose -f compose.yaml up reads one file only.
In what order do multiple -f files merge?
Left to right. Compose builds the configuration in the order you supply the files, and each file overrides and adds to the ones before it, so the last file on the line wins any conflict. The same list must be used for every command in that project, which is what COMPOSE_FILE=compose.yaml:compose.prod.yaml is for.
Why is my port still published after I overrode it?
Because ports entries are identified by the whole set of ip, target, published and protocol. An override of 127.0.0.1:8080:80 against a base of 8080:80 differs in the ip part, so Compose treats it as a second port and keeps both. Run docker compose config and you will see the two entries. Use ports: !override on Compose v2.24.4 or newer, or keep ports out of the base file so there is nothing to merge against.
What is the difference between include and -f?
-f layers several files onto one application, and every relative path in every file resolves against the first file's directory. include pulls in a separate Compose application, and each included path keeps its own project directory, so its relative paths resolve against itself. Use -f for environment layers of your own stack, and include for a fragment maintained elsewhere. include needs Compose v2.20.0 or newer.
How do I remove a value that the base file sets?
Use the !reset tag on Compose v2.24 or newer. Write ports: !reset [] or MY_VAR: !reset null in the overriding file and the attribute goes back to its default or to null. The value you give the tag is required but ignored. If you want to replace an attribute rather than clear it, !override does that, and it needs v2.24.4 or newer.