Docker Compose: env_file vs environment
You edit .env, restart the container, and the old value is still there - no error anywhere. The exact precedence order between env_file, environment, and your shell, reproduced on a real stack, plus the fix when Compose won't pick up the change.

Docker Compose: env_file vs environment
You change a value in .env, run docker compose up -d, exec into the container to check it - and it's still the old value. No error, no warning, nothing in the logs. The file is definitely saved. Compose definitely ran. So why didn't it take?
TL;DR
- Compose has three different things people lump together as "environment variables": the top-level
.envfile (fills${VAR}placeholders incompose.yamlitself),env_file:(loads a file's contents into the container), andenvironment:(sets container variables directly). - When the same key exists in both,
environment:always wins overenv_file:- per Docker's own precedence docs. - A variable already exported in your shell beats anything in a file, which is the most common reason an edited
.envvalue "doesn't take effect." docker compose up -donly recreates a container when it detects a config change - editing anenv_file:'s contents alone sometimes isn't enough to trigger that;--force-recreateis the reliable fix.${VAR:-default}and${VAR:?error}only apply to interpolation (.env/ shell), never to the contents loaded byenv_file:.
The Three Things People Call "Environment Variables" in Compose
"Set it in Compose" can mean three unrelated mechanisms, and mixing them up is what causes the silent-override confusion:
| Mechanism | What it actually does | When Compose reads it |
|---|---|---|
Top-level .env file |
Fills ${VAR} placeholders written inside compose.yaml itself |
At parse time, before the container even exists |
env_file: |
Loads a separate file's key=value lines into the container's runtime environment | At container creation |
environment: |
Sets variables in the container's runtime environment directly in compose.yaml |
At container creation |
The top-level .env file is the one that trips people up most: by itself, it puts nothing in the container. It only resolves ${VAR} tokens written literally in compose.yaml - image names, port numbers, anything you've parameterised. Docker's interpolation docs are explicit that this is a separate step from what ends up inside the running container.
The Precedence Order (Highest to Lowest Wins)
When the same variable name is defined in more than one place, Docker Compose resolves it in this order:
| Rank | Source | Wins over |
|---|---|---|
| 1 | docker compose run -e VAR=value on the command line |
Everything below |
| 2 | Shell-exported variable interpolated into environment: or env_file: |
environment: / env_file: set as literal values |
| 3 | environment: in compose.yaml |
env_file: |
| 4 | env_file: in compose.yaml |
ENV baked into the image |
| 5 | ENV in the image's Dockerfile |
Nothing - lowest priority |
The rule worth memorising: environment: beats env_file: for the same key, every time. If you're debugging a value that won't change, check environment: first - it's almost always the thing silently winning.
Warning
a variable already exported in your shell (export DB_PASSWORD=old left over from a previous session) outranks anything written in .env or env_file:. This is the single most common cause of "I changed the file and nothing happened" - the shell, not the file, is the active source.
Reproducing the Silent Override
I ran this exact setup on one of my homelab stacks to confirm the ordering, not just quote it. Minimal compose.yaml:
services:
app:
image: alpine
command: sleep infinity
env_file: .env
environment:
- DB_PASSWORD=from_environment_block
With .env containing:
DB_PASSWORD=from_env_file
Starting it and checking the actual container environment:
docker compose up -d
docker compose exec app env | grep DB_PASSWORD
Output: DB_PASSWORD=from_environment_block.
environment: wins, exactly as the precedence table says - env_file:'s value never makes it into the container at all for that key. Delete the environment: block entirely and DB_PASSWORD=from_env_file is what shows up instead.
Why ".env Changes Sometimes Don't Take Effect"
This is the part that actually confuses people, because it looks like Compose is ignoring the file outright. Two separate things can cause it:
- A shell variable of the same name already exists and silently outranks the file, per the precedence table above.
- Compose didn't recreate the container.
docker compose up -donly recreates a container when it detects a change to the resolved config - and in some Compose versions, editing the contents of a file referenced byenv_file:isn't always picked up as a config diff, because Compose doesn't hash the referenced file's contents the same way it hashes the service definition itself.
Note
when in doubt, don't debug blind - force it with docker compose up -d --force-recreate. This rebuilds the container from the current config and files regardless of whether Compose thinks anything changed, which rules out stale state as the cause.
Multiple env_file Entries and Override Order
env_file: also accepts a list, and when the same key appears in more than one file, the last file in the list wins:
services:
app:
env_file:
- .env.defaults
- .env.local
If both files set LOG_LEVEL, the value from .env.local is the one the container gets - treat the list order as least-specific to most-specific, the same mental model as CSS specificity.
Interpolation Defaults: ${VAR:-default} and ${VAR:?error}
Interpolation syntax only applies to values resolved from the shell or the top-level .env file into compose.yaml - never to the contents loaded by env_file::
services:
app:
image: "myapp:${TAG:-latest}"
environment:
- API_KEY=${API_KEY:?API_KEY must be set}
${TAG:-latest} falls back to latest if TAG is unset. ${API_KEY:?...} does the opposite - it refuses to start and prints the message if API_KEY is missing entirely, which is a cheap guard against a container silently booting with no credential at all.
A Quick Debugging Checklist
docker compose config- prints the fully resolvedcompose.yamlwith all interpolation applied, so you can see what Compose actually parsed.docker compose exec <service> env- the ground truth: what's actually inside the running container, regardless of what any file says.echo $VARNAMEin your shell - rules out a stray export from a previous session overriding your files.docker compose up -d --force-recreate- if a value still won't update after fixing the above, this forces the container to rebuild from current config.
Tip
docker compose config is the fastest way to catch a typo'd ${VAR} reference before it causes a confusing runtime symptom three steps later - run it before up becomes a habit worth keeping, not just a debugging step. If secrets management rather than plain config values is the actual goal here, our Docker Compose secrets vs .env guide covers the security side this article doesn't.
FAQ
What is the difference between env_file and environment in Docker Compose?
env_file: loads key=value pairs from a separate file into the container's environment. environment: sets variables directly inside compose.yaml. When both define the same key, environment: wins.
Does environment or env_file take precedence in Docker Compose?
environment: takes precedence over env_file: for the same variable name, per Docker's own precedence documentation. A shell-exported variable outranks both.
Why isn't my .env file change showing up in the container?
Usually one of two causes: a variable of the same name is already exported in your shell and silently wins, or Compose didn't detect a config change and never recreated the container. Run docker compose up -d --force-recreate to rule out the second cause.
Can I use multiple env_file entries in Docker Compose?
Yes - env_file: accepts a list of files. If the same key appears in more than one, the value from the last file in the list wins.
The Bottom Line
KEY-STAT: 5 — distinct precedence levels Compose checks before a container's environment is final - my own test on this homelab stack
Three mechanisms get called "environment variables" in Compose, and only one rule actually matters when they collide: environment: beats env_file:, and anything already exported in your shell beats both. When a value won't change, docker compose exec <service> env tells you the truth faster than re-reading your YAML ever will. If a port conflict shows up once your environment is sorted, our guide to Docker Compose ports covers that next; if the command itself won't run at all, this fix for "docker-compose: command not found" is the earlier-stage problem.
More from Self-Hosting & Privacy

Every password manager says it: "zero-knowledge." Here is what that actually means technically, how client-side key derivation works, and a 5-point checklist to verify any vendor is claim for yourself.

Duplicate keys, mixed env syntax, unquoted booleans - none of them throw an error. A structural walkthrough of docker-compose.yml with YAML anchors to cut repetition and the docker compose config habit that catches mistakes before they reach a running container.

A new phone demanded a Google Password Manager PIN before your logins synced. Here's exactly what it protects, what it doesn't, and what happens if you lose it.
Stay in the loop
Get the latest articles delivered to your inbox. No spam, unsubscribe anytime.
Zero-Knowledge Password Managers Explained
Every password manager says it: "zero-knowledge." Here is what that actually means technically, how client-side key derivation works, and a 5-point checklist to verify any vendor is claim for yourself.
Continue Reading