Docker Compose Merge & Override Strategies¶
- This lab teaches you how Docker Compose merges multiple compose files into a single configuration.
- You’ll learn about the default override file (
docker-compose.override.yaml), explicit multi-file with-f, and the merge rules for maps, lists, and sequences. - Each concept includes a standalone example to experiment with.

CTRL + click to open in new window
Table of Contents¶
- Docker Compose Merge & Override Strategies
- CTRL + click to open in new window
- Table of Contents
- How Merge & Override Works
- Pattern 1: Default Override (Auto-Loaded)
- Pattern 2: Explicit Multi-File with
-f - Pattern 3: Merge Rules Deep Dive
- Environment Merge vs Override
- Ports Override in Detail
- extends vs YAML Anchors for Overrides
- Best Practices
- Troubleshooting
- Hands-On Tasks
- Verification Checklist
- Additional Resources
- Cleanup
- Next Steps
How Merge & Override Works¶
Docker Compose supports merging multiple compose files into one effective configuration. This lets you separate concerns (base vs dev vs prod) and reuse common definitions.
There are two main patterns:
| Pattern | Usage | Example |
|---|---|---|
| Default override | docker compose up | Auto-loads docker-compose.override.yaml |
| Explicit multi-file | docker compose -f file1.yaml -f file2.yaml up | Manual control over file order |
Merge rules govern how overlapping keys are combined:
- Maps (dictionaries like
environment) are merged - keys from later files override earlier ones. - Lists (arrays like
ports) are replaced - the later list completely replaces the earlier one. - Sequences (keyed arrays like
depends_on) are merged by key/position.
The last file listed wins for overlapping scalar values and map keys.
graph TD
A[docker-compose.yaml<br/>Base] --> B[Merge Rules]
C[docker-compose.override.yaml<br/>Override] --> B
B --> D{Maps: environment, labels}
B --> E{Lists: ports, volumes}
B --> F{Sequences: depends_on}
D --> G[Deep Merge<br/>Last wins]
E --> H[Replace Entirely<br/>No append]
F --> I[Merge by Key<br/>Like maps]
Pattern 1: Default Override (Auto-Loaded)¶
When you run docker compose up in a directory containing both docker-compose.yaml and docker-compose.override.yaml, Docker Compose automatically loads both files - no -f flags needed.
Important: When you use any
-fflag, the auto-load ofdocker-compose.override.yamlis disabled.
Base File (docker-compose.yaml)¶
services:
web:
image: nginx:alpine
ports:
- "8198:80"
environment:
- APP_ENV=production
- LOG_LEVEL=warn
volumes:
- ./app:/usr/share/nginx/html:ro
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Override File (docker-compose.override.yaml)¶
services:
web:
ports:
- "8199:80" # LIST replaced: only this port survives
environment: # MAP merged: APP_ENVβdevelopment,
- APP_ENV=development # LOG_LEVELβdebug, DEBUG=true added
- LOG_LEVEL=debug
- DEBUG=true
volumes: # LIST replaced: only these volumes survive
- ./app:/usr/share/nginx/html
- ./src:/app/src:ro
db:
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: devpassword
adminer:
image: adminer
ports:
- "8080:8080"
What Happens at Runtime¶
Run docker compose config (without flags) to see the merged result:
services:
web:
image: nginx:alpine
ports:
- "8199:80" # β from override (list replaced)
environment:
APP_ENV: development # β override wins
LOG_LEVEL: debug # β override wins
DEBUG: "true" # β added by override
volumes:
- type: bind
source: ./app
target: /usr/share/nginx/html # β override list
- type: bind
source: ./src
target: /app/src
read_only: true
db:
image: postgres:16-alpine
ports:
- "5432:5432" # β from override
environment:
POSTGRES_PASSWORD: devpassword # β override wins
volumes:
- pgdata:/var/lib/postgresql/data # β from base (not overridden)
adminer:
image: adminer # β added by override (new service)
ports:
- "8080:8080"
Key behaviors:
- New services in the override file (like
adminer) are added to the final config. - Lists (
ports,volumes) are replaced entirely -ports: ["8199:80"]replacesports: ["8198:80"]. - Maps (
environment) are merged key-by-key with the last file winning conflicts. - Scalars (
image) are kept from the base when not overridden.
Pattern 2: Explicit Multi-File with -f¶
When you need more control - or want to support multiple environments (dev, staging, prod) - use explicit -f flags. This also disables auto-load of docker-compose.override.yaml.
graph LR
A[docker-compose.base.yaml] --> B[Merge]
C[docker-compose.dev.yaml] --> B
B --> D[Dev Config]
A --> E[Merge]
F[docker-compose.prod.yaml] --> E
E --> G[Prod Config]
# Development
docker compose -f docker-compose.base.yaml -f docker-compose.dev.yaml up
# Production
docker compose -f docker-compose.base.yaml -f docker-compose.prod.yaml up
File Breakdown¶
Base (docker-compose.base.yaml) - shared by all environments:
services:
web:
image: nginx:alpine
ports:
- "80" # Expose port 80 (no host binding yet)
environment:
- APP_ENV=base
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
Dev (docker-compose.dev.yaml) - developer overrides:
services:
web:
ports:
- "8198:80" # Bind to host port 8198
environment:
- APP_ENV=development
- DEBUG=true
volumes:
- ./src:/app/src:ro
db:
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: devpass
adminer:
image: adminer
ports:
- "8080:8080"
Prod (docker-compose.prod.yaml) - production overrides:
services:
web:
ports:
- "8200:80"
environment:
- APP_ENV=production
- LOG_LEVEL=warn
deploy:
replicas: 3
restart: always
db:
deploy:
resources:
limits:
cpus: "1"
memory: "512M"
Running by Environment¶
cd examples/multi-file
# ββ Development ββββββββββββββββββββββββββββββββββββββββββββββ
docker compose -f docker-compose.base.yaml -f docker-compose.dev.yaml up -d
docker compose ps
# web on :8198, db on :5432, adminer on :8080
# View merged config:
docker compose -f docker-compose.base.yaml -f docker-compose.dev.yaml config
docker compose down
# ββ Production βββββββββββββββββββββββββββββββββββββββββββββββ
docker compose -f docker-compose.base.yaml -f docker-compose.prod.yaml up -d
docker compose ps
# web on :8200, db (no host port), 3 replicas
docker compose -f docker-compose.base.yaml -f docker-compose.prod.yaml config
docker compose down
# ββ Both files listed wins for conflicts βββββββββββββββββββββ
# The LAST file (dev.yaml or prod.yaml) takes precedence
Why this works:
- The
-fflag takes a list of files applied in order. - Later files override earlier ones (using merge rules).
docker-compose.override.yamlis not loaded when-fis used.- You can chain any number of files:
-f base.yaml -f staging.yaml -f prod.yaml.
Pattern 3: Merge Rules Deep Dive¶
Docker Compose applies three distinct merge strategies depending on the YAML data type. The examples/merge-rules/ directory demonstrates all three.
1. Maps Merge (Last Writer Wins)¶
Files are merged key-by-key. If the same key appears in both files, the later file wins. Keys unique to each file are preserved.
Base (docker-compose.yaml):
services:
map-merge:
image: alpine:latest
command: ["echo", "map merge demo"]
environment:
SHARED_VAR: from-base
UNIQUE_VAR: base-only
Override (docker-compose.override.yaml):
services:
map-merge:
environment:
SHARED_VAR: from-override # β overwrites base
NEW_VAR: override-only # β added by override
Merged result:
environment:
SHARED_VAR: from-override # β override wins
UNIQUE_VAR: base-only # β preserved from base
NEW_VAR: override-only # β added by override
2. Lists Replace (Not Merged)¶
Lists are replaced entirely. The later file’s list completely replaces the earlier one - items are NOT appended.
Base:
Override:
Merged result:
3. Sequences Merge (By Position / Key)¶
Keyed sequences (maps with string keys like depends_on) are merged by key - like a map merge, not a list replace.
Base:
Override:
Merged result:
depends_on:
map-merge:
condition: service_started # β preserved from base
list-replace:
condition: service_started # β added by override
Merge Rules Reference¶
| YAML Type | Examples | Merge Behavior |
|---|---|---|
| Map (dict) | environment, labels, deploy.labels, build.args | Merge - keys from later file win, all others preserved |
| List (array) | ports, volumes, networks, command, entrypoint, dns, tmpfs | Replace - later list completely replaces earlier |
| Keyed Sequence | depends_on, secrets, configs | Merge by key - treat like a map merge |
| Scalar | image, restart, container_name | Replace - later file wins |
Environment Merge vs Override¶
Environment variables illustrate the map-merge rule clearly:
# base.yaml - web service
services:
web:
environment:
APP_ENV: base
LOG_LEVEL: info
# override.yaml
services:
web:
environment:
APP_ENV: development # β overrides
DEBUG: "true" # β added
Result:
| Variable | Value | Source |
|---|---|---|
APP_ENV | development | Override wins |
LOG_LEVEL | info | Preserved from base |
DEBUG | true | Added by override |
Warning: Environment variables defined as a list (- KEY=value) are converted to a map internally, so the same map-merge logic applies - even though the YAML syntax looks like a list.
# Both forms are treated as maps for merging:
environment:
- APP_ENV=development # list form β converted to map
- DEBUG=true
environment:
APP_ENV: development # map form
DEBUG: "true"
Ports Override in Detail¶
Ports are a list, so they follow the replace rule:
graph LR
A[Base: ports: [8198:80]] --> B[Override: ports: [8199:80]]
B --> C[Result: [8199:80]]
C -.->|8198:80 LOST| D[Not appended!]
E[Correct: repeat base ports] --> F[Override: [8198:80, 8199:80, 443:443]]
F --> G[Result: [8198:80, 8199:80, 443:443]]
Result: Only "8199:80" survives. The base port mapping is completely lost.
To keep ports from both files, you must repeat them:
# override.yaml - manually keep both
ports:
- "8198:80" # repeated from base
- "8199:80" # new port
- "443:443" # additional port
This is a common source of confusion - developers often expect append behavior, but lists are always replaced.
extends vs YAML Anchors for Overrides¶
Both extends and YAML anchors (& / <<: *) can be used alongside the override patterns. Here’s how they compare:
| Feature | Default Override | Multi-File -f | extends | YAML Anchors |
|---|---|---|---|---|
| Auto-loads | Yes | No | No | No |
| Cross-file | Same directory | Explicit -f | file: path | Single file |
| Overrides | Maps merge, lists replace | Same rules | Shallow merge | Deep merge |
| Best for | Local dev overrides | Environment switching | Full service templates | Reusable config blocks |
When to use what:
- Default override - your local dev machine, adding ports/volumes without modifying shared files.
- Multi-file
-f- CI/CD, staging vs prod, team environments. extends- inheriting a complete service definition from another compose file (great for monorepos).- YAML anchors - sharing small reusable blocks (
logging,restart,healthcheck) within a single file.
Example combining extends with override files:
# docker-compose.yaml - base
services:
web:
extends:
file: ./common/web-base.yaml
service: web-base
ports:
- "80"
# docker-compose.override.yaml - local only
services:
web:
ports:
- "8080:80" # replaces ports list
environment:
- NODE_ENV=development
Best Practices¶
1. Understand list replacement
# WRONG - expects ports to be additive
# override.yaml
ports:
- "443:443" # π« - "8080:80" from base is LOST
# RIGHT - repeat base ports
ports:
- "8080:80" # β
- keep base port
- "443:443" # β
- add new port
2. Use docker compose config to verify merges
docker compose config # See merged result (with auto-override)
docker compose -f base.yaml -f dev.yaml config # See explicit merge
3. Use descriptive file names for multi-file
# Good - clear intent
docker-compose.base.yaml
docker-compose.dev.yaml
docker-compose.staging.yaml
docker-compose.prod.yaml
# Avoid
base.yaml
dev-v2.yaml
4. Keep overrides minimal
- Override only what differs from the base.
- The smaller the override, the easier to reason about.
5. Commit docker-compose.yaml, gitignore local overrides
6. Use .env files for environment-specific values
# .env.development
APP_ENV=development
DB_PASSWORD=devpass
# .env.production
APP_ENV=production
DB_PASSWORD=prodsecret
# Run with specific env file
docker compose --env-file .env.development up
Troubleshooting¶
| Problem | Solution |
|---|---|
docker-compose.override.yaml not loaded | Are you using -f flags? Override is disabled when -f is used |
| Port from base file missing | Lists are replaced, not merged - repeat base ports in override |
| Environment variable lost | Check for typos in override - only overlapping keys are overwritten |
| Unexpected merge result | Run docker compose config to see the resolved output |
| Services defined twice error | A service name must be unique across all files for the same file set |
extends file not found | Check file: path is relative to the current compose file |
| Override file ignored | Verify the filename is exactly docker-compose.override.yaml |
Hands-On Tasks¶
Task 1: Default Override Pattern¶
cd examples/override
# View the base config
docker compose -f docker-compose.yaml config
# View the merged config (with override auto-loaded)
docker compose config
# Start all services
docker compose up -d
# Verify ports
docker compose ps
# web β :8199, db β :5432, adminer β :8080
# Verify environment in web
docker compose exec web env | grep -E "APP_ENV|LOG_LEVEL|DEBUG"
# Clean up
docker compose down
Task 2: Multi-File Environment Switching¶
cd examples/multi-file
# ββ Development ββββββββββββββββββββββββββββββββββββββββββββββ
docker compose -f docker-compose.base.yaml -f docker-compose.dev.yaml up -d
docker compose ps
docker compose exec web env | grep APP_ENV # β development
docker compose down
# ββ Production βββββββββββββββββββββββββββββββββββββββββββββββ
docker compose -f docker-compose.base.yaml -f docker-compose.prod.yaml up -d
docker compose ps
docker compose exec web env | grep APP_ENV # β production
docker compose down
# ββ View differences βββββββββββββββββββββββββββββββββββββββββ
diff <(docker compose -f docker-compose.base.yaml -f docker-compose.dev.yaml config) \
<(docker compose -f docker-compose.base.yaml -f docker-compose.prod.yaml config)
Task 3: Verify Merge Rules¶
cd examples/merge-rules
# View the merged config
docker compose config
# Observe:
# map-merge.APP_ENV β from-override (map merge)
# list-replace.ports β only "9090:80" (list replace)
# sequence-merge.depends_on β both services (sequence merge)
# Validate specific values
docker compose config | grep -A2 "SHARED_VAR" # β from-override
docker compose config | grep -A2 "UNIQUE_VAR" # β base-only
docker compose config | grep -A2 "NEW_VAR" # β override-only
docker compose config | grep -A3 "list-replace:" # β only 9090 port
Task 4: Custom Override Chain¶
# Create a three-file override chain
cat > docker-compose.base.yaml << 'EOF'
services:
app:
image: nginx:alpine
ports:
- "80"
environment:
REGION: us-east
TIER: base
EOF
cat > docker-compose.staging.yaml << 'EOF'
services:
app:
environment:
TIER: staging
DOMAIN: staging.example.com
EOF
cat > docker-compose.local.yaml << 'EOF'
services:
app:
ports:
- "8080:80"
environment:
TIER: local
EOF
# Apply all three (last file wins most)
docker compose -f base.yaml -f staging.yaml -f local.yaml config
# TIER β local (local.yaml wins)
# REGION β us-east (preserved from base)
# DOMAIN β staging.example.com (from staging)
# ports β ["8080:80"] (list replaced by local.yaml)
Verification Checklist¶
- I understand that
docker-compose.override.yamlis auto-loaded without-f - I know that using
-fdisables the auto-load ofdocker-compose.override.yaml - I can distinguish between map merge, list replace, and sequence merge
- I understand that
ports,volumes,networksare replaced (not merged) - I understand that
environment,labels,build.argsare merged key-by-key - I can use
docker compose configto inspect the merged result - I can switch between environments using
-f base.yaml -f env.yaml - I know to repeat base ports in overrides instead of expecting append
- I can combine
extendsorfragmentswith override files
Additional Resources¶
- Docker Compose File Reference
- Docker Compose Merge / Override Docs
- Docker Compose CLI
-fFlag - Docker Compose Extends
- YAML Anchors Guide
- Lab 009 - Fragments (Anchors & Extends)
Cleanup¶
# Stop all running containers from this lab
docker compose -f examples/override/docker-compose.yaml down
docker compose -f examples/multi-file/docker-compose.base.yaml -f examples/multi-file/docker-compose.dev.yaml down
docker compose -f examples/multi-file/docker-compose.base.yaml -f examples/multi-file/docker-compose.prod.yaml down
docker compose -f examples/merge-rules/docker-compose.yaml down
Next Steps¶
Now that you understand how Docker Compose merges configuration across multiple files, you’re ready to explore more advanced patterns:
| Lab | Topic | What You’ll Learn |
|---|---|---|
| 014 - Docker Networks | Networking | Bridge, overlay, custom networks, DNS resolution |
| 015 - Service Discovery | Discovery | DNS-based service discovery, load balancing |
| 009 - Fragments | Reusability | YAML anchors, extends, fragment libraries |
| 010 - Environment Files | Config | .env files, variable substitution, env_file |
Pro tip: Combine the multi-file override pattern with fragments (Lab 009) for maximum reusability - use -f for environment switching and YAML anchors for shared config blocks.
