Your compose.yaml Is Now a Novel
You scroll to line 412 to change one restart policy. Then you remember the same logging: block lives in 23 other services. You edit 22 of them. At 2 AM you find the one you missed, because that container is the one filling the disk.
Three Compose features fix this, and they fix different things. YAML anchors with x- fields dedupe blocks inside one file. extends copies one service definition into another. include: pulls whole sub-projects in as separate files. My verdict: anchors for repeated settings, include for splitting the file, and extends almost never. The rest of this post shows why, with output from a real docker compose config.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/docker/compose-include-extends-anchors
Everything below ran on Docker Compose v5.6.1. The include key needs Compose v2.20.0 or newer, which is the release that added it.
See the Final Result First: docker compose config
Every trick here rewrites your file before Compose reads it. You need a way to see the result.
docker compose configThat prints the fully merged, interpolated model and exits without starting anything. Run it after every change. It is the difference between guessing and knowing. Add -q if you only want a pass/fail exit code.
YAML Anchors and x- Fields: Dedupe Inside One File
An anchor (&name) labels a chunk of YAML. An alias (*name) pastes it somewhere else. Compose adds one rule on top: any top-level key starting with x- is ignored, so it is a safe place to park a block you only want to reference.
x-common: &common restart: unless-stopped logging: driver: json-file options: max-size: "10m" max-file: "3" healthcheck: test: ["CMD-SHELL", "wget -q --spider http://localhost/ || exit 1"] interval: 30s timeout: 5s retries: 3
x-env: &env TZ: UTC LOG_LEVEL: info
services: web: <<: *common image: nginx:alpine environment: <<: *env LOG_LEVEL: debug ports: - "8088:80"
docs: <<: *common image: nginx:alpine environment: *env healthcheck: test: ["CMD-SHELL", "wget -q --spider http://localhost/ || exit 1"] interval: 5s<<: is the YAML merge key. It copies every key from the anchor into the service, and keys you write next to it win. Thirty services, one logging: block. Your 2 AM self will appreciate it.
Gotcha: the merge is shallow
Look at docs. It overrides healthcheck with just test and interval. You might expect timeout: 5s and retries: 3 to survive from the anchor. Check:
docker compose config | sed -n '/^ docs:/,/^ web:/p' docs: restart: unless-stopped environment: LOG_LEVEL: info TZ: UTC image: nginx:alpine logging: driver: json-file options: max-file: "3" max-size: 10m networks: default: null healthcheck: test: - CMD-SHELL - wget -q --spider http://localhost/ || exit 1 interval: 5sThe whole healthcheck mapping got replaced, so timeout and retries are gone. A merge key only merges the top level of the service. Anything you set locally replaces the anchored value entirely. If you want to override one nested key, anchor the nested block on its own and merge it there:
x-hc: &hc interval: 30s timeout: 5s retries: 3
services: docs: image: nginx:alpine healthcheck: <<: *hc test: ["CMD-SHELL", "wget -q --spider http://localhost/ || exit 1"] interval: 5sGotcha: merge keys only take mappings
The environment key accepts a list (- TZ=UTC) or a map (TZ: UTC). Anchors only merge with the map form. Put a list behind <<: and Compose refuses to load the file:
yaml: construct errors: line 3: go-yaml load error in constructor at L4.C5:map merge requires map or sequence of maps as the valueYou can alias a whole list (environment: *env), but you cannot add keys beside it. Pick the map form in any file that uses anchors.
Gotcha: several anchors, first one wins
You can merge a list of anchors: <<: [*a, *b]. When both define the same key, the first one listed wins. I tested it with restart: always in a and restart: "no" in b:
app: restart: always cpu_shares: 512 mem_limit: "67108864"Put your most important anchor first.
Two more limits. The fragments docs say anchors resolve before variable interpolation, so you cannot build an anchor out of ${VAR}. And YAML anchors stay inside one file: compose.override.yaml or an included file cannot use an anchor from compose.yaml. If your goal is to share a block between files, anchors are the wrong tool.
extends: Inherit a Service From Another File
extends lets a service start from another service’s definition, in the same file or a different one.
services: web: extends: file: shared/common.yaml service: base image: nginx:alpine ports: - "8081:80" environment: LOG_LEVEL: debug
worker: extends: file: shared/common.yaml service: base image: busybox command: ["sleep", "3600"]services: base: restart: unless-stopped environment: TZ: UTC LOG_LEVEL: info env_file: - ./base.env ports: - "9100" volumes: - ./conf:/etc/app:ro logging: driver: json-file options: max-file: "3" max-size: "10m"The merge rules per the Compose reference: mappings merge (child keys win), sequences append (inherited items first, child items after), and scalars from the child win. The result for web:
web: restart: unless-stopped environment: FROM_BASE_ENV: 'yes' LOG_LEVEL: debug TZ: UTC image: nginx:alpine ... volumes: - type: bind source: ~/stacks/02-extends/shared/conf target: /etc/app read_only: true bind: {} ports: - mode: ingress target: 9100 protocol: tcp - mode: ingress target: 80 published: "8081" protocol: tcpThree things to read off that output. LOG_LEVEL came from the child and TZ from the base, so mappings merged. The ports list has both entries, so sequences appended. To replace a list, tag it !override in the child, or use !reset [] to clear it (both covered below). A child volume with the same container path also replaces the base one. And the relative ./conf in the shared file now points at shared/conf. Compose rebases those paths to the file they were written in. (A note in the extends how-to says paths should be relative to the base file. The config output above shows the rebasing, and the same page’s own “Relative paths” example says so too. Trust config.)
Gotcha: depends_on comes along, the target does not
Per the reference, extends does not import the resources a service refers to. It copies depends_on and leaves the service it points at behind. I extended a service that depends on cache from a file, without defining cache locally:
service "child" depends on undefined service "cache": invalid compose projectYou must declare every service, volume, network, and secret the extended service references. Within the same file this is a non-issue, because the target is already there. Across files it means extends is a leaky abstraction. Circular extends chains also error out.
Also from the docs: extends is not supported by docker stack deploy, so Swarm users cannot use it.
include: Split the File for Real
include: is the one that matches the “my file is 600 lines” pain. Each included file loads as its own project, with its own folder for relative paths and its own .env.
stack/ compose.yaml db/ compose.yaml .env cache/ compose.yaml cache.envinclude: - db/compose.yaml - path: cache/compose.yaml env_file: cache/cache.env
services: app: image: nginx:alpine ports: - "8082:80" depends_on: db: condition: service_healthy cache: condition: service_startedservices: db: image: redis:alpine volumes: - ./data:/data ports: - "${DB_PORT:-6379}:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 2s timeout: 2s retries: 10DB_PORT=6380The short form is a plain path. The long form takes three keys: path (a string or a list of files to merge), project_directory (defaults to the included file’s folder), and env_file (defaults to the .env in project_directory, which is the included file’s folder unless you set one; a list of files also works). Here is docker compose config on that stack:
cache: command: - redis-server - --maxmemory - 64mb ... volumes: - type: bind source: ~/stacks/03-include/cache/cache-data target: /data db: ... volumes: - type: bind source: ~/stacks/03-include/db/data target: /data ports: - mode: ingress target: 6379 published: "6380" protocol: tcpThree results. Each ./data resolved against its own folder. DB_PORT came from db/.env (6380), and CACHE_MEM from cache/cache.env. And app in the main file depends on db and cache as if they were local. The Compose docs say the main project’s environment beats the included .env for these defaults. I checked: DB_PORT=7000 docker compose config published 7000. Your shell wins, same as in Compose env precedence.
I ran docker compose up -d --wait on it. All three containers came up and the db healthcheck reported healthy.
Gotcha: duplicate names
The include reference says Compose warns on a name conflict and does not merge. The include how-to says Compose reports an error. Current Compose does neither. Since Compose v5.0.0 (compose-go v2.10.0), the file that does the including overrides the included definition, and Compose merges the two with the normal merge rules. I put a db service in the main file (postgres:17-alpine) and included db/compose.yaml, which also has a db:
db: image: postgres:17-alpine ... healthcheck: test: - CMD - redis-cli - ping ports: - mode: ingress target: 6379 published: "6380"No warning, exit code 0. Compose merged the two, by design: a Postgres image wearing a redis-cli healthcheck. Two includes defining the same service also merged, with ports from both appended. Treat service names as globally unique across everything you include, and read the config output when you add a new file. Resources from included files (volumes, networks) share the same global namespace.
Includes nest too: an included file can have its own include: section.
The Old Way: -f and compose.override.yaml
Before include, you split files with several -f flags, and Compose reads compose.override.yaml automatically.
services: app: image: nginx:alpine ports: - "8083:80" environment: TZ: UTC DEBUG: "0" command: ["nginx", "-g", "daemon off;"]services: app: ports: - "8443:443" environment: DEBUG: "1" command: ["nginx-debug", "-g", "daemon off;"]Plain docker compose config merges both. Here ports got appended, DEBUG became "1", and command was replaced, not appended. The merge docs list command, entrypoint, and healthcheck test as exceptions that override. Pass -f compose.yaml alone and the override is skipped: the 8443 port is gone.
The differences from include matter:
-fmerges files into one model. Same service name in two files means they combine, and that is the intent.includeloads each file as its own project with its own folder and.env.- With
-f, relative paths resolve against the first file’s folder. I put./html:/usr/share/nginx/html:roinsub/extra.yamland ran-f compose.yaml -f sub/extra.yaml. The mount resolved tohtmlin the main folder.
Override files are for environment variants of one stack: dev ports, debug flags. They are a poor fit for slicing a stack into parts.
Removing things: !reset and !override
Appending lists is a trap when you want to replace them. Two YAML tags from the merge docs fix it. !override replaces a value outright. !reset clears it.
services: app: ports: !override - "9000:80" environment: DEBUG: !reset nullRun docker compose -f compose.yaml -f reset.yaml config and the output has only 9000:80 for ports and no DEBUG variable:
environment: TZ: UTC ports: - mode: ingress target: 80 published: "9000" protocol: tcpWhich One When
| Problem | Tool |
|---|---|
| Same logging/restart/healthcheck repeated in many services | Anchors + x- field |
| One file too long, parts have separate owners or lifecycles | include |
| Dev vs prod variants of one stack | Override file, or -f |
| Services that share a base but live in other files | extends, if you must |
The SumGuy Take
Use anchors for repetition and include for size. That covers almost everything. A 600-line file with 30 services becomes a 60-line main file, one x-common anchor, and a folder per sub-stack, each with a compose.yaml that runs on its own.
extends is the odd one out. It needs the shared file to define every dependency, appends lists unless you tag them !override, and the docs contradict themselves on path handling. An anchor does the same job inside one file, and include does it across files, without the depends_on trap. It is like hiring a forklift to move a couch. Technically it works, but your neighbors will have questions. Skip it unless you have an existing codebase that already uses it.
One rule across all of them: run docker compose config before up. Take a breath, read the output, and then start containers.
Common Questions
Which Docker Compose version added the include element?
Docker Compose v2.20.0 added include. The v2.20.0 release notes list “Add support for include” from compose-go. The retired Python docker-compose v1 rejects the key. Older v2 releases, such as v2.19.1, silently ignore it and drop the included services, so run docker compose version before you split a file.
Does YAML anchor merging work with environment lists?
No. A merge key (<<:) only accepts mappings, so environment: ["A=1"] cannot be merged with extra keys. Compose fails to load the file with “map merge requires map or sequence of maps”. Write environment as KEY: value pairs wherever you use anchors.
Does extends copy depends_on from the base service?
Yes, but only the setting. Compose copies the depends_on entry and does not import the service it points to. If the extended service depends on cache and your file does not define cache, Compose stops with “depends on undefined service”. Declare every referenced service yourself.
Do I need to replace compose.override.yaml with include?
No. compose.override.yaml and include solve different problems. An override file varies one stack per environment, and Compose merges it automatically when it sits next to compose.yaml. include assembles separate projects, each keeping its own folder and .env. You can use both in the same stack, and the include how-to shows that pattern.