The real power comes from using all three mechanisms together, each doing what it does best.
The scenario
A team maintaining a web application with:
- Shared infrastructure (database, monitoring) reused across projects
- Common service configuration (logging, labels) applied to all services
- Different settings for local development vs CI vs production
Project structure
my-project/
├── compose.yml # Main entry point
├── compose.override.yml # Local dev overrides
├── compose.ci.yml # CI-specific overrides
├── base/
│ └── service-base.yml # Shared service config (extends)
└── infra/
├── database.yml # Postgres stack (include)
└── monitoring.yml # Prometheus + Grafana (include)
Step 1: Shared service config with extends
Define common configuration once:
# base/service-base.yml
services:
base:
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
labels:
com.company.project: ${COMPOSE_PROJECT_NAME}
com.company.env: ${ENV:-dev}
Step 2: Modular infrastructure with include
Self-contained stacks that can be reused across projects:
# infra/database.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready"]
interval: 10s
volumes:
db-data:
Step 3: Main compose file
Bring it all together:
# compose.yml
include:
- path: ./infra/database.yml
- path: ./infra/monitoring.yml
services:
api:
extends:
file: ./base/service-base.yml
service: base
image: myapp-api:${TAG:-latest}
environment:
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@postgres/myapp
depends_on:
postgres:
condition: service_healthy
worker:
extends:
file: ./base/service-base.yml
service: base
image: myapp-worker:${TAG:-latest}
environment:
DATABASE_URL: postgres://postgres:${DB_PASSWORD}@postgres/myapp
Step 4: Environment-specific overrides
# compose.override.yml (local dev - auto-loaded)
services:
api:
build: ./api # Build locally
volumes:
- ./api:/app # Hot reload
environment:
DEBUG: "true"
# compose.ci.yml (CI - explicit: docker compose -f compose.yml -f compose.ci.yml up)
services:
api:
image: myapp-api:${CI_COMMIT_SHA}
worker:
image: myapp-worker:${CI_COMMIT_SHA}
The result
Each mechanism handles its concern independently:
include: infrastructure stacks are isolated and reusableextends: service config is DRY and consistent- Override files: environment differences are explicit and targeted
Changing the logging config? Update base/service-base.yml once. Swapping the database stack? Replace one include line. Adjusting dev ports? Edit compose.override.yml without touching anything else.
Further reading
- Compose documentation: Multiple Compose files
- Watch: Managing multiple Compose files - a complete walkthrough of these patterns in practice