Quick answer

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.

  • .env fills ${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:

  1. docker compose run -e on the CLI
  2. environment or env_file with no value — passes through from your shell
  3. the environment attribute
  4. the env_file attribute
  5. a Dockerfile ARG or ENV

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?
No. Docker’s own precedence page says 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. A .env file is an interpolation source: it fills ${VAR} placeholders in the compose file. To get a variable into a container you need an explicit entry under environment or env_file.
What is the difference between .env and env_file?
They point in opposite directions. .env feeds interpolation of the compose file itself. env_file feeds the container’s environment and is NOT consulted during interpolation, so you cannot write image: webapp:${TAG} and expect TAG to come from a file listed under env_file. Each one silently fails to do the other’s job.
What is the precedence order for environment variables in Compose?
Docker documents it highest to lowest: docker compose run -e on the CLI, then environment or env_file with no value (which passes through from the shell), then the environment attribute, then the env_file attribute, then a Dockerfile ARG or ENV. Note there are two different precedence lists in the docs — one for interpolation and one for the container environment — and conflating them is a common source of confusion.
Does environment beat env_file?
Yes, and Docker’s docs add a detail people get caught by: this holds true even if those values are empty or undefined. So a stray FOO= under environment will override a perfectly good FOO in your env_file, with no warning.
Why did my image tag come out wrong?
Because an unset interpolated variable becomes the empty string rather than an error. image: postgres:${POSTGRES_VERSION} with POSTGRES_VERSION unset silently becomes postgres:, which the docs note is not a valid image reference. Compose does not stop you from writing it.
When does a Dockerfile ENV or ARG apply?
Only when Compose says nothing. The docs state that having any ARG or ENV setting in a Dockerfile evaluates only if there is no Docker Compose entry for environment, env_file or run –env. A Dockerfile default is a fallback, not a baseline you layer on top of.
Should I put secrets in environment variables?
Not if you can avoid it. Anything in the container environment is visible to docker inspect and to anyone who can reach the Docker socket, which on a Linux host is anyone in the docker group. Compose supports file-based secrets that land under /run/secrets, and that is the better tool for anything you would not paste in a screenshot.
Why does a missing --env-file error but a missing .env not?
Because they are treated differently on purpose. A path you named explicitly with –env-file is a hard error when it is absent. The default .env is optional, so its absence is silent. Asymmetric, and worth knowing when a stack works on your machine and not on the server.

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.

About the author

Written by Lowell K. Wood IV, who builds and runs TechFuelHQ from St. Louis, Missouri.