Skip to content
Blog

Multi-File Compose: The Base-Plus-Override Pattern

How docker compose -f base.yml -f override.yml merges files, the automatic compose.override.yml convention, and patterns for dev/staging/prod configs.

Published on • October 15, 2026

AI Assistant

One compose.yaml per environment is how most teams start, and it is how most teams end up with four drifted copies of the same stack. Docker Compose has a built-in answer: keep one canonical base file and layer small, single-purpose overrides on top of it. The merge engine is simple enough to hold in your head, but a few of its rules — especially path resolution and list concatenation — will bite you if you only learn them in production.

The default two-file convention

If you run docker compose up with no flags, Compose looks in the working directory (and its parent directories) for two files:

  • compose.yaml — the base configuration, by convention the canonical description of your services
  • compose.override.yaml — an optional file with overrides for existing services or entirely new services

If both exist at the same directory level, Compose combines them automatically. That is the whole “base-plus-override” trick for local development: the base file says what the app is, the override says what it looks like on my laptop. Nobody has to remember a magic command.

The legacy names (docker-compose.yml, docker-compose.override.yml) are still recognized, but new projects should use the compose.* naming.

Stacking files explicitly with -f

To use differently named overrides, or more than one, you pass -f for each file. Compose merges files in the order they are specified on the command line: subsequent files may merge, override, or add to their predecessors.

$ docker compose -f compose.yaml -f compose.prod.yaml up -d

The important corollary: as soon as you pass -f yourself, the automatic compose.override.yaml is no longer picked up. The command above deploys base + prod only — your local dev overrides (bind mounts, debug ports, DEBUG=true) are silently excluded. That is usually exactly what you want in production, and occasionally a nasty surprise when you meant “base, prod, and my tweaks”.

Two conveniences worth knowing:

  • COMPOSE_FILE lets you set the list of files once in the environment instead of typing -f repeatedly. Paths are separated by the platform path separator (: on Linux/macOS, ; on Windows).
  • -f - reads the configuration from stdin, which is handy for ephemeral CI tweaks. When you use stdin, all paths resolve relative to the current working directory.

The merge rules you actually need

Compose copies configuration from the earlier (original) service onto the later (local) one. When a key exists in both, the behavior depends on the kind of value:

Single-value options replace. image, command, mem_limit, entrypoint and similar scalars: the new value wins outright.

Some multi-value options concatenate. For ports, expose, external_links, dns, dns_search, and tmpfs, Compose concatenates both sets. So an override can add a published port, but it cannot delete one the base already declared — the result is the union of both lists.

Keyed mappings merge by key. For environment and labels, the variable or label name decides which value is used:

# base
environment:
  - FOO=original
  - BAR=original
# override
environment:
  - BAR=local
  - BAZ=local
# result: FOO=original, BAR=local, BAZ=local

volumes and devices merge by container mount path. This is the one that saves you: to swap the host directory behind a bind mount in an override, you redeclare the same target path with a different source. The base’s other mounts survive untouched.

# base
volumes:
  - ./original:/foo
  - ./original:/bar
# override
volumes:
  - ./local:/bar
  - ./local:/baz
# result: ./original:/foo, ./local:/bar, ./local:/baz

The path resolution trap

This is the rule people miss. When merging files, all relative paths in every file are resolved relative to the base file — the first file passed to -f, or compose.yaml in the current directory. Paths in an override are not resolved relative to that override’s own location.

That makes sense for a fragment that lives next to the base file, and it breaks down in a monorepo where each team keeps its own Compose file in a subdirectory and passes them all as overrides. Those files’ build contexts, env_file references, and bind mounts will resolve against the wrong directory.

The Compose docs are explicit about the fix: if you need each file’s paths to resolve relative to its own location, use the top-level include element instead of merging with -f. Each included file is loaded with its own project directory.

Before you commit anything, run docker compose config — it prints the merged configuration and makes path mistakes obvious in ten seconds instead of at deploy time.

A practical dev / staging / prod layout

Start with the canonical stack:

# compose.yaml — base
services:
  web:
    image: example/my_web_app:latest
    depends_on: [db, cache]
  db:
    image: postgres:18
  cache:
    image: redis:latest

Your local development override is picked up automatically on docker compose up:

# compose.override.yaml — dev
services:
  web:
    build: .
    volumes: ['.:/code']
    ports: ['8883:80']
    environment: { DEBUG: 'true' }
  db:
    command: '-d'
    ports: ['5432:5432']
  cache:
    ports: ['6379:6379']

And the production-shaped override lives in its own file — possibly its own repository, possibly owned by a different team:

# compose.prod.yaml
services:
  web:
    ports: ['80:80']
    environment: { PRODUCTION: 'true' }
  cache:
    environment: { TTL: '500' }

Deploying is then a one-liner, with no sed on YAML and no environment-specific copies of the base:

$ docker compose -f compose.yaml -f compose.prod.yaml up -d

For staging, add compose.staging.yaml and stack it after the base. Because merges happen left to right, you can also stack multiple overrides (-f compose.yaml -f compose.prod.yaml -f compose.region.yaml), which works well for genuinely orthogonal deltas — but keep the stack short. Past three files, reading docker compose config output becomes the only way to know what will actually run.

Gotchas and pitfalls

  • Explicit -f disables the automatic override. Understand this before you rely on dev tweaks being present.
  • Concatenation is not replacement. You cannot remove a port, an expose, or a DNS entry from the base via an override — you can only add. Plan your base file so it declares the minimum.
  • Relative paths resolve against the base file, not the override’s location. Use include when files live in different directories, and always sanity-check with docker compose config.
  • Override files do not have to be valid standalone Compose files. They can be tiny fragments containing one service’s environment block. That is a feature, but it means the override alone tells you nothing — review the merged result, not the file.
  • Secrets do not belong in any of these files. Keep real values out of Git and inject them with .env interpolation or an external secret store; overrides are for shape, not for credentials.
  • Environment files are not Compose files. Overriding environment: keys changes the container env; it does not change which .env file is interpolated for ${VARIABLE} substitution in the base file.
  • Order is semantic. -f a -f b and -f b -f a are different configurations. Put the base first, always; it keeps path resolution sane and matches how the docs describe the model.

Sources