Three repositories that all need the same Postgres and Redis stack have each copy-pasted the compose file, and the redis image tag now reads 7.2 in one repo, 6.2 in the next, and 7.0 in the third because a bump landed in one and never propagated. This page shows how to keep one base compose file as the single source of truth and pull it into every repo with include and extends, layering per-repo overrides on top. It builds on the resolution model described in Compose Profiles and Targeted Environments; if you are still deciding which services belong in a shared baseline versus a per-repo concern, read that parent topic first.

The failure here is drift, not a crash. Every repo boots, every test passes locally, and the divergence only surfaces when a query behaves differently against Redis 6.2 than it did against 7.2 on a teammate's laptop. Because the copies are independent files, nothing forces them back into agreement — the fix is to stop copying and start referencing.

Diagnostic

Confirm the drift is real before changing anything. Render each repo's resolved configuration with docker compose config and pull out the image each is actually pinning, rather than trusting the tag you remember writing. config expands every variable and merge, so it reports what Compose would launch, not what the raw YAML appears to say.

#!/usr/bin/env bash
set -euo pipefail
# Compare the resolved redis image across sibling repos
for repo in ../checkout-service ../reporting-service ../billing-service; do
  printf '%-24s ' "$(basename "$repo"):"
  docker compose -f "$repo/compose.yaml" config 2>/dev/null \
    | awk '/^  cache:/{f=1} f && /image:/{print $2; exit}'
done
# BAD: three repos, three different cache images
checkout-service:        redis:7.2
reporting-service:       redis:6.2
billing-service:         redis:7.0

A second check quantifies the maintenance tax. Count how many distinct compose files declare the same service across the tree — every one of them is a place a version bump has to be repeated, and every one is a place it can be forgotten.

#!/usr/bin/env bash
set -euo pipefail
# How many files would a single redis bump have to touch?
grep -rl --include='compose*.y*ml' -E '^\s+image:\s+redis' .. | sort -u | wc -l

The number this prints is the real cost. When it is greater than one, a single-line dependency change is actually an N-line change spread across N repositories, and the probability that all N stay in sync trends to zero over time.

Edits required to bump one image tag Bar chart comparing how many files a redis version bump must touch under copy-paste versus a shared base file. Files to Edit for One Image Bump copied per repo 6 shared submodule 1 OCI include 1
A six-repo estate collapses from six edits to one once the base file is referenced instead of copied.

Root cause

Each repo owns a full, independent copy of the service definitions. There is no reference between them, so a change in one file has no path to reach the others. The versions agree only for as long as nobody touches any copy; the first bump breaks the invariant permanently. Compose gives you two mechanisms to replace the copy with a reference, and choosing between them is the whole design decision.

include pulls an entire external compose file into your project as if its services were written inline. You get every service the base defines — db, cache, and anything else — and your own file adds services and merges overrides on top. extends is narrower: a single service in your file inherits the definition of one named service from another file, and you tweak the fields you care about. include is the right tool when a repo wants the whole shared stack; extends fits when a repo wants exactly one shared service and nothing else.

The two also differ in how they treat the base file's own context. include resolves the base as a self-contained sub-project: its env_file, its named volumes, and its depends_on graph come along intact, and Compose validates that nothing in the base collides with what your file already declares. extends copies only the fields of the one service you name and evaluates them in your file's context, which is why cross-service references like depends_on are stripped — they would dangle. Keeping that distinction in mind prevents the most common surprise, where a service imported with extends silently loses the readiness ordering it had in the base and races its database on startup.

include versus extends Comparison of the include directive, which imports a whole file, against extends, which inherits one service. include vs extends include imports the whole file all services appear depends_on preserved merge by service name use for the full stack extends inherits one service you pick which depends_on dropped override fields inline use for one service
Pick include when a repo needs the whole baseline; pick extends when it needs a single shared service.

Resolution

The plan is to publish the base once, vendor it into each repo, reference it, then layer repo-specific concerns as overrides. A git submodule is the most portable vendoring mechanism — it works on every CI runner and every laptop without a registry login — so the steps below use one; the FAQ covers pulling the base straight from a registry when you prefer that.

The ordering of these steps matters. The base must be self-contained and version-tagged before any consumer references it, because a repo that pins a tag inherits exactly the file at that commit and nothing later. Publishing the base first, then vendoring, then referencing, then overriding gives you a clean layering where each stage can only add to or narrow the one below it — never reach back and mutate a shared definition by accident.

1. Author the base compose file

Create a small repository — call it platform-base — holding one file that defines the shared services and nothing else. Parameterize every host-visible value through an environment variable with a default so two repos can run side by side without colliding, the same way you would parameterize a host port to avoid an allocation clash.

name: platform-base

services:
  db:
    image: postgres:16.3
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-app}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-devpass}
      POSTGRES_DB: ${POSTGRES_DB:-app}
    ports:
      - "${DB_PORT:-5432}:5432"
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app}"]
      interval: 5s
      timeout: 3s
      retries: 10

  cache:
    image: redis:7.2
    command: ["redis-server", "--save", ""]
    ports:
      - "${REDIS_PORT:-6379}:6379"

volumes:
  db-data:

2. Vendor the base into each repo

Add platform-base as a submodule pinned to a tag rather than a moving branch, so every repo advances its baseline deliberately.

#!/usr/bin/env bash
set -euo pipefail
# Run inside each consuming repo
git submodule add -b main \
  [email protected]:acme/platform-base.git platform-base
git -C platform-base checkout v1.4.0
git add platform-base .gitmodules
git commit -m "Vendor platform-base v1.4.0 as compose baseline"

3. Reference the base with include

In the repo's compose.yaml, pull the whole baseline in with include, then add only the service this repo owns. The included services (db, cache) are available for depends_on exactly as if you had written them inline.

name: checkout-service

include:
  - path: ./platform-base/base.compose.yaml
    env_file: ./platform-base/.env.defaults

services:
  app:
    build: .
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-devpass}@db:5432/${POSTGRES_DB:-app}
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    ports:
      - "${APP_PORT:-3000}:3000"

If a repo needs only the database and not the cache, skip include and pull the single service with extends instead — it inherits db and lets you override the port and database name locally without importing cache at all.

name: reporting-service

services:
  db:
    extends:
      file: ./platform-base/base.compose.yaml
      service: db
    ports:
      - "${DB_PORT:-5544}:5432"
    environment:
      POSTGRES_DB: reporting

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "8080:8080"
Base file merged into a repo project Flow from the shared base file through include and a per-repo override into one resolved project. How the Merge Resolves base.compose.yaml db + cache compose.yaml include + app resolved project db + cache + app override files merge last, by service name
include imports the base services; the repo adds app; docker compose config emits one merged project.

4. Layer per-repo overrides

Anything a single engineer or a single repo needs to change without touching the shared base goes in a compose.override.yaml, which Compose merges automatically on top of both the included base and the repo file. Bind mounts for hot reload, a debug log level, or a locally exposed port all belong here.

services:
  app:
    volumes:
      - ./src:/app/src
    environment:
      LOG_LEVEL: debug
  cache:
    ports:
      - "6380:6379"

Deciding where a change belongs is mechanical once you internalize the rule: shared and permanent goes in the base; repo-specific and permanent goes in compose.yaml; local and disposable goes in the override.

Where does a change belong Decision path routing a change to the base file, the repo compose file, or the local override. Where Does a Change Belong? Shared by all repos? and permanent? Yes edit base.compose.yaml One repo only edit compose.yaml Local / disposable compose.override.yaml
Route every change by scope: shared to the base, repo-specific to compose.yaml, local to the override.

5. Verify the merged result

Never trust the layering by eye. Render the fully merged project and confirm the services, images, and dependencies are what you expect before launching.

#!/usr/bin/env bash
set -euo pipefail
# Validate that base + repo + override merge cleanly
docker compose config -q && echo "merge is valid"
docker compose config --services
docker compose config | awk '/^  (db|cache):/{svc=$1} /image:/{print svc, $2}'

Expected output

A clean merge lists the base services alongside the repo's own service, and each image resolves to the version pinned in the one base file. Every repo that references the same submodule tag prints identical image lines.

merge is valid
app
cache
db
db: postgres:16.3
cache: redis:7.2

Because cache now resolves to redis:7.2 in every repo that vendors platform-base v1.4.0, the diagnostic loop from the top of this page prints the same tag across the whole estate instead of three different ones.

Prevention

Stopping the drift from returning is a matter of pinning the reference and validating it in CI, so a repo cannot silently fall behind or import a broken base.

  1. Pin the submodule to a tag, never a branch. A tag advances only when someone runs git -C platform-base checkout vX.Y.Z and commits it, which makes the baseline version a reviewable change in each repo's history rather than an invisible drift.
  2. Fail CI when the merged config is invalid. Add docker compose config -q as a required check so a base that breaks a consumer is caught in that repo's pipeline, not on a laptop. This is the same parity discipline described in running a targeted subset of services.
  3. Update the base image tags in one place. Point Renovate or Dependabot at platform-base alone; a single merged bump there becomes a one-line submodule advance in each consumer instead of an edit fanned across every repo.
# .github/workflows/compose-check.yml
name: compose-config
on: [push, pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - name: Validate merged compose config
        run: docker compose config -q

Platform caveats

WSL2: include and extends resolve relative paths from the project directory, so keep the base inside the repo tree via the submodule rather than referencing a Windows path like C:\shared\base.yaml. A backslash path fails to resolve under the Linux VM; always use forward-slash relative paths such as ./platform-base/base.compose.yaml.

macOS (Docker Desktop): if you place the base outside the repo and reference it with ../platform-base, that parent directory must fall inside a configured file-sharing path, or the bind for any relative volume in the base will fail with a mount error. Vendoring the base as a submodule inside the repo sidesteps the file-sharing list entirely.

Apple Silicon (ARM64): pin base images that publish multi-arch manifests, or the shared db/cache services pull an amd64-only layer and fall back to emulation. If a base image is single-arch, add platform: linux/arm64 in the repo override rather than editing the base, so x86 CI runners still get the native image. See fixing exec format errors on Apple Silicon for the underlying mechanism.

Rollback

If a base version regresses a consumer, revert that one repo to the previous tag without touching the base repository or any other consumer:

#!/usr/bin/env bash
set -euo pipefail
git -C platform-base checkout v1.3.0
git add platform-base
git commit -m "Roll platform-base back to v1.3.0 in this repo"
docker compose config -q && echo "rolled back and still valid"

To abandon the shared-file approach entirely and return to a self-contained file, inline the base with docker compose config > compose.yaml (which flattens the merge into a single static file), then git submodule deinit -f platform-base.

Frequently Asked Questions

What is the difference between include and extends?

include imports an entire external compose file into your project, so every service it defines becomes available and is merged by service name. extends inherits a single named service from another file and lets you override its fields inline. Use include when a repo wants the whole shared stack; use extends when it wants exactly one shared service and nothing else.

Can include pull a base file from a registry instead of a submodule?

Yes. Recent Compose can load a file published as an OCI artifact — reference it as oci://registry.example.com/platform-base:1.4.0 in the include list or through COMPOSE_FILE. That removes the submodule but requires every laptop and CI runner to authenticate to the registry, so a git submodule is often more portable for local development. Pin an immutable tag or digest either way.

Why did my depends_on disappear when I used extends?

extends deliberately does not copy depends_on, volumes_from, or links, because those reference other services that may not exist in the extending file. Redeclare the dependency in the service that uses extends, or switch to include, which imports the whole file and preserves depends_on as written in the base.

When two layers set the same field, does Compose append or replace?

Scalars and mappings from a later layer replace or deep-merge with earlier ones, but most sequences — ports, volumes, environment in list form — are concatenated rather than replaced. That means an override adding a port appends to the base's ports instead of overwriting them; use !reset or map-form environment when you need to replace a value rather than add to it. Always confirm with docker compose config.