CI runs docker compose -f compose.yaml -f compose.ci.yaml up and the API still mounts the source directory and runs the dev server with hot reload, because compose.override.yaml — meant for laptops only — was also picked up; or a CI override that sets ports: [] to avoid collisions leaves the local ports in place, because Compose appends lists instead of replacing them. Override files are the standard way to share one base definition between local development and CI, but their merge rules surprise almost everyone once. This page lays out a base-plus-overrides structure and shows exactly how each field merges, as part of Compose profiles and targeted environments.

The goal: compose.yaml describes what every environment shares, and each override adds only what differs, with the merged result checked rather than assumed.

Diagnostic

Print the merged configuration each environment actually gets, and diff them:

#!/usr/bin/env bash
set -euo pipefail
ls compose*.y*ml
docker compose config --format json > /tmp/local.json
docker compose -f compose.yaml -f compose.ci.yaml config --format json > /tmp/ci.json
jq '.services.api | {command, volumes: [.volumes[]?.source], ports: [.ports[]?.published]}' /tmp/local.json
jq '.services.api | {command, volumes: [.volumes[]?.source], ports: [.ports[]?.published]}' /tmp/ci.json

Expected bad output:

compose.ci.yaml  compose.override.yaml  compose.yaml
{ "command": ["npm","run","dev"], "volumes": ["/home/dev/shop/api"], "ports": ["3000"] }
{ "command": ["npm","run","dev"], "volumes": ["/home/runner/work/shop/api"], "ports": ["3000","3001"] }

The CI merge still has the dev server command and the source bind mount — so the CI override did not replace them — and its port list contains both the base port and the CI port.

How the Files Layer Layers of Compose files from the shared base through local and CI overrides to the merged result. How the Files Layer compose.yaml shared base, all envs compose.override.yaml local only, auto-loaded compose.ci.yaml CI only, explicit -f merged config docker compose config
Only one override applies per environment; the base holds everything shared.

Root cause

Compose merges files in order, and each field type has its own rule. Single-value fields (image, command, entrypoint, healthcheck.test) are replaced by later files. Maps (environment, labels) are merged key by key. Lists of "multi-value" fields — ports, expose, dns, volumes (merged by target path), devices — are combined, so an override cannot remove a published port by listing different ones. And compose.override.yaml is loaded automatically only when no -f flag is given; the moment CI passes -f compose.yaml -f compose.ci.yaml, the local override is excluded. In the diagnostic above, the dev command and bind mount came from the base file, where someone put local-only settings; the CI override never set a command, so nothing replaced it. The structural mistake is putting local conveniences in the base.

Environment variables add one more wrinkle. Values in environment: merge key by key, but values interpolated from .env or the shell are resolved before merging, and env_file: entries are lists that append. A CI override that sets NODE_ENV: test wins over the base's NODE_ENV: production, while an env_file added in CI is loaded in addition to — not instead of — any the base declares. When a variable has an unexpected value in one environment, the merged docker compose config output shows the final value and which mechanism set it far faster than reading the files side by side.

Resolution

  1. Keep the base environment-neutral. Production-like image, command and healthcheck; no source mounts, no published ports, no dev servers:
services:
  api:
    build:
      context: ./api
      target: runtime
    command: ["node", "dist/server.js"]
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://postgres:postgres@db:5432/shop
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 5s
      retries: 20
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16.4
    environment:
      POSTGRES_PASSWORD: postgres
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 3s
  1. Put local conveniences in compose.override.yaml, which laptops load automatically:
services:
  api:
    build:
      target: dev
    command: ["npm", "run", "dev"]
    environment:
      NODE_ENV: development
    volumes:
      - ./api:/app
      - /app/node_modules
    ports:
      - "127.0.0.1:3000:3000"
  db:
    ports:
      - "127.0.0.1:5432:5432"
  1. Put CI specifics in compose.ci.yaml, loaded explicitly. Use !reset to remove inherited values and !override to replace a list wholesale (Compose 2.24+):
services:
  api:
    environment:
      CI: "true"
    ports: !reset []
  db:
    tmpfs:
      - /var/lib/postgresql/data
    ports: !reset []

tmpfs for the database makes CI runs faster and guarantees a clean state. !reset [] clears any ports inherited from the base, which list merging alone cannot do.

  1. Encode the file sets in scripts so nobody types -f lists by hand:
#!/usr/bin/env bash
set -euo pipefail
case "${1:-local}" in
  local) exec docker compose "${@:2}" ;;
  ci)    exec docker compose -f compose.yaml -f compose.ci.yaml "${@:2}" ;;
esac
Compose Merge Rules by Field Table of how common Compose fields merge when a later file sets them. Compose Merge Rules by Field Field Later file Remove with image, command replaces set a new value environment, labels merges keys set key to empty ports, expose appends !reset [] volumes merges by target !reset or !override
Knowing the rule per field explains most override surprises.

Expected output

$ docker compose config --format json | jq -c '.services.api | {command, ports: [.ports[]?.published]}'
{"command":["npm","run","dev"],"ports":["3000"]}
$ ./scripts/compose.sh ci config --format json | jq -c '.services.api | {command, ports: [.ports[]?.published], volumes: [.volumes[]?.source]}'
{"command":["node","dist/server.js"],"ports":[],"volumes":[]}

Locally, the dev server runs with a bind mount and published port. In CI, the production command runs with no mounts and no published ports, from the same base definition.

The diff between the two merged outputs is now small and intentional: command, build target, mounts, ports and a couple of environment variables. Everything else — images, healthchecks, dependencies, networks — is identical, which is the parity the layering exists to protect. Reviewing that diff whenever a Compose file changes is a quick way to spot a local convenience leaking into the base.

Prevention

  1. Diff merged configs in CI. Render both environments with docker compose config and fail if the CI render contains bind mounts of the source tree or published ports; those are always local-only.
#!/usr/bin/env bash
set -euo pipefail
./scripts/compose.sh ci config --format json \
  | jq -e '[.services[] | (.ports // []), ([.volumes[]? | select(.type == "bind")])] | flatten | length == 0' >/dev/null \
  && echo "CI config has no ports or bind mounts" || { echo "local-only settings leaked into CI"; exit 1; }
  1. Review the base file as production-like. A pull request that adds a volumes: bind mount or ports: to compose.yaml is almost always a local convenience in the wrong file.

  2. Require Compose 2.24+ in the toolchain file, so !reset and !override are available everywhere.

Where Does This Setting Belong? Decision diagram placing a Compose setting in the base, the local override or the CI override. Where Does This Setting Belong? Would production run with this setting? yes compose.yaml base laptops only compose.override.yaml CI only compose.ci.yaml
If production would not have it, it does not belong in the base file.

Platform caveats

macOS (Docker Desktop): bind mounts in compose.override.yaml are where macOS file-sharing performance matters; the anonymous /app/node_modules volume keeps dependencies off the bind mount, as described in speeding up node_modules bind mounts on macOS.

WSL2: relative bind-mount paths resolve from the Compose file's directory; keep the project in the Linux filesystem so the local override's mounts are fast.

Apple Silicon (ARM64): build targets in overrides inherit the base's platform setting; if CI builds amd64 and laptops arm64, set platform only in the CI override.

Rollback

Restore the previous single-file setup from git; merged behaviour returns to what it was:

#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- compose.yaml compose.override.yaml compose.ci.yaml
docker compose config >/dev/null && echo "restored configuration is valid"

Frequently Asked Questions

Why is compose.override.yaml ignored in CI?

Compose loads it automatically only when no -f flags are passed. As soon as CI specifies files explicitly, only those files are merged. That is usually what you want; make sure nothing CI needs lives only in the override.

How do I remove a port or volume inherited from the base file?

List merging cannot remove entries. Use !reset [] on the field in the later file (Compose 2.24+), or move the entry out of the base into the local override where it belongs.

Should I use profiles or override files?

They solve different problems. Profiles choose which services start; override files change how a service is configured per environment. Most projects use both.

How can I see the final configuration Compose will use?

Run docker compose config with the same flags you use for up. It prints the fully merged, interpolated configuration, which is the only reliable way to check override behaviour.