Docker Compose Extends: Best Practices and Common Pitfalls
What Is Docker Compose Extends?
Docker Compose extends is a mechanism for reusing service definitions across one or more Compose files. Instead of duplicating configuration blocks for similar services, you can define a base service once and then inherit, override, or merge properties in child services. This promotes DRY (Don't Repeat Yourself) principles in your container orchestration configurations.
Historically, Docker Compose offered a dedicated extends keyword (available in Compose file versions 2.1 through 2.4). In modern Compose (version 3.x and the current Compose Specification), the extends field was deprecated, and the recommended approach shifted to using multiple Compose files merged together via the -f CLI flag. However, as of 2023–2024, the Compose Specification has reintroduced and stabilized extends as a first-class feature, making both approaches viable depending on your Docker Compose version.
Why Extends Matters
Using extends properly gives you several concrete benefits:
- Reduces duplication — Define environment variables, volumes, or health checks once and reuse them across dozens of services
- Environment-specific overrides — Maintain a base configuration and layer development, staging, or production tweaks on top
- Microservice consistency — Ensure all services in a large stack share the same logging driver, restart policy, or network configuration
- Simpler maintenance — Change a base image tag in one place and have it propagate to all derived services automatically
How to Use Extends — The Two Approaches
Approach 1: The extends Keyword (Compose Specification 2.x and Modern Compose)
The extends field allows a service to inherit from another service defined either in the same file or in a separate YAML file. When inheriting from a different file, you specify the file path. The child service can override any inherited property or add new ones.
Basic syntax — same file inheritance:
# docker-compose.yml
services:
# Base service — not meant to be run directly
base-app:
image: node:18-alpine
environment:
- NODE_ENV=development
- LOG_LEVEL=debug
volumes:
- ./app:/src
restart: unless-stopped
# Derived service that extends base-app
api:
extends:
service: base-app
ports:
- "3000:3000"
environment:
- PORT=3000
# Another derived service
worker:
extends:
service: base-app
command: ["npm", "run", "worker"]
environment:
- PORT=5001
Cross-file inheritance:
# common/base-services.yml
services:
base-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: appuser
volumes:
- db-data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
db-data:
# docker-compose.override.yml
services:
postgres-primary:
extends:
file: common/base-services.yml
service: base-db
environment:
POSTGRES_DB: myapp
ports:
- "5432:5432"
postgres-read-replica:
extends:
file: common/base-services.yml
service: base-db
environment:
POSTGRES_DB: myapp_replica
ports:
- "5433:5432"
When you run docker compose up, Docker Compose resolves all extends chains, merges configurations, and builds the final service definitions. Properties in the child take precedence — this is a shallow merge where entire mapping keys are replaced rather than deeply merged.
Approach 2: Multiple Compose Files with -f Flag
The multiple-file approach works by specifying a base Compose file plus one or more override files. Docker Compose merges them in the order provided, with later files taking precedence. This is universally supported across all Compose versions and is the most portable method.
# docker-compose.base.yml
services:
web:
image: nginx:alpine
volumes:
- ./static:/usr/share/nginx/html
networks:
- frontend
backend:
image: myapp:latest
environment:
- DEBUG=false
networks:
- frontend
- backend-db
networks:
frontend:
backend-db:
# docker-compose.dev.yml
services:
web:
volumes:
- ./src:/usr/share/nginx/html:ro # override volumes
ports:
- "8080:80"
backend:
image: myapp:dev
environment:
- DEBUG=true
- HOT_RELOAD=true
volumes:
- ./src:/app/src # add new volume
Run the combined stack with:
docker compose \
-f docker-compose.base.yml \
-f docker-compose.dev.yml \
up -d
Docker Compose reads files left-to-right. The docker-compose.dev.yml file overlays changes on top of docker-compose.base.yml. For list-type keys like volumes, the override file replaces the entire list unless you explicitly use merge-friendly syntax in newer Compose versions.
Understanding Merge Behavior
This is where many developers stumble. Docker Compose merges YAML documents using a specific strategy:
- Scalar values (strings, integers, booleans) — The last value wins
- Mappings/dictionaries (like
environment,labels) — The entire mapping is replaced, not deeply merged key-by-key - Sequences/lists (like
volumes,ports,command) — The entire list is replaced - Special merge keys — In Compose Specification v2.3+, you can use YAML merge types like
<<: *anchorfor deep merges with anchors/aliases
Here is a concrete example demonstrating the list replacement pitfall:
# base.yml
services:
app:
volumes:
- data:/var/data
- config:/etc/config
# override.yml
services:
app:
volumes:
- logs:/var/log # This REPLACES the entire list!
After merging, the app service has only logs:/var/log — the data and config volumes are gone. To preserve both, you must repeat the base volumes in the override file or use a different strategy like anchors.
Best Practices
1. Use YAML Anchors and Aliases for In-File Reuse
For configuration shared within a single file, YAML anchors are cleaner than extends. They allow deep merging and don't require splitting files.
# docker-compose.yml
services:
backend: &backend-template
image: myorg/backend:latest
environment: &common-env
DB_HOST: postgres
REDIS_HOST: redis
LOG_FORMAT: json
healthcheck: &health-defaults
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
api:
<<: *backend-template
ports:
- "8080:8080"
environment:
<<: *common-env
SERVICE_NAME: api-gateway
worker-payments:
<<: *backend-template
command: ["node", "worker.js", "--queue=payments"]
environment:
<<: *common-env
SERVICE_NAME: payment-worker
Anchors (&anchor-name) mark a block for reuse, and aliases (*anchor-name) insert it elsewhere. The <<: merge key performs a deep merge, so environment variables from the anchor are preserved when you add new ones.
2. Structure Files by Environment and Concern
Organize Compose files into layers that each serve a clear purpose:
project/
├── compose.base.yml # Core services, networks, volumes
├── compose.dev.yml # Development overrides
├── compose.staging.yml # Staging-specific config
├── compose.prod.yml # Production hardening
├── compose.observability.yml # Logging, monitoring sidecars
└── compose.test.yml # CI/test-specific services
Run with explicit file ordering:
# Development
docker compose -f compose.base.yml -f compose.dev.yml up
# Production with observability
docker compose -f compose.base.yml -f compose.prod.yml -f compose.observability.yml up
3. Keep Base Files Generic — Never Environment-Specific
A base Compose file should define the structural blueprint: which services exist, which images they use by default, and how they connect via networks. It should not contain secrets, production resource limits, or host-specific volume mounts. Those belong in environment-specific override files.
# Good base.yml — generic and portable
services:
app:
image: myapp:${APP_TAG:-latest}
environment:
- NODE_ENV=${NODE_ENV:-production}
networks:
- backend
# Bad base.yml — environment-specific details leak in
services:
app:
image: myapp:1.2.3-production-fixed
environment:
- NODE_ENV=production
- AWS_SECRET_ACCESS_KEY=sk-xxxxx # NEVER put secrets in base files
deploy:
resources:
limits:
memory: 4G # Production-specific resource limit
4. Document Your Extends Chain Explicitly
When using the extends keyword across multiple files, add comments in each file noting which other files are expected. This prevents confusion when new team members encounter the stack.
# This file extends common/postgres-base.yml
# Expected merge order: postgres-base.yml → this file
services:
postgres:
extends:
file: common/postgres-base.yml
service: postgres-base
# Production-specific hardening
environment:
POSTGRES_LOG_STATEMENT: all
5. Validate Merged Output Before Deploying
Always inspect the fully resolved Compose configuration to verify your merges and extends produced the intended result:
# Print the fully merged configuration
docker compose -f compose.base.yml -f compose.prod.yml config
# Save it to a file for review
docker compose -f compose.base.yml -f compose.prod.yml config > resolved-stack.yml
The docker compose config command normalizes and resolves all extends, merges, variable substitutions, and prints the final YAML. Review it carefully — especially list fields like volumes and ports — before promoting changes to production.
6. Use COMPOSE_FILE Environment Variable for Consistency
Instead of typing long -f chains repeatedly, set the COMPOSE_FILE environment variable so all team members and CI pipelines use the same file sequence:
# .env file in project root
COMPOSE_FILE=compose.base.yml:compose.dev.yml:compose.observability.yml
Now simply running docker compose up automatically merges all three files in order. Different environments can have their own .env files.
Common Pitfalls
Pitfall 1: Assuming Deep Merge for Lists
The most frequent source of bugs is expecting Docker Compose to intelligently merge lists (volumes, ports, environment when defined as lists). It does not. The last file's list completely overwrites previous lists.
# base.yml
services:
web:
ports:
- "80:80"
- "443:443"
# override.yml — intending to ADD a debug port
services:
web:
ports:
- "9229:9229"
# Result: ONLY port 9229 survives. 80 and 443 are gone.
Fix: Repeat all ports in the override, or switch to the dictionary form of ports (available in newer Compose versions) which merges more predictably:
# override.yml — explicitly listing all ports
services:
web:
ports:
- "80:80"
- "443:443"
- "9229:9229" # all three present
Pitfall 2: Circular or Deeply Nested Extends
The extends keyword can reference services that themselves extend others. While Compose resolves chains, deeply nested extends (more than 2–3 levels) become hard to debug and slow to resolve. Avoid creating chains like A extends B extends C extends D.
# Avoid this anti-pattern
services:
d: &d
image: alpine
c:
extends:
service: d
b:
extends:
service: c
a:
extends:
service: b
# What exactly does 'a' inherit? Hard to trace.
Pitfall 3: Extending Services with Conflicting Dependencies
When a base service defines depends_on and the extending service also defines depends_on, the child's list replaces the parent's. If the base service depended on a database that the child omits, the database may not start before the child service, causing race conditions.
# base.yml
services:
base:
depends_on:
- postgres
- redis
# child.yml
services:
api:
extends:
service: base
depends_on:
- redis # postgres dependency is LOST
Fix: Always explicitly include all required dependencies in the extending service, or use a shared anchor for the full dependency list.
Pitfall 4: Inconsistent File Paths in Extends
When using cross-file extends with the file parameter, paths are relative to the current Compose file, not the project root. This causes confusion when override files reference base files in different directories.
project/
├── docker-compose.yml
├── services/
│ └── base.yml
└── overrides/
└── dev.yml
# overrides/dev.yml — WRONG path reference
services:
app:
extends:
file: base.yml # Looks for overrides/base.yml, not services/base.yml!
service: base-app
# Correct reference uses relative path from dev.yml's location:
# file: ../services/base.yml
Pitfall 5: Secrets and Sensitive Data Propagation
Extends and merges copy configuration blocks verbatim. If a base service defines environment variables with default values that look like secrets (even placeholders), they propagate to every derived service and appear in docker compose config output, which is often committed to logs or CI artifacts.
# base.yml — DO NOT do this
services:
base:
environment:
- DB_PASSWORD=changeme # Appears in plain text in config dumps
# Instead, use variable substitution with no default in base:
services:
base:
environment:
- DB_PASSWORD=${DB_PASSWORD:?required} # Fails if not set
Pitfall 6: Forgetting That extends Copies Service Definitions Statically
The extends keyword copies the base service definition at parse time. It does not create a dynamic inheritance relationship. If you later change the base service and restart, child services do not automatically pick up changes unless you recreate the entire stack. This is different from how Docker Compose profiles or multiple -f files behave during a hot reload.
Real-World Example: Multi-Environment Microservice Stack
Consider a microservice application with five services that need different configurations for local development, CI testing, and production deployment.
Base Compose file:
# compose.base.yml
services:
gateway: &gateway-template
image: nginx:${NGINX_VERSION:-alpine}
networks:
- ingress
restart: unless-stopped
auth-service: &service-template
image: myorg/auth:${AUTH_TAG:-latest}
environment: &svc-env
LOG_LEVEL: info
TRACING_ENABLED: "true"
networks:
- backend
restart: unless-stopped
inventory-service:
<<: *service-template
image: myorg/inventory:${INV_TAG:-latest}
environment:
<<: *svc-env
DB_NAME: inventory_db
orders-service:
<<: *service-template
image: myorg/orders:${ORD_TAG:-latest}
environment:
<<: *svc-env
DB_NAME: orders_db
redis:
image: redis:7-alpine
networks:
- backend
networks:
ingress:
driver: bridge
backend:
driver: bridge
Development override:
# compose.dev.yml
services:
gateway:
ports:
- "80:80"
- "443:443"
auth-service:
environment:
LOG_LEVEL: debug
AUTH_MOCK_MODE: "true"
volumes:
- ./services/auth/src:/app/src
ports:
- "9001:9001"
inventory-service:
environment:
LOG_LEVEL: debug
DB_HOST: localhost
volumes:
- ./services/inventory/src:/app/src
orders-service:
environment:
LOG_LEVEL: debug
DB_HOST: localhost
volumes:
- ./services/orders/src:/app/src
redis:
ports:
- "6379:6379"
Production override:
# compose.prod.yml
services:
gateway:
deploy:
replicas: 2
resources:
limits:
memory: 256M
auth-service:
environment:
LOG_LEVEL: warn
TRACING_SAMPLING_RATE: "0.1"
deploy:
replicas: 3
inventory-service:
deploy:
replicas: 2
orders-service:
deploy:
replicas: 3
redis:
deploy:
replicas: 1
volumes:
- redis-persist:/data
volumes:
redis-persist:
Run commands for each environment:
# Local development
docker compose -f compose.base.yml -f compose.dev.yml up --build
# Production (with build args)
NGINX_VERSION=stable AUTH_TAG=2.1.3 \
docker compose -f compose.base.yml -f compose.prod.yml up -d
# Validate production config before deploy
docker compose -f compose.base.yml -f compose.prod.yml config > prod-resolved.yml
Conclusion
Docker Compose extends — whether through the extends keyword or the multiple-file merge strategy — is a powerful tool for building maintainable, DRY container configurations. The key to using it successfully is understanding the merge semantics: scalar overrides are straightforward, but lists and mappings replace rather than combine. By structuring your Compose files into layered bases and environment-specific overrides, using YAML anchors for in-file reuse, validating merged output with docker compose config, and vigilantly avoiding the pitfalls of list overwrites and secret leakage, you can create a Compose architecture that scales cleanly from a single developer's laptop to a full production deployment. Start small, validate often, and let the compose file layering work for you rather than against you.