Skip to content
shellhacks.dev
Docker

How to Use Compose Profiles Overrides and Extends Fast

Manage Compose variants with profiles, override files, multiple -f files and include: what merges, what replaces, and how to see the final result.

6 min read
Summarize in AI
Illustration for "How to Use Compose Profiles Overrides and Extends Fast": a terminal session, progress bars, environment variables.

One stack in development, a different one in production, an optional debugger in between: that is the shape of most Compose projects. Duplicating the file invites drift, so Compose provides merging instead. The tools differ in how explicit they are, and each has a merge rule that surprises. This post builds on the Compose tutorial. Behavior was checked with Compose 2.30.

Profiles for optional services

compose.yamlYAML
services:
  web:
    build: .
  adminer:
    image: adminer:4
    profiles: ["debug"]
    ports: ["127.0.0.1:8081:8080"]
Shell
docker compose up -d                       # web only
docker compose --profile debug up -d       # web and adminer
COMPOSE_PROFILES=debug docker compose up -d

Services without a profiles key always start. Naming a profiled service on the command line starts it anyway, as in docker compose run adminer. Profiles suit tools, test runners and seeders that most developers do not want every day.

The override file

compose.override.yamlYAML
services:
  web:
    volumes:
      - ./src:/app/src
    environment:
      DEBUG: "1"

If compose.override.yaml exists next to compose.yaml, Compose merges it without being asked, so a development laptop gets live-mounted source while the base file stays production-shaped. The risk is the same: on a server, a stray override file changes the deployment. In production, name the files explicitly.

Explicit files

Shell
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml config

Using -f disables the automatic override. Later files win. The merge follows rules: a mapping like environment merges key by key, with the later value replacing the earlier one; a sequence like ports or volumes appends, so a port in the base file stays published when the override adds another. To replace a list entirely, tag it in the later file:

YAML
services:
  web:
    ports: !override
      - "127.0.0.1:80:8000"
    environment: !reset null

!override and !reset need a recent Compose 2.x release. Check yours with docker compose version.

Include and extends

YAML
include:
  - path: ./monitoring/compose.yaml
services:
  web:
    extends:
      file: common.yaml
      service: app-base

include pulls in another Compose file as a self-contained application model, with its own relative paths, which fits shared components such as a monitoring stack. extends reuses one service definition. Use include for whole pieces and extends for a shared service shape; do not reach for either when a variable with a default would do.

Where this does not apply

Merging is hard to read when three files interact, so keep the layers few: a base, one development override, one production file. For different values rather than different structure, environment variables with defaults (${TAG:-latest}) are simpler, as in Docker environment variables. Always run config before deploy, and when something is missing, check the startup conditions.

A final recommendation: keep the base file runnable on its own with no override, so that a new teammate or a CI job can start it with one command, and treat the override as sugar. If the base only works with the override applied, the two files are really one file in disguise, and the split is hiding that.

When to use profiles

A profile marks a service as optional. A service with no profiles key always starts, and one with a profile starts only when that profile is activated with --profile name or the COMPOSE_PROFILES variable. That keeps a single file useful for several jobs: the application and database run by default, while a debugging tool, a mail catcher, a test runner or a one-off seed job sits behind its own profile. Anyone who runs docker compose up gets the lean stack, and the extras are one flag away. Naming a service directly on the command line starts it even when its profile is not active, which is handy for one-shot jobs such as docker compose run seed.

Profiles are not a security boundary or a way to model environments. A service in a profile still appears in the file, and a service that depends on a profiled service must be in a profile too, or Compose will report the dependency as undefined. Keep the use simple.

How merging overrides work

When you run docker compose up with no -f options, Compose reads the base file and merges compose.override.yaml on top if it exists. That makes the override the place for developer settings such as published ports, bind mounts of source code and debug flags, while the base stays close to what production runs. The merge rules are specific: single values such as image are replaced, mappings such as environment are merged key by key, and lists such as ports and volumes are appended. The append rule surprises people who wanted a replacement and end up with both ports. Tags such as !reset and !override in recent Compose versions let a later file clear or replace a list and not extend it.

View the merged result with docker compose config, and compare it with what you expected. Reading the merged output is faster than reasoning about the rules, and it catches the append surprise immediately.

Explicit files, include and extends

Passing files with -f in order, for example the base and a production file, replaces the implicit override. Setting COMPOSE_FILE in the environment does the same for a whole shell session, with a path separator between names. For large projects, the include element pulls in another Compose file as a unit with its own project directory, which suits teams that own separate services. The older extends element reuses a service definition from another file, with a restriction that it does not carry over dependencies between services. Choose the lightest tool: an override for local tweaks, an explicit second file for another environment, and include for modules owned by different teams. Relative paths in an included or extended file resolve against that file's own directory, which prevents a class of mistakes when files live in different folders.

Habits that keep layered files readable

Layered configuration can turn into puzzles if each file edits the others. Keep the base file complete enough to run and the extras small. Give each override one purpose, such as local development, tests or a debugging session, and name the file for it. Always check the merged output with docker compose config, and when a value looks wrong, read the merged file to see which layer set it. Put environment-specific values in environment files or variables, and not in new copies of the whole service, since duplicated definitions drift apart over time.

Test every combination you ship. A profile that has not been started since a change to the base file may reference a variable or network that no longer exists, and the error appears when someone needs the profile. A CI job that runs config for each file set and profile catches that early without starting any containers. Document the supported combinations in the readme, with the exact commands, so nobody has to deduce them from the file names.

See using profiles and merging Compose files.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.