extends, include, and multiple -f flags compared
As soon as a project spans multiple environments or multiple related services, a single monolithic compose.yaml quickly turns unwieldy. extends and the newer include offer two different ways to factor out shared definitions instead of copying everything over and over.
Table of Contents
- 1. Why a single Compose file eventually stops being enough
- 2. extends: inheriting individual service definitions
- 3. Limits of extends: what does not work
- 4. include: entire Compose files as project building blocks
- 5. include versus multiple -f flags: the practical difference
- 6. Combining extends and include
- 7. Shared networks and volumes across file boundaries
- 8. Practical example: using a modular structure in the CI pipeline
- 9. Decision guide: extends, include, or multiple -f files
- 10. Summary
- 11. FAQ
1. Why a single Compose file eventually stops being enough
A typical project starts with a single compose.yaml defining an app, a database, and a cache. As complexity grows, variations pile up quickly: a test environment without Mailhog, a staging environment with different resource limits, a CI setup without volumes for faster builds. Copying the file for every case leads to the copies drifting apart within a few weeks, because nobody reliably keeps every copy in sync.
Compose offers two complementary mechanisms for this: extends allows reusing individual service definitions across file boundaries, while include pulls in entire Compose files as self-contained but related project parts. Both solve different problems and can even be combined, but should not be mixed arbitrarily, or the structure itself becomes a debugging problem.
2. extends: inheriting individual service definitions
With extends, a service can adopt another service's configuration as its base, either from the same file or from a different one. The typical scenario is a base definition with image, network, and shared environment variables, from which several concrete services inherit and only add the differences. Unlike a plain YAML anchor, extends works across services and even across files, something plain YAML anchors cannot do.
The merge semantics matter here: list fields like ports or volumes are merged rather than replaced, while scalar values like image can be overridden by the inheriting service. These nuances are the main reason why extends works best for genuinely shared base configuration and not for services that differ fundamentally, where it causes more confusion than it saves effort.
# common.yaml
services:
app-base:
image: myapp:latest
environment:
NODE_ENV: production
networks:
- backend
# compose.yaml
services:
web:
extends:
file: common.yaml
service: app-base
ports:
- "3000:3000"
worker:
extends:
file: common.yaml
service: app-base
command: ["node", "worker.js"]
3. Limits of extends: what does not work
An often-missed detail: extends supports neither depends_on nor links nor volumes_from from the base definition, because those relationships reference other services that might not even exist in the base file's context. Anyone trying to inherit a database dependency via extends has to declare depends_on again manually in the inheriting service, which is easy to forget and leads to startup race conditions.
Also, extends only stays reliable at a single level of inheritance in practice: a service that inherits from an already-inheriting service is technically possible, but quickly kills traceability because one has to keep several files open at once to understand the effective configuration. Running docker compose config is therefore mandatory whenever extends is used, to verify the resolved definition.
# Show the resolved configuration after extends
docker compose config
# Inspect just the web service in isolation
docker compose config web
4. include: entire Compose files as project building blocks
While extends operates at the service level, include operates at the file level. A main compose.yaml can reference further, self-contained Compose files via include, whose services then become part of the same project, including shared networks and volumes. This works excellently for large systems made up of several functionally separate sub-projects, for example a frontend repository and a backend repository, each bringing its own Compose file.
The key difference from multiple -f flags is that include is declared directly inside the YAML file itself, making it versioned, documented, and reproducible without any extra command-line parameters. A developer does not need to remember a list of flags, a plain docker compose up is enough, because the file itself knows which other files belong to it.
# compose.yaml (main project)
include:
- path: ./backend/compose.yaml
- path: ./frontend/compose.yaml
env_file: ./frontend/.env
services:
reverse-proxy:
image: nginx:alpine
ports:
- "80:80"
depends_on:
- backend-api
- frontend-web
5. include versus multiple -f flags: the practical difference
Multiple -f flags, like docker compose -f a.yaml -f b.yaml up, merge files by overriding or merging values at the key level, similar to a Compose override. This works well for variants of the same services, for example a base file plus a file with debug ports for local development. include, on the other hand, brings together entirely separate services from different files into one shared project, without keys overriding each other, since service names are typically distinct.
A common mistake is trying to use include for pure override purposes, for example changing just the port of an existing service in a staging file. For that, -f with multiple files or a compose.override.yaml remains the right tool. include is meant for additive composition, not for surgically overriding existing services.
6. Combining extends and include
In larger systems, both mechanisms complement each other: include ties together the Compose files of several sub-projects, while within a sub-project extends is used to build related services like api and api-worker on a shared base definition. This separation by responsibility, service-internal reuse via extends, cross-project composition via include, keeps both concepts clearly separated and prevents a single file from becoming an unmaintainable dumping ground.
A real-world example is a monorepo with three services (api, worker, scheduler) that all descend from the same Docker image and only differ in their start command, plus two external sub-projects (frontend, admin-panel) with their own Compose files. The api/worker/scheduler group internally uses extends on a shared base, while the root Compose file pulls in frontend and admin-panel via include. The result is a flat, easily navigable structure despite many moving parts.
# services/api-group.yaml -- uses extends for related services
services:
api-base:
image: myorg/api:latest
env_file: ./services/api.env
api:
extends: { service: api-base }
ports: ["8080:8080"]
worker:
extends: { service: api-base }
command: ["node", "worker.js"]
# compose.yaml -- uses include for sub-projects
include:
- path: ./services/api-group.yaml
- path: ./frontend/compose.yaml
- path: ./admin-panel/compose.yaml
7. Shared networks and volumes across file boundaries
A central advantage of include over entirely separate Compose projects is that all included files automatically belong to the same Compose project and can share the same top-level defined networks and volumes, as long as they are named identically. A backend network defined in the main file is therefore also available to services from included files, without having to manually create and reference an external network via docker network create.
With separate projects only started sequentially via a shell script or CI pipeline, one has to explicitly reference a previously created network with external: true, which is an extra source of errors if the start order is wrong or the network does not exist yet. include removes this coordination burden entirely, because Compose resolves the entire effective configuration in a single pass.
8. Practical example: using a modular structure in the CI pipeline
This modularity pays off especially in CI pipelines. A job that only wants to test the backend part can directly call docker compose -f services/api-group.yaml up without starting the entire project including the frontend, while an end-to-end test job uses the root compose.yaml with all include entries. The same files therefore serve both focused unit test runs and full integration tests, without duplication.
Also important for CI: relative paths in include are always resolved relative to the file that contains the include entry, not relative to the CI runner's current working directory. If the repository is checked out into a different folder, the paths remain stable as long as the relative structure between the files is preserved, a detail that quickly breaks pipelines when absolute paths are used instead.
# Test only the backend part in isolation
docker compose -f services/api-group.yaml up -d --wait
docker compose -f services/api-group.yaml exec api npm test
docker compose -f services/api-group.yaml down -v
# Full project for end-to-end tests
docker compose up -d --wait
docker compose exec e2e-runner npm run test:e2e
9. Decision guide: extends, include, or multiple -f files
Choosing the right mechanism depends on the specific reuse goal. For closely related services sharing a common base, such as several worker variants of the same image, extends is the most precise solution. For self-contained but related sub-projects that can also be developed and tested separately, include is preferable. For environment variants of the same services, such as production versus local development with extra debug ports, multiple -f files or an automatic compose.override.yaml remain the right choice.
Anyone unsure should start with the simplest solution and only modularize once duplication actually appears. Splitting prematurely into many small files with complex extends and include chains often makes a small project harder to understand than a single, well-structured file with clear comments. Modularity is a remedy for duplication, not a goal in itself.
| Mechanism | Level of effect | Typical use | Merge behavior |
|---|---|---|---|
extends |
Individual service | Related services sharing a base | Lists merged, scalars overridden |
include |
Entire Compose file | Combining self-contained sub-projects | Additive, no overriding of matching keys |
Multiple -f flags |
Entire Compose file | Environment variants of the same services | Overriding plus list merge |
compose.override.yaml |
Entire Compose file (implicit) | Local developer adjustments | Loaded automatically, like -f |
Mironsoft
Container infrastructure, CI pipelines and deployment automation
Docker setups that hold up across the team and in production?
We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.
Dockerfile Review
Systematically optimizing multi-stage builds, layer caching and image size.
Security Audit
Hardening container isolation, secrets handling and image scanning against real attack surfaces.
CI/CD Integration
Building build pipelines, registries and deployment strategies for reproducible releases.
10. Summary
Compose extends and include: Key Takeaways
extends
Inherits individual service definitions, does not support depends_on or links from the base.
include
Pulls entire, self-contained Compose files additively into a shared project.
Vs. multiple -f
include composes distinct services, -f overrides variants of the same services.
Rule of thumb
Modularize only once real duplication appears, do not split preemptively.