← All posts

Docker Compose Environment Variables: .env vs env_file Explained

Published

Docker Compose has two separate mechanisms that both involve a file called .env, and they do completely different things. Most confusion about Compose environment variables comes from not knowing which one you are using.

Mechanism one substitutes values into compose.yml itself. It replaces ${POSTGRES_VERSION} in the file with whatever .env says before Compose even reads the config.

Mechanism two passes variables into the running container. The container's process sees them; compose.yml does not.

They can use the same file. They frequently do. But a variable in .env does not reach the container unless you also tell Compose to pass it through, and this is the thing that catches everyone once.

Mechanism one: interpolation into compose.yml

Put a .env file next to your compose.yml:

POSTGRES_VERSION=16
APP_PORT=3000

Reference the values with ${...} in the Compose file:

services:
  db:
    image: postgres:${POSTGRES_VERSION}
  api:
    build: .
    ports:
      - "${APP_PORT}:3000"

Compose reads .env automatically and swaps the placeholders in before doing anything else. Run docker compose config and you will see the resolved file with postgres:16 and "3000:3000" written out.

This is only for values the Compose file needs: image tags, ports, volume paths, build args. The container does not see POSTGRES_VERSION unless you separately pass it in.

You can point at a different file with --env-file:

docker compose --env-file .env.staging up

Mechanism two: variables inside the container

There are three ways to get a variable into the container's environment.

The environment attribute

services:
  api:
    environment:
      DATABASE_URL: postgres://db:5432/app
      LOG_LEVEL: debug

Or the list form:

    environment:
      - DATABASE_URL=postgres://db:5432/app
      - LOG_LEVEL

A bare name with no value, like LOG_LEVEL above, passes the variable through from your shell. If it is not set in your shell, it is not set in the container.

The env_file attribute

services:
  api:
    env_file:
      - .env
      - .env.local

This reads every KEY=VALUE line from the listed files and puts them into the container. Later files override earlier ones. This is the mechanism people usually want when they say "load my .env into the container."

Note it is the same filename as mechanism one, but a different action. Naming a file .env gets you interpolation for free. It does not get you env_file behaviour unless you write env_file: .env.

Combining them

You can use interpolation to fill in the environment attribute:

services:
  api:
    environment:
      DATABASE_URL: ${DATABASE_URL}

Now .env supplies the value via mechanism one, and the environment attribute passes it into the container via mechanism two. This is explicit about what the container receives, which is why it is the pattern most production setups use.

Precedence when values conflict

Two separate orderings, because there are two mechanisms.

For interpolation into the Compose file, highest priority first:

  1. Variables set in your shell
  2. The file passed with --env-file
  3. The default .env in the project directory

Shell wins. If you export APP_PORT=4000 and .env says APP_PORT=3000, the Compose file gets 4000. This trips people up when a stale export from an earlier session silently overrides the file.

For the container's final environment, highest priority first:

  1. docker compose run -e KEY=value
  2. The environment attribute
  3. The env_file attribute
  4. ENV instructions in the image's Dockerfile

So a value in environment: beats the same key in an env_file, which beats whatever the image baked in.

Defaults and required variables

Compose supports shell style parameter expansion inside ${...}:

image: postgres:${POSTGRES_VERSION:-16}

If POSTGRES_VERSION is unset or empty, use 16.

ports:
  - "${APP_PORT:?APP_PORT must be set}"

If APP_PORT is unset or empty, fail immediately with that message. This is the right choice for anything the stack cannot run without, because a missing variable becomes a clear error at startup instead of a container that boots and then crashes.

The colon matters. ${VAR:-default} treats empty as missing. ${VAR-default} only applies the default if the variable is entirely unset, so an empty string is passed through as empty.

Debugging what the container actually got

Three commands answer nearly every "why is my variable wrong" question.

See the resolved Compose file with all interpolation done:

docker compose config

See the environment inside a running container:

docker compose exec api env | sort

Check one variable:

docker compose exec api sh -c 'echo $DATABASE_URL'

If config shows the right value but exec does not, you have mechanism one working and mechanism two missing. Add the variable to environment: or add the file to env_file:.

If config shows the wrong value, check your shell with env | grep VAR for a stale export that is overriding the file.

Secrets

The environment and env_file attributes put values into the container's environment, where anything running in the container can read them, and where docker inspect will print them. For local development that is fine. For anything with real credentials, Compose has a secrets mechanism that mounts values as files instead:

services:
  api:
    secrets:
      - db_password

secrets:
  db_password:
    file: ./db_password.txt

The container reads the value from /run/secrets/db_password rather than from $DB_PASSWORD. Not every application supports reading secrets from files, so it is a case by case decision.

Multiple environments

The common pattern is one Compose file with placeholders and one .env file per environment:

.env.development
.env.staging
.env.production

Then docker compose --env-file .env.staging up. The Compose file never changes; only the values do.

Commit .env.example with the keys and no values. Do not commit the real files. Add .env* to .gitignore and then explicitly un-ignore the example with !.env.example.

Sharing the .env files with a team

At this point you have three or four .env files, each with a database password and a handful of API keys, and every developer needs current copies. The usual solution is someone pastes them into Slack, which puts your production credentials in a chat log with whatever retention policy Slack has.

A secrets manager fixes this: one shared source, each developer runs a command to pull the current .env for whichever environment they need, and a rotated key shows up for everyone on the next pull. Krypt is the one we build, priced flat for the whole team, and this comparison covers the alternatives if you want to weigh options. For a solo project, .env files on disk are fine and you do not need any of it.

FAQ

Why does my container not see the variable from my .env file? Because .env next to compose.yml only does interpolation into the Compose file. To pass it into the container, add env_file: .env to the service, or reference it in environment: with ${VAR}.

Which wins, environment or env_file? environment wins. It has higher precedence than env_file, which has higher precedence than the image's ENV.

Why is Compose using an old value I already changed in .env? Almost certainly a shell export overriding it. Shell variables beat the .env file for interpolation. Run env | grep VARNAME and unset VARNAME.

How do I use a different .env per environment? docker compose --env-file .env.staging up. Or set COMPOSE_ENV_FILES in your shell, but note it has to be exported, not just assigned.

Can I use .env values in the Dockerfile? Not directly. Pass them as build args in the Compose file with build: args: and consume them with ARG in the Dockerfile. .env values are not visible during image build otherwise.

Does docker compose read .env automatically? For interpolation, yes, if it is in the project directory. For passing into containers, no, you need env_file:.