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.
Table of Contents
- 1. Two separate mechanisms sharing one name
- 2. Shell environment: the strongest source for interpolation
- 3. The .env file: silent default for interpolation
- 4. The environment block: explicit container injection
- 5. env_file: pulling whole files in as a variable source
- 6. The full precedence chain in practice
- 7. Extra factor: multiple Compose files with -f
- 8. Systematic debugging when values look wrong
- 9. Recommendations for stable Compose configurations
- 10. Summary
- 11. FAQ
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.