skip to content

With Docker Compose, a stack renders one image tag on your laptop and another in CI: which sources feed compose.yaml interpolation, and in what order do they win?

level: seniorimportance: should knowfreq 38%

answer

  1. the process environment comes first
  2. replace, not layer
  3. which directory counts as the project
  4. later env file wins a tie

basics

~20 s

Variables exported in the shell win first. Next come files passed with --env-file, later ones overriding earlier ones. Only when no --env-file is given does Compose read .env from the project directory, which defaults to the first compose file's folder.

solid answer

~40 s

Compose builds one interpolation environment before it parses `compose.yaml`. It starts with the environment of the process running `docker compose`, and those values are never overwritten. It then adds env files: the ones named with `--env-file` (or the `COMPOSE_ENV_FILES` variable), where a later file beats an earlier one, or, only if none was named, the `.env` in the **project directory** — `--project-directory` if given, otherwise the directory of the first `-f` file, otherwise the current directory. Two consequences explain most laptop-versus-CI surprises: a variable left exported in a developer's shell silently shadows `.env`, and `--env-file ci.env` *replaces* `.env` rather than layering on it, so anything only in `.env` becomes unset. `docker compose config --environment` prints what Compose actually resolved.

code

bash · 16 lines
bash
$ cat .env
APP_TAG=dev
DB_PORT=5432
$ export DB_PORT=5433        # left over from another project
$ docker compose config --environment | grep -E '^(APP_TAG|DB_PORT)='
APP_TAG=dev
DB_PORT=5433
$ cat ci.env
APP_TAG=ci-4711
$ docker compose --env-file ci.env config --environment | grep -E '^(APP_TAG|DB_PORT)='
APP_TAG=ci-4711
DB_PORT=5433
$ unset DB_PORT
$ docker compose --env-file .env --env-file ci.env config --environment | grep -E '^(APP_TAG|DB_PORT)='
APP_TAG=ci-4711
DB_PORT=5432

go deeper

for a junior

Recall that a variable exported in your shell beats the same name in .env, and that --env-file points Compose at a different file.

for a middle

Explain the full order: process environment, then --env-file files in sequence, and only without them the project directory's .env, including how the project directory is chosen.

for a senior

Show the debugging path for a CI mismatch: compare docker compose config --environment on both machines, spot shell shadowing or a replaced .env, and keep credentials in the job's environment behind a :? guard.

for a principal

Argue a repository convention for env files — example file committed, real one ignored, CI injecting through the environment — and how it keeps laptop and pipeline behaviour predictable.

## What the interpolation environment is Before Docker Compose reads a single service, it assembles a set of name–value pairs used to replace `${VAR}` placeholders in `compose.yaml`. This is the **interpolation environment**. It is built once per command, from up to three sources, and it is separate from the environment any container later receives. ## The order, highest first 1. **The process environment** — everything exported in the shell, or injected by the CI job, that runs `docker compose`. Compose loads it first and never lets a file overwrite it. 2. **Files named with `--env-file`** — the flag may be repeated, and files are read in order, so a later file overrides an earlier one for the same key. If the flag is absent, the `COMPOSE_ENV_FILES` variable (a comma-separated list) plays the same role. 3. **The default `.env`** — read only when no env file was named. It comes from the project directory. There is one more, rarely seen, layer: if that `.env` sets `COMPOSE_FILE` to a compose file in a different directory, Compose also loads the `.env` there, with lower precedence than the first one. ## `--env-file` replaces; it does not layer This is the fact most often guessed wrong: - `docker compose --env-file ci.env up` reads `ci.env` **instead of** `.env`. A key present only in `.env` is now unset. - To layer, name both: `--env-file .env --env-file ci.env`, and `ci.env` wins ties. - A relative `--env-file` path is resolved from the **current directory**, and a missing file is an error. - Setting `COMPOSE_DISABLE_ENV_FILE=true` turns off the default `.env` entirely. ## Which `.env` is "the" `.env` The default file lives in the **project directory**, chosen in this order: `--project-directory`, else the directory of the first compose file given with `-f`, else the current directory. | Invocation from the repository root | Default env file read | |---|---| | `docker compose up` (compose.yaml in root) | `./.env` | | `docker compose -f deploy/compose.yaml up` | `deploy/.env` | | `docker compose --project-directory . -f deploy/compose.yaml up` | `./.env` | | `docker compose --env-file ci.env up` | `ci.env` only | ## Three laptop-to-CI failures - **Shell shadowing.** A developer exported `DB_PORT=5433` for another project; `.env` says `5432`, yet Compose uses `5433`, because the shell always wins. - **Dropped keys.** CI adds `--env-file ci.env` to set `APP_TAG`, and every other key from `.env` disappears; placeholders without a default then warn and resolve to blank strings. - **A different project directory.** CI calls `docker compose -f deploy/compose.yaml` from the repository root, so the root `.env` a developer relies on is never read. ## Keeping secrets out of the committed file Because the process environment wins, CI does not need a `.env` at all for credentials: - Commit a `.env.example` with harmless defaults and keep the real `.env` gitignored. - Let the CI job export `DB_PASSWORD` from its secret store into the environment of the `docker compose` step. - Guard it in `compose.yaml` with `${DB_PASSWORD:?DB_PASSWORD must be set}` so a missing value fails the load. - Remember that `docker compose config` output contains the resolved value; do not print it in a shared log. ## A checklist for a CI job 1. Decide which single mechanism supplies values in CI — the job's exported environment, or an explicit `--env-file` — and write it into the job definition rather than relying on a file that happens to exist on a runner. 2. If you use `--env-file`, name every file you need, in override order, because the default `.env` is no longer read. 3. Pass `--project-directory` when the command runs from a different directory than the compose file, so the right default `.env` and relative paths apply. 4. Guard values the stack cannot run without with `${VAR:?message}`, so a missing one stops the job at load time. 5. Add a `docker compose config -q` step before `up`; it fails fast on a missing required variable. ## Proving it `docker compose config --environment` prints every variable of the interpolation environment as `KEY=value`, after all three sources are merged. Comparing its output on the laptop and in the CI job shows which source supplied a surprising value.

  • A developer says .env sets DB_PORT=5432 but Compose keeps using 5433. What do you check first?
    The shell. Run `docker compose config --environment` or `env | grep DB_PORT`: a variable exported in the process environment always beats env files for interpolation. Unsetting it, or running the command with a clean environment, makes the `.env` value apply.
  • After adding --env-file ci.env to a CI job, Compose warns that several variables are not set. Why?
    Naming an env file replaces the default `.env`, so keys that lived only in `.env` are gone. Either move them into `ci.env`, or pass both files — `--env-file .env --env-file ci.env` — so `ci.env` overrides only what it sets.
  • How should a CI job supply the database password to a Compose stack?
    Export it from the CI secret store into the environment of the `docker compose` step, reference it as `${DB_PASSWORD:?DB_PASSWORD must be set}` so absence fails the load, and keep real values out of any committed env file. Avoid echoing `docker compose config` output, which contains the resolved value.

saying these in an interview costs you the question

  • The project .env file overrides whatever is exported in the shell.
  • --env-file adds its values on top of the default .env file.
  • Compose always reads .env from the directory where the command is typed.
  • With two --env-file flags, the first file wins for a repeated key.
  • A committed .env file is a fine place for the CI database password.