Skip to main content
Self-Hosting & Privacy

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.

Milan BuhaOctober 29, 20266 min read
ShareXin
Docker Compose: env_file vs environment

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 .env file (fills ${VAR} placeholders in compose.yaml itself), env_file: (loads a file's contents into the container), and environment: (sets container variables directly).
  • When the same key exists in both, environment: always wins over env_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 .env value "doesn't take effect."
  • docker compose up -d only recreates a container when it detects a config change - editing an env_file:'s contents alone sometimes isn't enough to trigger that; --force-recreate is the reliable fix.
  • ${VAR:-default} and ${VAR:?error} only apply to interpolation (.env / shell), never to the contents loaded by env_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:

  1. A shell variable of the same name already exists and silently outranks the file, per the precedence table above.
  2. Compose didn't recreate the container. docker compose up -d only recreates a container when it detects a change to the resolved config - and in some Compose versions, editing the contents of a file referenced by env_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

  1. docker compose config - prints the fully resolved compose.yaml with all interpolation applied, so you can see what Compose actually parsed.
  2. docker compose exec <service> env - the ground truth: what's actually inside the running container, regardless of what any file says.
  3. echo $VARNAME in your shell - rules out a stray export from a previous session overriding your files.
  4. 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.

Related stories

More from Self-Hosting & Privacy

Stay in the loop

Get the latest articles delivered to your inbox. No spam, unsubscribe anytime.

Read next

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