Engineering9 min read
Docker Compose .env vs env_file: which variables reach the container?
Run a small Docker Compose example to see how .env interpolation, env_file and environment differ, including precedence and common loading failures.
Docker Compose uses a .env file in the project folder to fill in ${...} placeholders in compose.yaml, and it does not copy that file into your container. Variables reach the container only through env_file and environment in the service definition. So a value in .env shows up inside the container only when compose.yaml names it, for example as GREETING: ${GREETING}.
TL;DR
.envfeeds interpolation. It sets the image tag, ports and anything else written as${VAR}incompose.yaml.env_file:copies a whole file into the container.environment:sets single variables and wins overenv_file.docker compose run -ebeats both, and the image's ownENVloses to all of them.- For interpolation, a variable exported in your shell beats
--env-file, which replaces.enventirely. - Run
docker compose configto see the resolved file before you start anything.
Every output below comes from one small project, run with Docker Compose 5.6.1 and Docker Engine 29.9.2 from a clean shell. If you need a refresher on the file format itself, quoting and .env.example, read What is a .env file? first.
The example project
The project has three files. The container is plain Alpine and prints the six variables we care about, so you can see exactly what arrived.
services:
app:
image: alpine:${ALPINE_TAG}
env_file: app.env
environment:
GREETING: ${GREETING}
PORT: "8080"
DEBUG:
command: ["sh", "-c", "env | sort | grep -E '^(APP_NAME|DATABASE_URL|DB_PASSWORD|DEBUG|GREETING|PORT)='"]# Read by Compose to fill in ${...} in compose.yaml. Every value is fake.
ALPINE_TAG=3.20
GREETING=hello from .env
DB_PASSWORD=only-in-dot-env# Copied into the container by env_file. Every value is fake.
APP_NAME=Acme notes
PORT=3000
DATABASE_URL=postgres://db:5432/acmeDownload compose.yaml, sample.env (rename it to .env) and app.env into one folder, then run it:
docker compose run --rm appAPP_NAME=Acme notes
DATABASE_URL=postgres://db:5432/acme
GREETING=hello from .env
PORT=8080Four things already happened here. APP_NAME and DATABASE_URL came from app.env. GREETING came from .env, but only because compose.yaml asked for ${GREETING}. PORT is 8080 because environment beat the 3000 in app.env. And DB_PASSWORD, which sits in .env, never reached the container, while DEBUG is missing because nothing gave it a value.
What .env does: interpolation
When you run any docker compose command, Compose reads .env from the project directory and uses it, together with your shell, to replace every ${VAR} in compose.yaml. That is how alpine:${ALPINE_TAG} became alpine:3.20. The result is the resolved Compose model, and docker compose config prints it:
docker compose config environment:
APP_NAME: Acme notes
DATABASE_URL: postgres://db:5432/acme
DEBUG: null
GREETING: hello from .env
PORT: "8080"
image: alpine:3.20This is the most useful debugging command in this post. If a value is wrong here, it will be wrong in the container. docker compose config --environment lists the variables Compose used for interpolation, which includes everything in .env and everything your shell exports:
ALPINE_TAG=3.20
DB_PASSWORD=only-in-dot-env
GREETING=hello from .envCompose read DB_PASSWORD, but no line in compose.yaml asked for it, so it stopped there.
What env_file and environment do: the container
env_file and environment both set variables inside the running container, and they differ in scope. env_file keeps a service's whole runtime configuration out of compose.yaml, and each entry under environment sets one variable:
Entry | What the container gets |
|---|---|
env_file: app.env | Every line of the file |
PORT: "8080" | One variable with a fixed value |
GREETING: ${GREETING} | One variable, filled from the shell or .env |
DEBUG: | One variable, only when the shell or .env has it |
Two details are easy to miss. First, a bare name such as DEBUG: takes its value from the shell and also from .env. In the precedence test below, PORT: with no shell variable picked up PORT=7000 from .env. When neither has it, the variable is left out of the container, which is why DEBUG is missing in the first run.
Second, Compose interpolates the values inside an env_file too. With a line APP_NAME=${GREETING} app in the env file, the container received:
APP_NAME=hello from .env appIf you want a .env value in the container without naming each one, list .env as an env file as well. With env_file: [app.env, .env] the first run gains DB_PASSWORD=only-in-dot-env. This works, but it also hands the container ALPINE_TAG and anything else you added for interpolation, so keep the two files separate when you can.
Which value wins? The precedence matrix
To test precedence, each row below sets one variable, PORT, in a different combination of places and prints what the container received. The image is alpine:3.20 with ENV PORT=5000, app.env holds PORT=3000 and .env holds PORT=7000.
Where PORT is set | Container gets |
|---|---|
Image ENV only | 5000 |
Image, plus env_file | 3000 |
Image, env_file, plus environment: PORT: "8080" | 8080 |
The row above, plus PORT=9999 exported in the shell | 8080 |
The row above, plus docker compose run -e PORT=1234 | 1234 |
Image and env_file, plus run -e PORT=1234 | 1234 |
env_file, plus environment: PORT: ${PORT}, with .env | 7000 |
env_file, plus environment: PORT: ${PORT}, with .env and shell 9999 | 9999 |
env_file, plus bare environment: PORT:, with .env | 7000 |
env_file, plus bare environment: PORT:, with .env and shell 9999 | 9999 |
Read from strongest to weakest, the container gets its value from:
docker compose run -eon the command line.environment, where a${VAR}or bare name is filled from the shell first and.envsecond.env_file.- The image's
ENV.
The fourth row is the one that surprises people. Exporting PORT=9999 in your shell does nothing to the container when compose.yaml sets a fixed value, because a shell variable only reaches the container through ${PORT} or a bare PORT:. The same goes for DB_PASSWORD. Running the example with DB_PASSWORD=from-shell exported still left it out of the container.
The order matches the Docker precedence docs. They also list docker compose run -e above everything and the image ENV at the bottom.
Which value fills a placeholder? Shell, --env-file and .env
Interpolation has its own, shorter order. Each row below changes one thing about how docker compose run --rm app runs and shows the GREETING the container received:
How you run it | GREETING |
|---|---|
No extra options | hello from .env |
Shell exports GREETING | hello from the shell |
--env-file staging.env | hello from staging.env |
-f from another folder | hello from .env |
So the shell beats --env-file, and --env-file replaces .env. Replace is the important word. When the alternative file left out ALPINE_TAG, Compose did not fall back to .env for it:
level=warning msg="The \"ALPINE_TAG\" variable is not set. Defaulting to a blank string."
unable to get image 'alpine:': Error response from daemon: invalid reference formatThe last row matters for scripts and CI. We ran docker compose -f ../acme/compose.yaml from a folder with its own .env, and Compose still used acme/.env. It looks for .env in the project directory, which defaults to the folder that holds the first compose.yaml, and ignored the .env in the folder where we ran the command. env_file paths are resolved relative to the project directory as well, and --project-directory changes both.
Getting the values onto a new machine
Everything above assumes app.env already exists. On a fresh checkout it usually doesn't, because env files with real values stay out of Git. That handoff is where Sniptide fits: an env group holds the project's variables, encrypted at rest, and the CLI writes them to the file env_file already points at.
npm install --global @sniptide/cli
printf '%s' "$SNIPTIDE_TOKEN" | sniptide login --token-stdin
sniptide env pull acme-notes --file app.env
docker compose upThe pulled file starts with a comment such as # sniptide env acme-notes revision 1, which Compose skips like any other # line. compose.yaml doesn't change, so a teammate who writes app.env by hand runs exactly the same project. To push the file the first time, run sniptide env push acme-notes --file app.env.
You can also skip the file with sniptide env run acme-notes -- docker compose up. Then the values arrive as Compose's shell environment, so the precedence rules above apply: they fill ${VAR} and bare names, and they don't reach the container on their own.
Sniptide is a good fit when a solo developer or small team wants a Compose project's env file on every machine with one command, without sending it through chat. If you'd rather keep those values on your own server, Sniptide itself self-hosts with Docker Compose in one command on a Linux server with Docker and the Compose plugin:
curl -fsSL https://sniptide.com/install.sh | shPasswords and other sensitive values
Whichever way the file is written, env_file and environment hand the container plain environment variables. Anyone who can run docker inspect or read /proc inside the container can read them, and they often end up in logs and crash reports. Docker's own guidance is to use Compose secrets for passwords and keys that a running container needs, which mount each value as a file under /run/secrets/ instead. Env files work well for configuration and for local development, and production credentials deserve the secrets mechanism.
When the variable doesn't reach the container
- A
.envvalue is missing inside the container..envonly feeds interpolation. AddNAME: ${NAME}underenvironment, or list the file underenv_file. - A shell variable is ignored.
compose.yamlsets that name to a fixed value. Use${NAME}or a bareNAME:if the shell should be able to override it. - The value is blank and Compose warns
The "GREETNG" variable is not set. Defaulting to a blank string.A typo incompose.yaml, here${GREETNG}, gives an empty value and only a warning. Write placeholders as${GREETING:?set GREETING in .env}, and the same typo makes Compose stop withrequired variable GREETNG is missing a value: set GREETING in .envinstead. invalid reference formaton the image. The image tag came from a variable that was blank. Usually--env-filereplaced.envand the new file lacks the tag.- You edited
app.envand nothing changed.docker compose restartkeeps the container and its old environment. Rundocker compose up -d, which recreates the container with the new values. - The env file is in the wrong place. Paths are relative to the project directory. Check it with
docker compose config, and mark a file that may be absent asrequired: false(Compose 2.24 and later).
For the format of the files themselves, quoting rules and how other loaders read them, see What is a .env file?.
Sources
Checked on 12 November 2026.
- Docker docs: environment variables precedence, interpolation, set environment variables, best practices and secrets in Compose
- Sniptide: CLI, Sync .env files, encrypted secrets, self-hosting
- Tests: the compose.yaml, sample.env and app.env above, plus one-line variations for each table row, run with Docker Compose 5.6.1, Docker Engine 29.9.2 and alpine:3.20 from a shell started with
env -i