DevTools Hub

Search tools

Search for a developer tool

Docker Compose Environment Variables Explained

Part of the Docker Toolkit

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 .env file — read by the Compose CLI for interpolation only: resolving ${VAR} tokens anywhere in docker-compose.yml as plain text substitution, before Compose even parses the YAML. It lives next to the compose file (or wherever --env-file points) 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_VAR with no =, meaning "whatever the shell running docker compose currently 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.
Project .env file
interpolation only
${DB_HOST} in docker-compose.ymlsubstituted while parsing
Inside the running containernot present, automatically
environment: / env_file:
service keys
Inside the running containerpresent at runtime

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:

SyntaxBehavior
${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: overrides env_file: for the same key on the same service — if both set DEBUG, the inline environment: value wins.
  • Both environment: and env_file: override whatever the image's Dockerfile set with ENV — 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 .env file — PORT=4000 docker compose up wins over PORT=3000 sitting 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.

Related tools