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.

Diagnostic

Stop guessing and ask Compose to show the fully merged result. docker compose config resolves every source and prints the final value.

#!/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.

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.

Resolution

  1. 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
  1. Decide the single intended source. For runtime config that should follow .env, remove the hardcoded environment: override and let env_file or 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"
  1. Distinguish interpolation from injection. To resolve ${LOG_LEVEL} from .env without a stray shell export winning, unset it in the shell or pass --env-file explicitly.
#!/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

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.

Prevention

  1. Commit a docker compose config hash to CI and fail on drift, so an accidental override is caught: docker compose config --no-interpolate | sha256sum.
  2. Keep a single, documented environment: block per variable; never set the same key in two -f files.
  3. Add a pre-commit check that greps for shell exports of app variables in developer profiles that could shadow .env.

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 .env or --env-file rather than inherited shell state. macOS (Docker Desktop): the GUI may inject keychain-sourced variables into the daemon; use --env-file explicitly and avoid --env host inheritance for reproducibility.

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