Skip to content
Go back

Compose include, extends, and YAML Anchors

By KingPin 12 min read
Compose include, extends, and YAML Anchors
Contents

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.

Terminal window
docker compose config

That 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.

compose.yaml
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:

Terminal window
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: 5s

The 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:

compose.yaml
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: 5s

Gotcha: 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 value

You 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.

compose.yaml
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"]
shared/common.yaml
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: tcp

Three 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 project

You 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.env
compose.yaml
include:
- 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_started
db/compose.yaml
services:
db:
image: redis:alpine
volumes:
- ./data:/data
ports:
- "${DB_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 2s
retries: 10
db/.env
DB_PORT=6380

The 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: tcp

Three 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.

compose.yaml
services:
app:
image: nginx:alpine
ports:
- "8083:80"
environment:
TZ: UTC
DEBUG: "0"
command: ["nginx", "-g", "daemon off;"]
compose.override.yaml
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:

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.

reset.yaml
services:
app:
ports: !override
- "9000:80"
environment:
DEBUG: !reset null

Run 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: tcp

Which One When

ProblemTool
Same logging/restart/healthcheck repeated in many servicesAnchors + x- field
One file too long, parts have separate owners or lifecyclesinclude
Dev vs prod variants of one stackOverride file, or -f
Services that share a base but live in other filesextends, 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Homebox: Self-Hosted Home Inventory
Next Post
promptfoo: Unit Tests for Your Prompts

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts