Docker Compose Env Precedence: Shell, .env, environment, env_file Explained
AI generated
FROM
RUN
Docker · Compose · Configuration
Docker Compose Env Precedence
Who wins: shell, .env, environment, or env_file?

Four different sources for environment variables, one precedence order that is rarely documented well: when a value never reaches a container, or a different one shows up than expected, an unclear precedence between shell environment, the .env file, the environment block, and env_file is almost always the cause.

17 min read Compose interpolation .env file env_file vs. environment

1. Two separate mechanisms sharing one name

The most common misconception about Docker Compose is assuming there is a single pool of environment variables. In reality two entirely separate mechanisms run in parallel: interpolation of placeholders like ${VAR} inside the compose.yaml file itself, and injection of environment variables into the running container. Both use the same syntax and partially overlapping sources, but follow different rules. Anyone who conflates the two ends up chasing a bug for hours that is really just a misunderstanding.

For interpolation, Compose reads values from the shell environment and from a .env file in the project directory before the YAML file is even parsed. For container injection, the environment block and env_file inside the service definition come into play as well. A value can therefore be interpolated in the YAML file without ever reaching the container, and conversely a value can land inside the container without ever being visible to interpolation.

2. Shell environment: the strongest source for interpolation

For interpolating ${VAR} placeholders in the Compose file, the shell environment in which docker compose is invoked has the highest priority. Running export TAG=v2 before the call automatically overrides any matching entry in the .env file, no matter what is written there. This is intentional: CI pipelines and local overrides should work without editing a file.

That is exactly the most common source of confusion, though. A developer exports DB_HOST in their .bashrc for an unrelated project, forgets about it, and weeks later wonders why a brand-new Compose project suddenly points at the wrong database. Running docker compose config shows the fully resolved configuration including every interpolated value, and is the first debugging step before starting any containers.


# Check active shell variables that could collide with the project
env | grep -iE 'DB_|TAG|PORT'

# Show the fully resolved configuration (after interpolation)
docker compose config

# Extract just one specific value
docker compose config | grep -A2 'DB_HOST'

3. The .env file: silent default for interpolation

If a file named .env exists in the same directory as the docker compose invocation, Compose reads it automatically and makes every line available as an interpolation source, but only at a lower priority than the shell environment. The syntax is simple: KEY=value per line, comments with #, quotes optional but allowed. Important: this file is read exclusively by Compose itself, it is not automatically injected into the container.

A second common misunderstanding concerns the file path. Compose looks for a .env in the current working directory by default, not in the directory of the compose.yaml file, if that file is referenced via -f from a different folder. Newer Compose versions allow controlling this explicitly with --env-file, which is essential when running multiple environments, such as dev, staging, and prod, each with their own env file.


# Force a specific env file for a given environment
docker compose --env-file .env.staging up -d

# .env lives somewhere other than compose.yaml
docker compose -f deploy/compose.yaml --env-file deploy/.env.prod up -d

4. The environment block: explicit container injection

The environment block inside a service definition is the most direct way to inject a variable into the container. It can hold literal values or interpolated placeholders like ${DB_PASSWORD}, which are then resolved from shell or .env. Crucially, values in the environment block always override anything coming from env_file for the same key, regardless of the order they appear in the YAML document.

A frequently overlooked variant is the short form without a value, just - DB_PASSWORD with no equals sign. In that case Compose picks up the value from the host system's shell environment and passes it through unchanged, which is convenient for secrets that should never live in a file, but is easy to forget and results in an empty variable inside the container if the shell never had it set in the first place.


services:
  app:
    image: myapp:latest
    environment:
      NODE_ENV: production
      DB_HOST: db
      DB_PASSWORD: ${DB_PASSWORD}   # resolved from shell or .env
      # short form: pass a value straight through from the host shell
      - API_TOKEN

5. env_file: pulling whole files in as a variable source

While the environment block is meant for a handful of explicit values, env_file exists to pull in entire lists of variables from separate files, such as app.env or secrets.env. These files are never interpolated, meaning a placeholder like ${VAR} inside a file loaded via env_file stays a literal string and is not resolved, a difference that regularly leads to wrong expectations.

If several files are listed, the last file listed wins for any matching key. Combining env_file with an environment block, the environment block always has the final word, even if it appears before env_file in the YAML file. This precedence is fixed in the Compose specification and does not depend on key order in the document.


services:
  app:
    image: myapp:latest
    env_file:
      - ./config/base.env
      - ./config/local.env   # overrides values from base.env
    environment:
      # ALWAYS wins over env_file, regardless of position
      NODE_ENV: production

6. The full precedence chain in practice

Summarized, the final variable inside the container follows this order from highest to lowest priority: a value set via ENV in the Dockerfile is overridden by env_file, env_file is overridden by the environment block, and if the shell uses docker compose run -e VAR=value at container start, that value wins over everything else. For pure YAML interpolation the separate rule applies: shell environment beats the .env file.

In practice a fixed team convention pays off: secrets and confidential values come exclusively via env_file from a file that is never committed, fixed configuration values like port numbers live directly in the environment block, and project-wide defaults such as the Compose project name live in the .env file. This separation drastically reduces the number of places a value could be overridden.


# Override a single value for a one-off run
docker compose run -e DEBUG=true app npm test

# Inspect the effective environment of a running container
docker compose exec app env | sort

7. Extra factor: multiple Compose files with -f

When a project is started with several -f flags, for example docker compose -f compose.yaml -f compose.override.yaml up, scalar entries inside environment blocks follow simple override rules: the file listed later overrides matching keys from the earlier one. List entries such as env_file, however, are merged rather than replaced, unless an explicit empty array resets the list. This asymmetric merge logic between scalar and list fields is one of the subtlest traps in layered Compose setups.

A compose.override.yaml is loaded automatically by Compose whenever it sits in the same directory as the main Compose file, with no explicit -f needed. That is convenient for local developer overrides, but can cause confusion when a teammate is unaware the file exists and is silently taking effect. A quick look at docker compose config reliably surfaces such hidden overrides.

8. Systematic debugging when values look wrong

When an unexpected value shows up in a container, a three-step approach helps: first run docker compose config to see the YAML structure after interpolation. Second, check whether a .env file or shell variable exists that is unexpectedly filling the placeholder. Third, inspect the actually injected variables inside the running container with docker compose exec app env, because the interpolation result and the container environment are two different things.

A frequent edge case is an empty rather than a missing variable. If ${VAR} is interpolated and VAR is set neither in the shell nor in .env, Compose defaults to replacing the placeholder with an empty string, without any error or warning. Only the syntax ${VAR:?error message} makes Compose abort startup in a controlled way, which is strongly recommended for production-critical variables such as database credentials.


services:
  app:
    environment:
      # Aborts startup with a clear error message if unset
      DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set}
      # Fallback value if the variable is missing
      LOG_LEVEL: ${LOG_LEVEL:-info}

9. Recommendations for stable Compose configurations

Committing an .env.example file without real values, but listing every expected key, immediately shows new team members which variables exist at all. The real .env belongs in .gitignore, as does any file loaded via env_file that contains actual secrets. This separation of structure (versioned) from values (not versioned) prevents the most common cause of accidentally committed credentials.

For multiple environments, a consistent naming scheme such as .env.dev, .env.staging, .env.prod is recommended, combined with the explicit --env-file flag instead of relying on implicit auto-loading. Adding a CI pipeline check that verifies every variable referenced in the Compose file is actually set, for instance via docker compose config --quiet with error evaluation, catches missing values before they turn into empty strings in production.

Source Affects Priority Interpolates placeholders?
docker compose run -e Container environment Highest Yes, passed directly
environment block Container environment High, beats env_file Yes, from shell/.env
Shell environment (export) YAML interpolation High, beats .env N/A, is the source
env_file Container environment Low, below environment No, values stay literal
.env file YAML interpolation Low, below shell N/A, is the source
Dockerfile ENV Container environment (default) Lowest No

Mironsoft

Container infrastructure, CI pipelines and deployment automation

Docker setups that hold up across the team and in production?

We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.

Dockerfile Review

Systematically optimizing multi-stage builds, layer caching and image size.

Security Audit

Hardening container isolation, secrets handling and image scanning against real attack surfaces.

CI/CD Integration

Building build pipelines, registries and deployment strategies for reproducible releases.

10. Summary

Compose Env Precedence: Key Takeaways

Interpolation

Shell environment beats the .env file, both only affect ${VAR} placeholders in the YAML.

Container injection

The environment block beats env_file, regardless of order in the document.

Multiple -f files

Scalar values get overridden, list fields such as env_file get merged.

Debugging

docker compose config shows interpolation, docker compose exec app env shows the real container environment.

11. FAQ: Compose Env Precedence: Key Takeaways

1Why doesn't my variable from the .env file reach the container?
The .env file is only read for interpolating placeholders in compose.yaml, it is not automatically injected into the container. For a value to reach the container it must additionally be referenced in the environment block or pulled in via env_file.
2What wins if the same variable appears in both environment and env_file?
The environment block always wins, regardless of where it appears in the YAML file. This precedence is fixed in the Compose specification.
3Are placeholders inside a file loaded via env_file resolved?
No. Files loaded via env_file are never interpolated. An entry like DB_URL=${HOST} stays a literal string with the dollar sign and is not substituted.
4Where does Compose look for the .env file by default?
In the current working directory of the docker compose invocation, not necessarily in the directory of the referenced compose.yaml. When paths differ, the explicit --env-file flag helps.
5What happens if a referenced variable is set nowhere at all?
Compose defaults to replacing the placeholder with an empty string, without any error or warning. The syntax ${VAR:?message} can be used instead to force a controlled abort with an error message.
6Can I specify multiple env_file entries at once?
Yes, env_file accepts a list of file paths. For matching keys, the file listed last wins, so the files are merged in sequence.
7Why do I see different values in docker compose config than inside the container?
docker compose config only shows the result of YAML interpolation, i.e. shell and .env file. The actual container environment can be further changed by environment and env_file, so it should be verified with docker compose exec app env.
8Is compose.override.yaml loaded automatically?
Yes, as long as it sits in the same directory as the main Compose file, it is loaded automatically without an explicit -f flag, and its values override matching keys from the main file.
9How do I pass a value for just a single run without editing any file?
With docker compose run -e VAR=value servicename or docker compose run --env VAR=value. This value has the highest priority and overrides every other source for that one invocation.
10Should the .env file be committed to version control?
No, the real .env with production values belongs in .gitignore. Instead, an .env.example with the same keys but placeholder values should be committed, so new team members know which variables are required.