A beginner-friendly guide to reusing configuration in compose.yaml without turning the file into a copy-paste mess.
- Why this matters
- The short definitions
- The most important mental model
- Part 1: A simple, useful example
- Part 2: Alias vs merge, side by side
- Part 3: A realistic example with 3 services
- Part 4: Overriding a merged block
- Part 5: Profiles for optional services
- Part 6: Complete example
- Part 7: Common mistakes beginners make
- Part 8: Practical rules of thumb
- Final takeaway
As soon as a Compose file has more than one or two services, repetition starts showing up everywhere:
- the same restart policy
- the same environment variables
- the same logging settings
- the same networks
- the same healthcheck options
That repetition is annoying, but the real problem is maintenance. If the same block is copied into three services and later only two copies get updated, the file becomes inconsistent.
This is where YAML reuse features help.
In Docker Compose documentation, this kind of reuse is often described under fragments. Under the hood, these are standard YAML features that Compose understands:
&name→ anchor*name→ alias<<:→ merge key
And when some services should be optional, Compose adds:
profiles:→ start only certain services when needed
An anchor gives a YAML value a reusable name.
x-app-defaults: &app_defaults
restart: unless-stopped
networks:
- appnetHere, &app_defaults is the anchor.
An alias reuses the anchored value as-is.
environment: &common_env
APP_ENV: production
LOG_LEVEL: info
# later
environment: *common_envHere, *common_env means: “use that earlier value here unchanged.”
The merge key is used when the anchored value is a mapping (a key/value object) and that mapping should be merged into another mapping.
service:
<<: *app_defaults
image: nginx:alpineThat means: “take the key/value pairs from app_defaults and merge them into this mapping.”
This is the pattern most often used for service defaults.
There are two different kinds of reuse:
*alias= reuse a value unchanged<<: *alias= merge a mapping into another mapping so it can be extended or overridden
That distinction matters much more than “list vs mapping”. An alias can point to a list, mapping, or scalar. A merge key only works with mappings.
A full side-by-side example appears in Part 2: Alias vs merge, side by side.
A realistic starter setup:
web= frontend containerapi= backend containerdb= PostgreSQL
web and api should share some defaults:
- same restart policy
- same network
- same logging options
services:
web:
image: nginx:alpine
restart: unless-stopped
networks:
- appnet
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
api:
image: mycompany/api:latest
restart: unless-stopped
networks:
- appnet
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
APP_ENV: production
db:
image: postgres:16
restart: unless-stopped
networks:
- appnet
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
networks:
appnet:This works, but it repeats a lot.
x-service-defaults: &service_defaults
restart: unless-stopped
networks:
- appnet
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
web:
<<: *service_defaults
image: nginx:alpine
ports:
- "8080:80"
api:
<<: *service_defaults
image: mycompany/api:latest
environment:
APP_ENV: production
db:
image: postgres:16
restart: unless-stopped
networks:
- appnet
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
networks:
appnet:web and api now inherit:
restart: unless-stopped- the
appnetnetwork - the logging block
That block is written once and reused in both services.
This is a very common Compose convention.
Keys starting with x- are extensions: helper blocks meant for reuse. Compose ignores them as top-level runtime configuration, which makes them a good place to store anchors.
That means this:
x-service-defaults: &service_defaults
restart: unless-stopped
networks:
- appnetis basically saying:
“This is not a service. This is a reusable template block.”
Using x- is not mandatory, but in Compose files it is usually the clearest pattern.
This is the section most beginners need.
x-env: &common_env
APP_ENV: production
LOG_LEVEL: info
services:
api:
image: mycompany/api:latest
environment: *common_env
worker:
image: mycompany/worker:latest
environment:
<<: *common_env
QUEUE: emailsCreates an anchor named common_env.
Reuses the whole environment mapping unchanged.
So api gets:
environment:
APP_ENV: production
LOG_LEVEL: infoReuses the common environment as a starting point, then adds more keys.
So worker gets:
environment:
APP_ENV: production
LOG_LEVEL: info
QUEUE: emails- use
*common_envwhen the exact same block is needed - use
<<: *common_envwhen shared values are needed plus service-specific additions or overrides
Because merge and alias solve different problems.
This is perfectly valid and often the cleanest option:
environment: *common_envThere is no need to wrap that in a merge when nothing should be changed.
A merge becomes useful only when a new mapping must be built from the old one:
environment:
<<: *common_env
API_PORT: "8080"The merge key works only on mappings, not on sequences/lists.
This is valid:
x-env-list: &env_list
- APP_ENV=prod
- LOG_LEVEL=info
services:
api:
environment: *env_listHere, *env_list just reuses the list unchanged.
This is not valid YAML merge usage:
environment:
<<: *env_listWhy? Because *env_list points to a list, but <<: only merges mappings.
So:
- alias can reuse lists, mappings, or scalars
- merge only works with mappings
A more realistic application setup:
apiworkerscheduler
They all use the same image. They all need:
- a shared env block
- the same restart policy
- the same network
- the same database connection settings
But each service has a different command.
x-app-base: &app_base
image: mycompany/laravel-app:latest
restart: unless-stopped
networks:
- backend
environment: &app_env
APP_ENV: production
APP_DEBUG: "false"
DB_HOST: db
DB_PORT: "5432"
DB_DATABASE: app
DB_USERNAME: app
DB_PASSWORD: secret
services:
api:
<<: *app_base
command: php artisan serve --host=0.0.0.0 --port=8000
ports:
- "8000:8000"
worker:
<<: *app_base
command: php artisan queue:work --verbose --tries=3
scheduler:
<<: *app_base
command: sh -c "while true; do php artisan schedule:run; sleep 60; done"
db:
image: postgres:16
restart: unless-stopped
networks:
- backend
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
networks:
backend:
volumes:
pgdata:The repeated application setup lives in one place:
- image
- restart
- network
- common environment
Each service only declares what is unique:
commandportsforapi
That is the sweet spot: shared defaults above, service-specific details below.
A common question is:
“What if one service should inherit the defaults, but change one value?”
That is exactly where merge keys shine.
x-app-base: &app_base
image: mycompany/app:latest
restart: unless-stopped
environment: &common_env
APP_ENV: production
LOG_LEVEL: info
CACHE_DRIVER: redis
services:
api:
<<: *app_base
worker:
<<: *app_base
environment:
<<: *common_env
LOG_LEVEL: debug
WORKER_CONCURRENCY: "5"api gets:
environment:
APP_ENV: production
LOG_LEVEL: info
CACHE_DRIVER: redisworker gets:
environment:
APP_ENV: production
LOG_LEVEL: debug
CACHE_DRIVER: redis
WORKER_CONCURRENCY: "5"So the worker:
- inherits the common env
- overrides
LOG_LEVEL - adds
WORKER_CONCURRENCY
That is a very common pattern in real Compose files.
Profiles let some services stay optional.
Typical use cases:
- a database admin UI that only starts sometimes
- a mail testing tool for development
- a debugging container
- a one-off helper service
- services without
profiles:start normally - services with
profiles:start only when that profile is enabled, or when that service is explicitly targeted on the command line
services:
api:
image: mycompany/api:latest
ports:
- "8000:8000"
db:
image: postgres:16
adminer:
image: adminer
profiles:
- debug
ports:
- "8081:8080"If this command is run:
docker compose up -dCompose starts:
apidb
But not adminer, because adminer belongs to the debug profile.
If this command is run:
docker compose --profile debug up -dCompose starts:
apidbadminer
There are two useful patterns.
docker compose --profile debug up -dThis starts:
- all normal services
- all services assigned to
debug
docker compose up -d adminerWhen a profiled service is explicitly targeted on the command line, it can run without manually enabling its profile.
In that case, Compose starts:
adminer- any declared dependencies of
adminer
It does not automatically start other services that merely share the same profile.
That makes this very useful for one-off helper services.
This compose.yaml combines:
- extension blocks with
x- - anchors
- aliases
- merge keys
- shared env
- shared service defaults
- 3 application services
- 2 optional profile-based services
x-app-service: &app_service
image: mycompany/shop-api:latest
restart: unless-stopped
networks:
- backend
depends_on:
- db
- redis
environment: &app_env
APP_ENV: production
APP_DEBUG: "false"
DB_HOST: db
DB_PORT: "5432"
DB_DATABASE: shop
DB_USERNAME: shop
DB_PASSWORD: secret
REDIS_HOST: redis
REDIS_PORT: "6379"
x-debug-service: &debug_service
restart: unless-stopped
networks:
- backend
profiles:
- debug
services:
api:
<<: *app_service
command: php artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000
ports:
- "8000:8000"
worker:
<<: *app_service
command: php artisan queue:work --tries=3
environment:
<<: *app_env
QUEUE_CONNECTION: redis
WORKER_CONCURRENCY: "4"
scheduler:
<<: *app_service
command: sh -c "while true; do php artisan schedule:run; sleep 60; done"
db:
image: postgres:16
restart: unless-stopped
networks:
- backend
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- backend
adminer:
<<: *debug_service
image: adminer
depends_on:
- db
ports:
- "8081:8080"
mailpit:
<<: *debug_service
image: axllent/mailpit
ports:
- "8025:8025"
- "1025:1025"
networks:
backend:
volumes:
pgdata:docker compose up -dThis starts:
apiworkerschedulerdbredis
It does not start:
adminermailpit
because those are in the debug profile.
docker compose --profile debug up -dNow Compose starts everything above plus:
adminermailpit
docker compose up -d adminerThat starts adminer and its declared dependencies. It does not automatically start every other service in the debug profile.
A very useful learning and debugging command is:
docker compose configThis shows the resolved Compose model after aliases and merges have been applied.
When fragments feel confusing, this command is often the fastest way to see what Compose actually received.
They are not.
Good when the whole value should be reused unchanged:
environment: *common_envGood when a mapping should be reused and then extended:
environment:
<<: *common_env
EXTRA_FLAG: "1"The first says “use exactly this value.” The second says “start with this mapping, then modify it.”
The merge key works on mappings, not lists.
Good:
x-env: &common_env
APP_ENV: production
LOG_LEVEL: info
services:
api:
environment:
<<: *common_env
EXTRA_FLAG: "1"Not good:
x-ports: &common_ports
- "8000:8000"
services:
api:
ports:
<<: *common_portsThat does not work the way most people expect.
For lists, either reuse the whole value with *alias or repeat the list if it is short.
This:
x-env: &common_env
APP_ENV: productionis very different from this:
environment: &common_env
APP_ENV: productionThe anchor applies to the exact YAML node where it is placed.
So it matters whether the anchor represents:
- an
environmentmapping - a whole service block
- a
loggingblock - a
healthcheckblock
In a merge like this:
environment:
<<: *common_env
LOG_LEVEL: debugLOG_LEVEL: debug wins over the merged value.
That override behavior is often exactly what is wanted.
When deciding what to do, these rules usually work well:
- Use
x-...top-level extension blocks to store reusable Compose snippets. - Anchor reusable mappings such as service defaults, environment blocks, logging blocks, or healthchecks.
- Use
*aliaswhen the exact same value should be reused unchanged. - Use
<<: *aliaswhen a mapping should be reused and then extended or overridden. - Prefer mapping syntax for
environmentwhen merges are needed. - Use
profilesfor optional tools such as Adminer, Mailpit, or debugging helpers. - Use
docker compose configto inspect the final expanded result.
The most useful beginner summary is this:
- Anchor = give a YAML value a name
- Alias = reuse that value unchanged
- Merge key = reuse a mapping and extend or override it
- Extension (
x-...) = a clean place to store reusable Compose blocks - Profile = make a service optional
Once that mental model clicks, Compose files become much easier to read, scale, and maintain.