Resolving Env Precedence Conflicts Across Compose Files
A service reads LOG_LEVEL=debug from your .env, but the running container reports info — and you cannot tell which of the shell, env_file, environment:, or a second -f override won. This guide is part of dotenv and configuration management within the environment sync, secrets and CI parity baseline, and it pairs closely with multi-service orchestration with Compose where multiple -f files are the norm.
The confusion is almost never a Docker bug. It is that Docker Compose reads the same variable name from as many as five distinct places, each with its own priority, and two of those places do completely different jobs that share identical syntax. Once you can see the merged result and name the layer that won, the fix is a one-line edit. This page shows the exact diagnostic command, explains the precedence rules that produce the surprise, and gives a repeatable resolution you can enforce in CI so the drift never comes back.
Diagnostic
Stop guessing and ask Compose to show the fully merged result. docker compose config resolves every source — .env interpolation, env_file:, inline environment:, and every -f overlay — and prints the final, authoritative document that the daemon will actually use.
#!/usr/bin/env bash
set -euo pipefail
export LOG_LEVEL=warn # a stray shell export
docker compose -f docker-compose.yml -f docker-compose.override.yml \
config | grep -A3 "LOG_LEVEL"
Expected BAD output (the value is not what .env said):
environment:
LOG_LEVEL: info
.env had debug, the shell exported warn, yet the merged config shows info — a hardcoded environment: entry in the override file is winning over both. The value info never appears in your .env at all; it is a literal baked into docker-compose.override.yml, and because the override is the last -f file on the command line, its environment: block sits at the top of the precedence stack.
To confirm which file supplied the literal, render the config twice — once with each -f file alone — and diff the two. The file whose solo output already contains info is the source of the override:
#!/usr/bin/env bash
set -euo pipefail
docker compose -f docker-compose.yml config | grep LOG_LEVEL || echo "not in base"
docker compose -f docker-compose.override.yml config | grep LOG_LEVEL || echo "not in override"
One subtlety trips people here: when you run bare docker compose with no -f flags, Compose auto-loads docker-compose.yml and then docker-compose.override.yml if it exists, in that order. So the reproduction you run by hand with explicit -f flags and the command your teammate runs without them can resolve to different values if their working tree is missing the override file, or if an extra COMPOSE_FILE environment variable is silently prepending another overlay. Print the actual file list Compose intends to merge before you trust any diagnostic:
#!/usr/bin/env bash
set -euo pipefail
docker compose config --services >/dev/null # validates the merge succeeds
echo "COMPOSE_FILE=${COMPOSE_FILE:-<unset, using defaults>}"
If COMPOSE_FILE is set in a shell profile or a .env, it silently changes which files merge and in what order — a frequent reason a precedence bug reproduces for one engineer and not another.
Root cause
Compose layers values from several sources with a fixed precedence, highest first: the environment: key in the last -f file wins over earlier files; an explicit environment: value wins over env_file:; a value already in the shell wins over .env only for interpolation (${VAR} substitution in the YAML), not for the container's runtime environment. The two mechanisms are easy to conflate: .env feeds ${...} interpolation in the compose files, while env_file: and environment: set variables inside the container. Multiple -f files merge in order, with later files overriding earlier ones.
The order in which -f files merge deserves its own emphasis because it is positional, not alphabetical. Compose applies the files strictly left to right as they appear on the command line, so -f a.yml -f b.yml and -f b.yml -f a.yml can produce different merged results for any key both files declare. The last file always wins ties. This is by design — it is how per-environment overlays and docker-compose.override.yml are meant to layer on top of a shared base — but it also means a habit of reordering flags between scripts quietly changes which value the container gets. Pin the order in a Makefile target or a wrapper script so every invocation merges identically.
That distinction between interpolation and injection is the crux of most precedence bugs. There are really two separate pipelines that happen to read variables of the same name. The interpolation pipeline runs first, on the host, before the container exists: it substitutes ${LOG_LEVEL} tokens inside the YAML using the shell environment first and the .env file second. The injection pipeline runs when the container starts: it copies env_file: entries and environment: entries into the process environment, with environment: overriding env_file: on a per-key basis. A shell export of LOG_LEVEL changes what ${LOG_LEVEL} interpolates to, but it does not directly reach the container unless an environment: entry references it. Conversely, a hardcoded environment: {LOG_LEVEL: info} reaches the container directly and ignores both .env and the shell entirely.
env_file: has its own internal ordering that mirrors the -f rule: when you list multiple files, later files override earlier ones key by key, and a later .env.local beats an earlier .env for any shared key. Compose reads these files verbatim — it does not expand ${...} inside an env_file, so a line like URL=${HOST}/api is passed to the container as the literal string, dollar sign and all. That asymmetry surprises people who expect env_file values to interpolate the way inline environment: values do. If you need a composed value, build it with an inline environment: entry where interpolation is active, not inside the env file.
When you write the full precedence out as an ordered stack, the resolution rule becomes mechanical: whichever source sits highest and actually declares the key is the value you get. Everything below a hardcoded environment: literal is dead weight for that key.
Resolution
- Render the merged configuration to see the authoritative value and where it comes from.
#!/usr/bin/env bash
set -euo pipefail
docker compose -f docker-compose.yml -f docker-compose.override.yml config
- Decide the single intended source. For runtime config that should follow
.env, remove the hardcodedenvironment:override and letenv_fileor interpolation supply it.
# docker-compose.yml
services:
app:
image: app:local
env_file:
- .env
environment:
# Interpolated from .env / shell; no hardcoded literal
LOG_LEVEL: ${LOG_LEVEL:-info}
# docker-compose.override.yml
services:
app:
# Override only what is genuinely environment-specific here,
# and do NOT redeclare LOG_LEVEL unless you mean to force it.
ports:
- "9229:9229"
- Distinguish interpolation from injection. To resolve
${LOG_LEVEL}from.envwithout a stray shell export winning, unset it in the shell or pass--env-fileexplicitly.
#!/usr/bin/env bash
set -euo pipefail
unset LOG_LEVEL
docker compose --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d
- Re-render after every edit and confirm the value moved to the layer you intended. Never trust the running container's report until the merged config agrees with it.
#!/usr/bin/env bash
set -euo pipefail
docker compose config | grep -A2 LOG_LEVEL
docker compose exec app printenv LOG_LEVEL
The config output and the printenv output must match. If they diverge, the container is stale — recreate it with docker compose up -d --force-recreate so the new merged environment is applied. A common trap is editing the YAML, seeing config report the right value, and forgetting that a long-running container still holds the old injected value from its last start.
Expected output
$ docker compose config | grep -A2 "LOG_LEVEL"
environment:
LOG_LEVEL: debug
The merged value now matches .env, and docker compose up injects debug into the container. Confirming from inside the running process closes the loop:
$ docker compose exec app printenv LOG_LEVEL
debug
When both commands report debug, the interpolation and injection pipelines have converged on the same source, and no hidden override remains.
For a machine-checkable assertion you can drop into a smoke test, compare the two directly and exit non-zero on any mismatch. This is the single most reliable signal that precedence is settled, because it catches both stale containers and duplicate declarations at once:
#!/usr/bin/env bash
set -euo pipefail
want=$(docker compose config | awk '/LOG_LEVEL:/ {print $2; exit}')
have=$(docker compose exec -T app printenv LOG_LEVEL)
if [ "$want" != "$have" ]; then
echo "Mismatch: merged config says '$want' but container has '$have'"; exit 1
fi
echo "Precedence settled: LOG_LEVEL=$have"
Prevention
- Commit a
docker compose confighash to CI and fail on drift, so an accidental override is caught:docker compose config --no-interpolate | sha256sum. The--no-interpolateflag keeps the hash stable across machines by leaving${...}tokens unresolved, so it detects structural changes to the compose files themselves rather than environment-specific values. - Keep a single, documented
environment:block per variable; never set the same key in two-ffiles. If you must override a value for one environment, override it in exactly one place and add a comment naming the reason, so the next reader does not re-add the base declaration. - Add a pre-commit check that greps for shell exports of app variables in developer profiles that could shadow
.env. A strayexport DATABASE_URL=...in a personal~/.zshrcis a frequent, hard-to-spot cause of "works on my machine" interpolation differences.
#!/usr/bin/env bash
set -euo pipefail
# CI guard: fail if any key is declared in more than one compose file's environment block.
dupes=$(grep -hoP '^\s{6}\K[A-Z_][A-Z0-9_]+(?=:)' \
docker-compose.yml docker-compose.override.yml | sort | uniq -d)
if [ -n "$dupes" ]; then
echo "Duplicate environment keys across compose files:"; echo "$dupes"; exit 1
fi
echo "No duplicate environment keys."
This guard belongs in the same CI stage that validates the rest of your configuration. If you already run environment variable validation to block startup on missing keys, add the duplicate-key check next to it so precedence and presence are enforced together.
Platform caveats
WSL2: Windows-set environment variables do not propagate into the WSL2 shell, so a value that interpolates on Windows may be empty in Linux; rely on
.envor--env-filerather than inherited shell state. macOS (Docker Desktop): the GUI may inject keychain-sourced variables into the daemon; use--env-fileexplicitly and avoid--envhost inheritance for reproducibility. Apple Silicon (ARM64): the precedence rules are identical, but a.envcopied from an x86 CI runner can carry a leading UTF-8 BOM that makes the first key silently fail to interpolate; strip it withsed -i '1s/^\xEF\xBB\xBF//' .envbefore debugging further.
Rollback
If a config change broke startup, restore the previous compose files and bring the stack back up cleanly:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- docker-compose.yml docker-compose.override.yml
docker compose up -d --force-recreate
The --force-recreate flag matters on rollback specifically: without it, Compose sees the reverted files as unchanged from the container's current definition in some cases and skips recreation, leaving the broken injected environment in place. Force the recreate so the restored precedence actually takes effect, then run the verification loop from the resolution one more time to confirm the merged config and the container agree before you hand the environment back to the team.
Frequently Asked Questions
Does a shell export override the container's environment: value?
No. A shell export only changes what ${VAR} interpolates to on the host, before the container starts. If an environment: entry hardcodes a literal (for example LOG_LEVEL: info), that literal is injected into the container regardless of any shell export. The shell export reaches the container only when an environment: entry explicitly references it, such as LOG_LEVEL: ${LOG_LEVEL}.
What is the difference between env_file: and the environment: key?
Both inject variables into the container, but environment: wins on a per-key basis. env_file: loads a batch of keys from a file, while environment: sets keys inline in the YAML. When the same key appears in both, the inline environment: value takes precedence. Neither of them affects ${VAR} interpolation — that job belongs to .env and the shell.
Why does docker compose config show the right value but the container has the wrong one?
The running container was created before your edit and still holds the environment it was injected with at start-up. config renders the current files, but it does not restart anything. Recreate the container with docker compose up -d --force-recreate so the merged environment is applied, then confirm with docker compose exec app printenv VAR.
How do I stop .env from interpolating when I want a literal dollar sign?
Escape it by doubling the dollar sign: $$ in a compose file renders as a literal $ and is not treated as an interpolation token. This matters for values like password strings or cron expressions that legitimately contain $. Run docker compose config afterward to confirm the literal survived unmodified.