Toolarity

Docker Compose .env Not Loading? 6 Real Causes, Tested

Fix Docker Compose Published October 1, 2026

You put a .env file next to your compose.yaml, run docker compose up, and the variable inside your container is empty, or it’s still the old value, or Compose complains it’s “not set” even though you can see it in the file. There isn’t one bug here — there are at least six different reasons this happens, and they don’t look alike from the outside.

I tested each one on Docker Compose v5.5.1 using docker compose config (and docker compose config --format json), which parses the compose file and .env exactly like up does but doesn’t need a running daemon. Every command and output below is copied from a real terminal, not written from memory.

Short answer (Docker Compose v5.5.1, tested 2026-09-29):

SymptomCauseFix
.env variable is set, but the container doesn’t have it.env only feeds ${VAR} interpolation in the compose file; it isn’t auto-injectedUse environment: or env_file: to actually put it in the container
.env is right there, but nothing gets substituted.env is read from the project directory (first -f file’s dir, or --project-directory), not your cwdMove .env next to the compose file, or pass --project-directory
--env-file custom.env picks the wrong fileIts path is relative to cwd, the opposite of env_file: (relative to the compose file)Use an absolute path, or check your cwd
A value from env_file: didn’t override what you expectedenvironment: always beats env_file:; later env_file: entries beat earlier onesReorder, or move the override into environment:
${VAR:-default} behaves differently from ${VAR-default} on empty:- also triggers on an empty-but-set variable; - only triggers when fully unsetPick the form on purpose
config shows price$$5, not sure if that’s really two $config re-escapes a literal $ back to $$ when printingCouldn’t confirm the container’s real value without a daemon — see caveat below

First: prove Compose is even seeing your .env

Run this from your project directory — it needs no running containers:

$ docker compose config

If a variable you expect from .env doesn’t show up correctly substituted anywhere in this output, nothing downstream will have it either. Every claim below was checked with this command or docker compose config --format json.

Cause 1: .env only feeds interpolation — it does not become a container variable

This is the most common misunderstanding, and it’s easy to have because both .env and env_file: have “env” in the name.

Compose file: image: nginx:${TAG} and nothing else. .env: TAG=1.25. Running docker compose config gives:

name: exp1_interpolation
services:
  web:
    image: nginx:1.25
    networks:
      default: null

Notice there’s no environment: block in the output at all. .env did exactly one job: it filled in ${TAG} in the YAML text. If your app inside the container reads process.env.TAG, it gets nothing, because nothing ever told Compose to put TAG into the container’s environment.

To actually get a variable into the container, you need one of:

services:
  web:
    environment:
      - TAG        # reads TAG the same way ${TAG} does: shell first, then .env

or

services:
  web:
    env_file:
      - app.env    # loads every key in app.env into the container, unconditionally

I tested the bare environment: - TAG form specifically, because it’s easy to assume it only reads the shell. It doesn’t — with no shell variable set it read TAG=from-dotenv straight out of .env, and setting the shell variable overrode it. Same resolution order as ${TAG} interpolation, just written differently.

env_file: is separate. env_file: [app.env] loaded both keys from app.env into the container, and a same-named key in the project’s top-level .env was ignored — env_file: doesn’t consult .env at all, it just reads the file(s) you list.

Cause 2: .env has to be in the project directory, not your current directory

This one bit me setting up the test itself. Compose looks for .env in the project directory — determined by --project-directory if you pass it, otherwise the directory of the first -f/--file compose file, otherwise your current directory — not wherever you happen to be standing when you run the command.

I put a compose file in proj/ and ran commands from a sibling directory, other_cwd/, using -f ../proj/compose.yaml. A .env sitting only in proj/ was used correctly:

$ (cd other_cwd && docker compose -f ../proj/compose.yaml config)
    image: nginx:in-proj-dir

Then I put a different .env in other_cwd/ (right next to where I was running the command from) and ran the exact same command again — same result, the other_cwd/.env was never touched:

$ (cd other_cwd && docker compose -f ../proj/compose.yaml config)
    image: nginx:in-proj-dir

If you call Compose from a deploy script or a Makefile target that lives in a different folder from your compose.yaml, and you drop a .env next to the script “for convenience,” it’s silently ignored — Compose doesn’t warn you, the variable just falls back to whatever default the compose file specifies.

--project-directory changes where .env is read from, and it also changes the default project name:

$ (cd proj && docker compose --project-directory ../other_cwd -f compose.yaml config)
name: other_cwd
    image: nginx:in-othercwd-dir

Cause 3: --env-file and env_file: resolve relative paths differently

These two look almost identical and behave differently, which is the part I’d flag as the single most useful thing in this article.

env_file: (the YAML attribute inside compose.yaml) resolves relative paths against the compose file’s own directory. With env_file: [config/app.env] in proj/compose.yaml, running Compose from a totally different directory with -f ../proj/compose.yaml still found proj/config/app.env correctly:

$ (cd elsewhere && docker compose -f ../proj/compose.yaml config)
    environment:
      FOO: from-proj-config

--env-file (the CLI flag) resolves relative paths against the directory you ran the command from. From other_cwd/, pointing at a compose file in proj/:

$ (cd other_cwd && docker compose -f ../proj/compose.yaml --env-file custom.env config)
name: proj
    image: nginx:from-custom-env-file

custom.env was picked up from other_cwd/ (the cwd), not from proj/ (the project directory), even though Compose still reported the project name as proj. Run Compose from different places — local shell, CI, a wrapper script — and a relative --env-file path can point at different files depending on where you were standing, while the same relative path inside env_file: never moves.

Cause 4: priority — shell wins, environment: beats env_file:

For interpolation, a shell environment variable beats .env:

$ unset TAG; docker compose config | grep image
    image: nginx:from-dotenv
$ TAG=from-shell-env docker compose config | grep image
    image: nginx:from-shell-env

For what ends up in the container, environment: beats env_file: — and this held even with a shell variable of the same name set, because the environment: block used a literal value, not a ${...} reference:

env_file: [base.env]      # COLOR=red
environment:
  COLOR: green
$ COLOR=fromshell docker compose config --format json
"COLOR": "green"

This matches Docker’s precedence page (checked 2026-09-29): highest to lowest, docker compose run -e, then environment:/env_file: with a value interpolated from shell/.env, then a plain environment: value, then env_file:, then the image’s own ENV.

With multiple env_file: entries, later files override earlier ones key by key; non-conflicting keys are kept:

env_file: [base.env, override.env]   # base: COLOR=red, SIZE=small; override: COLOR=blue
"COLOR": "blue",
"SIZE": "small"

Cause 5: .env parsing quirks — most are fine, one # rule isn’t obvious

I ran a batch of edge cases through .env — quotes, spacing, comments, export, CRLF line endings, and a UTF-8 BOM. Most of them just worked:

  • KEY = value with spaces — spaces around = are trimmed on both sides.
  • KEY="quoted value" and KEY='quoted value' — quotes are stripped correctly.
  • export KEY=value — the export prefix (common if copied from a shell profile) is recognized and stripped.
  • Blank lines and full-line # comments are skipped without affecting the next line.
  • A CRLF line ending (KEY=value\r\n) didn’t leave a stray \r in the value.
  • A UTF-8 BOM at the start of the file didn’t corrupt the first variable’s name.

One thing that isn’t obvious: # only starts a comment if there’s a space before it. KEY=abc#nospace keeps the whole string including the #; KEY=abc #comment gets truncated to abc. Quoting the value keeps the # either way.

Double-quoted values also interpolate recursively — BASE=core plus NESTED="prefix-${BASE}-suffix" resolves to prefix-core-suffix. Single-quoted values don’t: NESTED='prefix-${BASE}-suffix' keeps ${BASE} as literal text.

Cause 6: $$ blocks substitution, but don’t trust what config prints

$$ is documented (in the Compose Spec, checked 2026-09-29) as the way to write a literal $. I confirmed the blocking part: $$TAG, with TAG set in .env, did not get replaced — docker compose config printed it back as $$TAG, not the value of TAG.

What I can’t confirm is what a running container actually receives. docker compose config re-escapes a literal $ back into $$ when printing, so its output stays valid if fed through Compose again. I saw this directly: a single-quoted .env value containing a literal ${BASE} came back from config as prefix-$${BASE}-suffix — an extra $ not in the source file. So price$$5 in config’s output most likely means the container gets price$5 (one dollar sign), not two — but confirming that needs the Docker daemon, which this test explicitly didn’t start. Verify with docker compose run --rm <service> env before relying on it.

${VAR:-default} vs ${VAR-default}, and ${VAR?err}

With EMPTY_VAR= (set to an empty string) and SET_VAR=actual-value in .env:

ExpressionResult
${UNSET_VAR:-fallback} (unset)fallback
${EMPTY_VAR:-fallback} (set, empty)fallback
${EMPTY_VAR-fallback} (set, empty, no colon)“ (empty string, not the fallback)
${SET_VAR:-fallback} (set, non-empty)actual-value

The colon matters: :- treats “set to empty” the same as “unset.” Without the colon, an explicitly empty variable is left empty.

${VAR?message} (or ${VAR:?message}) fails instead of substituting anything:

$ docker compose -f compose2.yaml config
error while interpolating services.web.environment.WITH_REQUIRED: required variable REQUIRED_VAR is missing a value: custom error message

Exit code was 1. This is useful as a cheap CI guard for “someone forgot to set a required variable” — it fails before anything tries to start.

COMPOSE_PROJECT_NAME

The default project name comes from the directory name, normalized (lowercased, spaces and punctuation stripped) — a directory named My Weird Dir! became myweirddir. Precedence, highest to lowest: -p flag, COMPOSE_PROJECT_NAME shell variable, COMPOSE_PROJECT_NAME inside .env, then the directory-name default. I checked each by setting one, then adding a higher-priority one on top.

What I’d check, in order

  1. Run docker compose config and look for your variable, substituted, anywhere in the output. Not there or not substituted — nothing else matters yet.
  2. Check where you’re running the command from versus where compose.yaml lives. Relying on -f ../something/compose.yaml? Your .env needs to sit next to that compose file, not next to your shell prompt.
  3. Using --env-file? Its path is relative to your current directory, not the env_file: rule.
  4. Decide on purpose: ${VAR} (compose-file-only, needs environment:/env_file: to reach the container) or env_file: (loads everything from a file straight into the container).
  5. If two sources disagree: environment: beats env_file:, and later env_file: entries beat earlier ones.

Test setup and what this doesn’t cover

Docker Compose v5.5.1 (docker compose version), Docker CLI 29.8.0, on macOS 15.3.1, tested 2026-09-29. Every claim above was checked with docker compose config / docker compose config --format json, which parses the compose file and .env without a running daemon. I deliberately didn’t run docker compose up, docker compose run, or start Docker Desktop, so anything that only shows up once a container is running — the $$ question in Cause 6, above all — is flagged as unverified instead of guessed at.

Not tested: Windows and Linux hosts, older Compose versions (v1, early v2), and docker-compose.override.yaml merge behavior.

Official docs checked 2026-09-29: Docker’s variable-interpolation, set-environment-variables, and envvars-precedence pages, and the Compose Spec’s interpolation page on GitHub. Where this article adds detail the docs don’t cover — the --env-file vs env_file: path difference, and config’s $$ print-time re-escaping — that’s called out explicitly rather than stated as if the docs said so.