Docker Compose extends and include: Building Modular Compose Files
AI generated
FROM
RUN
Docker · Compose · Architecture
Modular Compose Files
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.

16 min read Service inheritance Compose modularization Multi-project setups

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.

11. FAQ: Compose extends and include: Key Takeaways

1What is the main difference between extends and include?
extends inherits the configuration of a single service into another, even across files. include pulls a complete, self-contained Compose file with all its services into the current project.
2Can extends also inherit depends_on?
No, extends supports neither depends_on nor links nor volumes_from from the base definition, because those could reference services that do not exist in the base file's context. These must be set again in the inheriting service.
3When should I use include instead of multiple -f flags?
include is suited for self-contained sub-projects with distinct services, such as a frontend and backend from separate repositories. Multiple -f flags work better for variants of the same services, such as production versus local development.
4Do files included via include share the same network?
Yes, as long as the networks are named identically, all files included via include automatically belong to the same Compose project and can use the same top-level defined networks and volumes.
5How are relative paths in include resolved?
Relative paths in include are resolved relative to the file containing the include entry, not relative to the current working directory of the invocation. This keeps paths stable even if the repository is checked out into a different folder.
6Can I use extends and include together in the same project?
Yes, both can be combined. A common pattern is extends for related services within a sub-project and include to combine several such sub-projects into an overall project.
7How do I verify that an extends inheritance resolved correctly?
docker compose config shows the fully resolved configuration including all extends inheritance. You can also use docker compose config to check a single service in isolation.
8What happens if two files included via include both define a service with the same name?
Compose treats this as a configuration conflict, since include is meant to be additive and provides no automatic override logic for services with matching names across included files. Service names should therefore be unique across the whole project.
9Is extends suitable for services that differ significantly?
No, extends is primarily meant for genuinely shared base configuration. For services that differ fundamentally, extends causes more confusion than it saves, a standalone definition is usually clearer there.
10Should I preemptively modularize a small project with extends and include?
No, it is recommended to start with the simplest solution and only modularize once duplication actually appears across multiple Compose files. Splitting prematurely often makes small projects needlessly complex.