Tech Digest

Guide

Why aren't my .env variables reaching the container in Docker Compose?

A .env beside compose.yaml is read by Compose to fill in the placeholders in that file. Nothing in it reaches the container unless the compose file references it or loads the file with env_file. The rest of the confusion is a dollar sign in a value, a shell variable that quietly wins, and docker compose restart.

Last reviewed

Why are my .env variables not passed to the container in Docker Compose?

Because Compose uses .env only for interpolation: it substitutes ${VAR} in the compose file and passes nothing to the container by itself. Reference each variable under environment: (DB_PASS: ${DB_PASS}), or load a file into the container with env_file:. After changing a value, run docker compose up -d, which recreates the container; docker compose restart keeps the old environment. A variable set in your shell beats the same name in .env.

Because Compose reads .env for one job: filling in ${VAR} placeholders in the compose file. It does not hand the file's contents to any container. Docker's interpolation page describes .env as defining "variables that should be made available for interpolation", and that is the whole of it. A variable reaches the container only if the compose file names it under environment: or the service loads a file with env_file:. If you changed a value and the container still has the old one, you ran docker compose restart instead of docker compose up -d.

Two answers circulate, and both leave out half#

The first answer is "Compose loads .env automatically, so the variables are there". The loading part is true, which is why people believe the rest. Compose does read the file every time it runs. It just reads it for itself.

The second answer, the correction, is ".env is for the compose file and env_file is for the container". That is right, and it is where most answers stop. It does not explain the three things that still go wrong once you know it: a $ inside a value is a variable reference in both kinds of file, the same file means different things to docker compose and docker run, and a shell variable with the same name quietly beats the file.

What reached the container, measured#

We ran each case on Docker Engine 28.1.1 with Compose 2.35.1 and an alpine:3.20 container that printed its own environment. The .env held six variables, and the compose file referenced some of them.

In .envIn compose.yamlIn the container
ONLYDOT=xnot mentionedabsent
FOO=fromdotenvFOO: ${FOO}FOO=fromdotenv
FOO=fromdotenv, and FOO=fromshell exported in the shellFOO: ${FOO}FOO=fromshell
BARE=bare-from-dotenvBARE: with no valueBARE=bare-from-dotenv
X=from-env-file in app.envenv_file: app.env and X: from-environmentX=from-environment
EMPTYVAR=${EMPTYVAR:-default}default
EMPTYVAR=${EMPTYVAR-default}empty string

The first row is the bug most people arrive with. The third is the one that costs an afternoon: a variable exported in your shell profile, or left over from an earlier session, beats .env with no warning. Compose's documented order for interpolation is shell first, then a file given with --env-file, then .env in the project directory.

The fourth row is a shortcut worth knowing. A key under environment: with no value takes it from the shell or .env, so a list of bare names passes exactly those variables and nothing else.

The last two rows differ by one colon. :- treats an empty value as unset; - treats it as a value. A .env with DB_PASS= and a compose file using ${DB_PASS-changeme} gives the container an empty password.

What wins when the same variable is set twice#

Compose's precedence page lists five levels for the container's environment, highest first:

  1. docker compose run -e on the command line.
  2. environment or env_file with a value interpolated from the shell or an env file.
  3. A literal value under environment.
  4. A value from env_file.
  5. ENV in the image's Dockerfile.

Our X row matches levels 3 and 4: environment beat env_file. Level 5 is why some variables look set even though you never set them: the image put them there, and anything in the compose file replaces them.

Why a $ in a value breaks it#

A password or hash with a $ in it is the commonest way a value reaches the container wrong. Compose interpolates unquoted and double-quoted values in .env and in env_file files, as well as everywhere in compose.yaml. Each $name is read as a variable, and an unknown variable becomes an empty string with a warning. What we got:

Written asWhereContainer received
PASS=ab$cd.envab, four warnings The "cd" variable is not set
HASH='$argon2id$v=19$m=65540'.env$argon2id$v=19$m=65540, intact
DD=ab$$cd.envab$cd
TOK: $argon2id$v=19$m=65540compose.yaml=19=65540
YQ: '$argon2id$v=19'compose.yaml=19
RAW=a$benv_filea, with a warning

The fifth row catches people who know YAML. Single quotes in compose.yaml are YAML quoting, and Compose interpolates the string after YAML has parsed it, so they protect nothing. Single quotes inside .env are Compose's own syntax: its documentation says "single-quoted (') values are used literally".

So the rule depends on the file. In compose.yaml, double every literal $ to $$. In .env or an env_file, put the value in single quotes. Vaultwarden's wiki gives exactly this split for its Argon2 ADMIN_TOKEN: $$ throughout when the hash sits in compose.yaml, single quotes when it sits in .env and is referenced as ${VAULTWARDEN_ADMIN_TOKEN}. One part of that wiki page overstates it: it says interpolation "is not needed" with a .env file, but our unquoted PASS=ab$cd shows the .env value itself is interpolated. The quotes are what make it work.

The warning is the tell. Compose said The "cd" variable is not set. Defaulting to a blank string. four times for one bad value, on every command, including config and run. It is a warning, not an error, so the stack still starts with a truncated password and the app reports an authentication failure that never mentions Compose.

The same file means different things to docker run#

env_file and docker run --env-file look interchangeable, and Compose's own docs suggest sharing one file between them. They parse it differently. We loaded one app.env three ways:

Line in app.envenv_file: (default)env_file: with format: rawdocker run --env-file
QT="dq"dq"dq""dq"
RAW=a$baa$ba$b
REF=${SECRET}, with SECRET in .envfromdotenv${SECRET}${SECRET}

Compose strips quotes and interpolates, so one variable can be built from another or from .env. docker run takes every character after the = literally, quotes included; Docker's docs say interpolation in env files "is not supported when running docker run --env-file". The format: raw attribute makes Compose behave like docker run, which is the safe choice for a file full of generated secrets you never want interpolated.

A file written for one and used with the other breaks quietly. A password stored as DB_PASS="s3cret" works under Compose and arrives as "s3cret", quotes and all, under docker run.

Where Compose looks for .env#

Compose looks in the project directory, not the current one. By default that is the directory of the first -f file, then --project-directory if you give it. Running docker compose -f ../compose.yaml config from a subdirectory that had its own .env used the .env next to the compose file and ignored the local one. With --project-directory pointed at another directory, Compose read .env from there and also resolved the relative env_file: app.env there, failing with env file /tmp/envt2other/app.env not found. The compose reference says relative env_file paths are resolved "from the Compose file's parent folder"; on 2.35.1 that held only while the project directory was that folder.

Two syntax details that are fine in .env: lines starting export and spaces around = both parsed (export E1=exported gave exported, E2 = spaced gave spaced). A file saved with Windows line endings also came through clean, with no carriage return on the value. An inline comment needs a space before the #: CMT=val #comment gave val, while CMT2=val#nocomment kept the whole string.

Changed .env and nothing happened#

A container's environment is fixed when the container is created. docker compose restart restarts the existing container, and its reference says changes to environment variables "are not updated after restarting". We changed VAL=one to VAL=two in .env:

Command after the editVAL in the container
docker compose restart cone
docker compose up -dtwo, container recreated
docker compose up -d again, no changetwo, container left Running

up -d is the command to use. It recreates only containers whose resolved configuration changed, so running it across a whole stack costs nothing for the services you did not touch.

If the container now has the new value and the app still uses the old one, the app stored it. The Postgres image warns that its POSTGRES_* variables "will only have an effect if you start the container with a data directory that is empty". Changing POSTGRES_PASSWORD in .env after the first start changes nothing in the database; change the password inside Postgres with ALTER USER, then update .env to match. Vaultwarden does something similar with its config.json, which overrides the environment for most settings once you save anything on the admin page; Replace LastPass or 1Password covers that trap. Immich vs PhotoPrism has the PhotoPrism version.

Make a missing variable fail loudly#

An unset variable becomes an empty string by default, and an empty database password is a hard bug to read from an app's logs. Use the required form for anything that must be set:

yaml
services:
  app:
    image: example/app:2.3.1
    environment:
      DB_PASS: ${DB_PASS:?set DB_PASS in .env}
      TZ: ${TZ:-Etc/UTC}

With DB_PASS missing, docker compose up -d refused to start anything and exited with status 1:

text
error while interpolating services.app.environment.DB_PASS: required variable DB_PASS is missing a value: set DB_PASS in .env

Then check what Compose will actually use before you start anything:

bash
docker compose config --environment | grep DB_
docker compose config | grep -A12 'environment:'
docker compose exec app env | sort

The first prints the variables Compose resolved for interpolation, after shell, --env-file and .env are merged, so a shell variable shadowing your file shows up there. The second prints the compose file with every placeholder filled in, which is where a truncated $ value or an empty default becomes visible. In that output literal dollar signs appear doubled, as $$argon2id, which is the escaped form and not a bug. The third shows what the running container really has.

When this answer stops applying#

  • Compose version. required: false on env_file needs Compose 2.24.0. Everything here, format: raw included, was measured on 2.35.1. The legacy Python docker-compose 1.x and docker stack deploy were not tested.
  • podman-compose. It reimplements this parsing, and we did not test it. Docker vs Podman covers where it differs from Compose.
  • Values the app reads once. Everything above is about what the container's environment holds. Database passwords, admin accounts and anything an app writes into its own config on first start follow the app's rules, covered in the section above on changes that do nothing.

What to do next#

Run docker compose config 2>&1 | grep 'not set' in every stack directory. Each line is a value that reached its container shorter than you wrote it. Quote the .env values that contain $ with single quotes, double the ones in compose.yaml, and switch any variable that must not be empty to ${VAR:?}. Keep .env out of git and in your backups: A security baseline for a home server covers the first, and Backups that actually restore explains why .env is one of the files a restore cannot do without. The per-stack layout these files belong in is in Docker Compose conventions, and if the database is now getting the right password but the app still starts before it is ready, that is Why depends_on does not wait for the database.

Questions#

What is the difference between .env and env_file in Docker Compose?

.env is read by the Compose CLI to fill ${VAR} placeholders in the compose file; on its own it sets nothing inside any container. env_file: is a service attribute that loads a file's variables into that container's environment. You can point env_file: at .env to do both, but then every variable in it, including ones meant for other services, lands in that container.

Why does Compose say 'The variable is not set. Defaulting to a blank string'?

A $ followed by letters was read as a variable reference. Usually it is part of a password or an Argon2 hash, not a variable at all: $argon2id$v=19$m=65540 produced three such warnings in our test and reached the container as =19=65540. In compose.yaml, double every literal $ to $$. In .env, wrap the value in single quotes. The warning is printed on every Compose command, so it is easy to scroll past.

Do I need to restart the container after changing .env?

You need to recreate it, which is not the same thing. docker compose restart restarts the existing container with the environment it was created with; in our test the old value survived it. docker compose up -d compares the resolved configuration, sees the change and recreates the container with the new value. If nothing changed, it leaves the container running.

Which wins, environment or env_file?

environment. Compose's reference says that when both are set for a service, values set by environment have precedence, and our test agreed: the same variable in both arrived with the environment value. Above both sits docker compose run -e, and below both sits ENV in the image's Dockerfile. A shell variable does not override either directly; it only changes what ${VAR} interpolates to.

Does Docker Compose read the .env file from the current directory?

No, from the project directory, which defaults to the directory of the first compose file. Running docker compose -f ../compose.yaml config from a subdirectory with its own .env used the .env next to the compose file and ignored the one in the current directory. --project-directory moves it, and also moves where relative env_file paths are resolved.

Sources#

Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.