A value in the project's root .env file feels like it should just be "available" wherever the app runs — that's the whole point of an environment variable. In Docker Compose specifically, that assumption is wrong in a way that causes a real, common class of bug: that top-level .env file is read by the docker compose CLI itself, to substitute ${VAR} placeholders inside docker-compose.yml before the file is parsed. It has nothing to do with what ends up inside the container's environment unless something in the compose file explicitly puts it there.
Three mechanisms, one easily-confused name
- The project
.envfile — read by the Compose CLI for interpolation only: resolving${VAR}tokens anywhere indocker-compose.ymlas plain text substitution, before Compose even parses the YAML. It lives next to the compose file (or wherever--env-filepoints) and is never automatically copied into a running container. environment:— a key on a service that sets real runtime environment variables inside that container, either as literal values or by passing through the value of a shell variable of the same name (- SOME_VARwith no=, meaning "whatever the shell runningdocker composecurrently has").env_file:— also a key on a service, also sets real runtime environment variables inside the container, but reads them from a file instead of listing them inline.
A value only reaches the container if a service's environment: or env_file: puts it there — the top-level .env file alone never does that on its own.
The reason this bites people specifically: it's completely possible — common, even — to write environment: - DB_HOST=${DB_HOST}, which chains both mechanisms together (the project .env supplies the substitution value, then environment: is what actually puts it in the container). That combination working correctly is exactly what makes it easy to assume the .env file did the whole job by itself.
The interpolation syntax has more to it than ${VAR}
Compose supports the same parameter-expansion syntax as a POSIX shell, and the difference between the variants is a real, useful distinction most people never learn past the basic form:
| Syntax | Behavior |
|---|---|
${VAR} | Plain substitution — empty string if unset |
${VAR:-default} | Use default if VAR is unset or empty |
${VAR-default} | Use default only if VAR is completely unset — an explicitly empty value is kept |
${VAR:?error} | Fail with error if VAR is unset or empty |
${VAR?error} | Fail with error only if VAR is completely unset |
The : is the whole difference in each pair — it's the same "unset-or-empty" vs "unset-only" distinction bash parameter expansion has always had, and it's easy to reach for :- out of habit when what a config actually needs is "fail loudly if this required value is missing," which ${VAR:?must be set} does directly instead of silently falling back to a default that might not be safe.
What wins when the same variable is set more than one way
A few specific, well-defined relationships, rather than one global ranking worth memorizing as a list:
environment:overridesenv_file:for the same key on the same service — if both setDEBUG, the inlineenvironment:value wins.- Both
environment:andenv_file:override whatever the image's Dockerfile set withENV— this isn't Compose-specific, it's how container creation works at the Docker engine level: values set at container-creation time always override the image's baked-in defaults. - For interpolation specifically, a shell variable takes precedence over the same name in the project
.envfile —PORT=4000 docker compose upwins overPORT=3000sitting in.env.
Build args are a separate mechanism again
build: args: corresponds to Dockerfile ARG, and it's visible only during the image build — it does not persist into the running container's environment unless the Dockerfile explicitly promotes it with ENV SOME_VAR=$SOME_VAR after the matching ARG line. Treat this as a genuinely different lifecycle from everything above, not a variant of it.
The part worth being careful about specifically: a build arg's value gets baked into the image's layer history whether or not it ends up in the final runtime environment — anyone with access to the image can retrieve it via docker history, even if the running container never sees it as an env var. Never pass a real secret through build.args. BuildKit's secret mounts (--mount=type=secret) exist specifically to make a value available during the build without it landing in any layer at all.
Multiple compose files merge by key, they don't replace
A docker-compose.override.yml (loaded automatically alongside docker-compose.yml) or an explicit -f base.yml -f prod.yml chain merges the environment: mapping key-by-key across files, not wholesale-replaces it — a variable set in the base file and left untouched in the override stays exactly as it was; only the keys the override file actually mentions change.
Try it yourself
.env Validator catches the dotenv parsing gotchas (unquoted # truncation, duplicate keys, unclosed quotes — covered in more depth in Managing Environment Variables) in any file referenced by env_file:, and Docker Compose Validator checks the compose file itself for structural mistakes before docker compose up finds them for you.
FAQ
Does putting a value in the top-level .env file automatically make it available inside my container?
No. That file only feeds Compose's own variable substitution while it parses docker-compose.yml. Something in the compose file — typically environment: - VAR=${VAR} — has to explicitly forward the resolved value into the container for it to actually be there at runtime.
Is it safe to pass a secret through build args?
No — the value gets recorded permanently in the image's layer history and is retrievable via docker history, regardless of whether it's ever exposed as a runtime environment variable. Use a BuildKit secret mount instead for anything genuinely sensitive.
If I define the same variable in both environment: and env_file:, which one wins?
environment: — Compose applies it after env_file:, so an inline value always overrides one that came from a file, for the same service and the same key.