A .env file fills ${VAR} placeholders in the compose file itself and sets nothing inside the container. env_file passes variables into the container and is ignored during interpolation. They point in opposite directions, which is why swapping them produces two different silent failures rather than one obvious error.
By LK Wood IV · 2026-08-15 · ~9 min read · St. Louis County, MO
Put a .env file next to your compose.yaml, fill it with variables, start the stack, and exec into the container to check.
They are not there.
This is the single most common Compose confusion. It is also not carelessness.
The two mechanisms have similar names, similar syntax, and point in opposite directions.
.envfills${VAR}placeholders in the compose file itself. By itself it puts nothing in a container.env_file:passes variables into the container, and is not consulted when Compose interpolates the compose file.
Docker says the first half plainly. Their precedence page notes that the Host OS environment and .env file columns are listed only for illustration purposes, and that in reality they do not result in a variable in the container by itself.
Read that twice. It is the whole article.
The two directions, side by side
# .env (interpolation source)
POSTGRES_VERSION=17
# compose.yaml
services:
db:
image: postgres:${POSTGRES_VERSION} # .env fills THIS
env_file:
- db.env # this goes INTO the container
environment:
- TZ=America/Chicago # so does this
POSTGRES_VERSION never reaches the container.
TZ and everything in db.env do reach it, and neither of them can interpolate anything, which means the file you edited and the file you needed to edit are frequently different files.
The mirror-image mistake is just as common and harder to spot:
services:
web:
image: "webapp:${TAG}" # TAG will NOT come from env_file
env_file:
- .env
Listing .env under env_file: does not make its contents available for interpolation. It makes them available inside the container, which is a different thing that you did not ask for.
Precedence, in the order Docker documents
Highest to lowest, for what ends up in the container:
docker compose run -eon the CLIenvironmentorenv_filewith no value — passes through from your shell- the
environmentattribute - the
env_fileattribute - a Dockerfile
ARGorENV
Two entries in that list catch people.
environment beats env_file, even when it is empty. Docker’s wording is explicit that this holds true even if those values are empty or undefined. So a leftover FOO= under environment: silently overrides a correct FOO in your env file. Nothing warns you. The variable is simply empty, and you spend the next twenty minutes reading the env file, which is correct, because the problem is in the other file entirely.
A Dockerfile default is a fallback. The docs state that having any ARG or ENV setting in a Dockerfile evaluates only if there is no Compose entry for environment, env_file or run --env. You do not layer on top of it. You replace it entirely.
There is also a second, different precedence list in the docs covering interpolation: shell, then --env-file, then the project .env. Two lists. Two jobs. Reading one and applying it to the other is how people end up certain the docs contradict themselves.
The silent failures
This is what makes the topic worth a page rather than a paragraph.
Almost every way to get it wrong fails quietly.
Unset interpolated variables become empty strings, not errors.
image: "postgres:${POSTGRES_VERSION}"
With POSTGRES_VERSION unset, that resolves to postgres:, which the docs note is not a valid image reference.
Compose builds the string anyway and hands you the failure one layer down, where the error message talks about an image rather than about the variable that produced it, so the search you run next is the wrong search.
Bare passthrough does not warn; the explicit form does.
environment:
- DEBUG # unset? no warning, nothing passed
- DEBUG=${DEBUG} # unset? Compose warns
Both are legal. Only the second tells you when the shell had nothing to give. I use it for that reason alone.
A missing --env-file is a hard error. A missing .env is silent.
That asymmetry is deliberate. A path you named explicitly is a promise; the default is optional. It also means a stack that runs on your laptop can start on the server with a chunk of its configuration quietly absent, because .env was never committed and nobody noticed it was gone.
I check for that first now, whenever something runs locally and not on the host. It is nearly always this.
Secrets do not belong here
Anything you put in the container environment is readable with docker inspect. That means it is readable by anyone who can reach the Docker socket — and on a Linux host, that is anyone in the docker group, which is root-equivalent access anyway.
Compose supports file-based secrets that arrive at /run/secrets/<name> instead:
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./db_password.txt
Note the pattern in that block. The environment variable holds a path. The secret itself never enters the environment. Many official images support a _FILE suffix on their password variables for exactly this, and where an image supports it, it is strictly better than pasting the value.
ARG deserves a specific warning too: build arguments are visible in the image history. An ARG carrying a token is published rather than private, and it stays published in every layer built from it.
What I do
A .env for things that shape the compose file: versions, ports, host paths.
An env_file per service for what the application reads.
environment for one or two values I want visible in the compose file itself, so that reading the file tells the truth about what the service gets rather than sending the reader off to a second file to find out.
The rule I hold to, after being bitten by the empty-override: never set the same variable in two places. Precedence rules exist so that Compose can resolve a conflict, not so that I can create one and rely on remembering the order at 1am.
What I have not tested
I have not verified the behaviour on old Compose v1. Everything here is from current Compose documentation, and v1 differed in enough places that I would not extend it there.
I have not tested how the newer top-level include: interacts with .env resolution across multiple compose files, which is the case I would most expect to hold a surprise.
What getting it wrong costs
Usually an hour. The hour has a distinctive shape: the configuration looks correct, the container disagrees, and nothing produces an error that points at either one.
Occasionally it costs more. environment silently overriding an env-file value with an empty string is a fine way to start a database with an empty password variable, and the failure mode there depends entirely on how forgiving the image is about it. Some are not forgiving at all, which is the good outcome.
Frequently asked questions
Does a .env file automatically set variables in my container?
What is the difference between .env and env_file?
What is the precedence order for environment variables in Compose?
Does environment beat env_file?
Why did my image tag come out wrong?
When does a Dockerfile ENV or ARG apply?
Should I put secrets in environment variables?
Why does a missing --env-file error but a missing .env not?
Evidence ledger
- Last updated
- Methodology
- This tutorial was written and edited by Lowell K. Wood IV in St. Louis County, MO. Specs and prices verified against vendor and project documentation current on the date above. Full editorial standard: methodology.
- Update log
- 2026-08-15 — Last reviewed and updated.
- Corrections
- Spotted an error or a stale number? Email hello@techfuelhq.com. Confirmed corrections are added to the update log above.