Docker Compose v2 (the docker compose CLI, written in Go and the default since Compose v1's docker-compose was retired in June 2023) offers five distinct mechanisms for environment variables, and they do not all reach the same place. Inline environment: and the env_file: directive inject values into the running container. .env (auto-loaded) and --env-file only feed ${VAR} substitution inside the Compose file itself — they never touch the container unless you wire them through environment:. The table below maps every form to its scope and where it sits in the precedence order.
| Form | What it does | Scope | Precedence |
|---|---|---|---|
| environment: (inline) | Sets vars directly on the container | Single service container | Overrides service env_file; run -e can override it |
| env_file: (YAML) | Loads one or more files into the container | Container environment | Lower than environment:; later files override earlier ones |
| .env (auto-loaded) | Provides values for ${VAR} in the YAML | Compose file interpolation only | Feeds substitution; not seen by the container |
| --env-file (CLI) | Replaces .env for substitution at parse time | Compose file interpolation only | Overrides .env for substitution; not seen by the container |
| ${VAR} substitution | Expands ${VAR} in YAML using shell + .env / --env-file | Compose file only | Shell values win over selected interpolation files |
Most "why is my variable missing?" tickets come from confusing env_file: with .env: the former injects into the container, the latter only feeds Compose-file substitution. When a substitution is missing entirely, Compose v2 prints WARN[0000] The "FOO" variable is not set. Defaulting to a blank string. — that warning is your fastest signal that .env or --env-file isn't wired up the way you think. For the equivalent rules when running plain docker run -e, see the Docker environment variables guide.
TL;DR
- Two destinations:
environment:andenv_file:reach the container;.env,--env-file, and${VAR}only feed substitution inside the Compose file. - The most common "missing variable" bug is confusing
env_file:(injects into the container) with.env(Compose-file interpolation only). Still missing a value at runtime? The Docker env variables not working guide maps each symptom to its cause and fix. - Resolve interpolation first. For container values,
docker compose run -eoverrides service configuration;environmentoverridesenv_file, which overrides imageENV. The host shell is not injected automatically. - A missing
env_file:path aborts the whole run — use the long form withrequired: false(Compose v2.24+, January 2024) to make a file optional.
How do I set environment variables inline in Docker Compose?
The simplest way to set environment variables in Docker Compose is directly in your docker-compose.yml:
services:
app:
image: node:20
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgres://db:5432/myappYou can also use the map syntax (without the dash):
services:
app:
image: node:20
environment:
NODE_ENV: production
PORT: "3000"Both formats are equivalent. The list syntax (- KEY=value) is more common in the wild. Note that NODE_ENV and PORT set here override anything the image baked in with ENV.
How does Docker Compose auto-load .env files?
Docker Compose automatically loads a file named .env in the same directory as your docker-compose.yml. Variables from this file are available for substitution in the YAML file itself — they are NOT automatically injected into containers.
Create a .env file:
# .env
POSTGRES_VERSION=16
APP_PORT=3000Reference these variables in docker-compose.yml with ${} syntax:
services:
db:
image: postgres:${POSTGRES_VERSION}
ports:
- "${APP_PORT}:5432"Important distinction: The .env file is for Compose file interpolation. To inject variables into the container's environment, use the env_file directive (next section).
Auto-loading is also switchable: set COMPOSE_DISABLE_ENV_FILE=1 to make Compose ignore the default .env entirely, or point COMPOSE_ENV_FILES=base.env,prod.env at a comma-separated list to replace it without repeating --env-file on every invocation.
What does the env_file directive do?
To inject environment variables directly into a container, use env_file:
services:
app:
image: node:20
env_file:
- ./app.env
- ./secrets.envWhere app.env contains:
# app.env
NODE_ENV=production
PORT=3000
API_KEY=sk-abc123Key behaviors:
- Variables are injected into the container's environment (visible via
docker exec <container> env) - Later files override earlier ones if the same variable appears
- Lines starting with
#are comments - Empty lines are ignored
- Default Compose env_file parsing removes surrounding quotes. Docker run --env-file and Compose format: raw differ.
- Quoted multiline values are supported by default Compose
env_fileparsing; Docker run differs. See the .env file syntax reference for the full quoting and parser-difference rules
When should I use the --env-file flag?
The --env-file flag replaces the default .env file for Compose file interpolation:
# Use .env.prod instead of .env for variable substitution
docker compose --env-file .env.prod up
# Combine with other flags
docker compose --env-file .env.staging up -dThis flag affects which file is used for ${} substitution in docker-compose.yml — it does NOT change what gets injected into containers via env_file.
The flag is repeatable, which most teams miss and work around by generating a merged file in CI. Pass it several times and Compose reads them in order, with later files overriding earlier ones — so you can keep a shared base and a thin per-environment overlay instead of duplicating every key:
# base.env holds the 40 shared keys, prod.env holds the 6 that differ
docker compose --env-file base.env --env-file prod.env up -d
# Same thing without repeating flags on every invocation
COMPOSE_ENV_FILES=base.env,prod.env docker compose up -dBoth forms replace the default .env rather than adding to it. Once you pass --env-file even once, an untouched .env sitting next to your Compose file is ignored completely.
How do I manage multiple .env files per environment?
A common pattern is maintaining separate env files per environment:
project/
├── docker-compose.yml
├── .env # Default/development values (auto-loaded)
├── .env.staging # Staging overrides
├── .env.production # Production overrides
├── app.env # App-specific vars (injected into container)
└── db.env # Database vars (injected into container)# docker-compose.yml
services:
app:
image: myapp:latest
env_file:
- ./app.env
environment:
- APP_ENV=${APP_ENV:-development}
db:
image: postgres:${POSTGRES_VERSION:-16}
env_file:
- ./db.envRun with different environments:
# Development (uses .env automatically)
docker compose up
# Staging
docker compose --env-file .env.staging up
# Production
docker compose --env-file .env.production up -dHow do I mix env sources per service?
Each service can have its own combination of inline variables and env files. If you use VS Code Dev Containers, the same per-service env layout drops straight into devcontainers — they integrate Docker Compose directly via dockerComposeFile:
services:
frontend:
image: nginx:alpine
environment:
- NGINX_PORT=80
backend:
image: node:20
env_file:
- ./backend.env
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://db:5432/app
db:
image: postgres:16
env_file:
- ./db.env
environment:
- POSTGRES_DB=myappWhich source wins?
Resolve interpolation and container injection separately. Exported shell values win over selected interpolation files; later repeated --env-file flags win over earlier ones. Default file discovery depends on project settings. Inspect docker compose config --environment.
For injection, explicit docker compose run -e KEY=value wins. Service environment overrides env_file, even when empty; these override image ENV. A shell value enters a container only through a reference or pass-through entry. Docker’s full precedence reference distinguishes interpolated and literal entries.
# compose.yaml — Compose v2.24+ / v5
services:
app:
image: alpine:3.22
env_file: app.env
environment:
MODE: ${MODE:-fallback}printf 'MODE=file\n' > app.env
MODE=shell docker compose config
# Resolved services.app.environment.MODE: shell
MODE=shell docker compose run --rm -e MODE=cli app printenv MODE
# cliWith MODE: inline, the resolved value is inline despite the shell value. Remove that entry and app.env supplies file. These are synthetic values; resolved configuration may disclose real credentials.
Check assignments with the env validator, then use the env converter for explicit container configuration.
How does variable substitution work in docker-compose.yml?
Docker Compose supports shell-like variable substitution in YAML:
services:
app:
image: myapp:${TAG:-latest}
ports:
- "${PORT:?PORT must be set}:3000"
environment:
- DEBUG=${DEBUG:-false}| Syntax | Behavior |
|---|---|
| ${VAR} | Value of VAR, empty if unset |
| ${VAR:-default} | Default if VAR is unset or empty |
| ${VAR-default} | Default only if VAR is unset |
| ${VAR:?error} | Error message if VAR is unset or empty |
| ${VAR?error} | Error message only if VAR is unset |
Check what Compose will resolve:
docker compose configThis prints the fully resolved docker-compose.yml with all variables substituted. For the rest of the CLI surface — profiles, depends_on health conditions, and develop.watch — see the Docker Compose cheat sheet, or the Docker cheat sheet for plain docker commands.
One non-obvious property: .env files interpolate themselves. A later line can reference an earlier one, so a single file can derive values instead of repeating them:
# .env
DEV_MODE=true
COMPOSE_DEBUG=${DEV_MODE:-false}
REGISTRY=ghcr.io/acme
IMAGE=${REGISTRY}/api:${TAG:-latest}This cuts both ways. A typo in an earlier key silently resolves to an empty string in every line that references it, and because the shell wins over the file, an exported REGISTRY on a developer's machine quietly rewrites IMAGE for everything downstream. Run docker compose config before blaming the registry.
Which COMPOSE_* variables configure Compose itself?
Separate from anything your services read, Compose reads its own set of variables that change how the CLI behaves. These are the ones worth knowing — set them in your shell, in .env, or per-command:
| Variable | What it controls | Default |
|---|---|---|
| COMPOSE_PROJECT_NAME | Prefix on every container, network, and volume | Directory basename |
| COMPOSE_FILE | Which Compose file(s) to load, separator-delimited | compose.yaml in cwd |
| COMPOSE_ENV_FILES | Comma-separated env files, replacing .env | .env in project dir |
| COMPOSE_DISABLE_ENV_FILE | Set to 1 to ignore the default .env entirely | 0 (auto-load on) |
| COMPOSE_PROFILES | Which service profiles activate on up | none active |
| COMPOSE_PATH_SEPARATOR | Separator used inside COMPOSE_FILE | : on Unix, ; on Windows |
| COMPOSE_REMOVE_ORPHANS | Auto-remove orphaned containers on update | 0 (leave them) |
| COMPOSE_PARALLEL_LIMIT | Max concurrent calls to the engine | unbounded |
| COMPOSE_ANSI | ANSI colour output: auto, never, always | auto |
| COMPOSE_PROGRESS | Progress renderer: auto, tty, plain, json, quiet | auto |
COMPOSE_PROJECT_NAME causes the most confusion of the set. Rename the directory holding your Compose file and Compose treats it as a brand-new project: it cannot find the old containers to stop, and the named volumes stay attached to the previous prefix. That reads exactly like the database losing its data, but docker volume ls still shows the old prefix holding it. Pin the name explicitly on anything long-lived.
COMPOSE_PROGRESS=plain and COMPOSE_ANSI=never are the two worth exporting in CI — the default TTY renderer emits cursor-movement escapes that turn build logs in GitHub Actions into unreadable single-line churn.
How should I manage secrets in Docker Compose?
Never commit secrets to version control. Use a committed .env.example to document required variables (the same convention covered in the .env file guide) alongside an ignored .env:
# .env.example (commit this — documents required variables)
DATABASE_URL=
API_KEY=
JWT_SECRET=
# .env (DO NOT commit — add to .gitignore)
DATABASE_URL=postgres://user:pass@db:5432/app
API_KEY=sk-live-abc123
JWT_SECRET=super-secret-keyAdd to .gitignore:
.env
.env.production
.env.staging
*.env
!.env.exampleA connection string like DATABASE_URL embeds the password in plaintext, so keep it out of the committed file. For production, consider Docker secrets:
services:
app:
image: myapp:latest
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txtWhat are common pitfalls and debugging tips?
Which parser reads the quotes?
Default Compose env_file parsing removes surrounding quotes and supports quoted multiline values. Docker run --env-file keeps quotes literally and reads physical lines. Compose v2.30+ format: raw preserves quotes and dollar signs. Do not transfer quoting assumptions between these mechanisms.
.env vs env_file confusion
.env(auto-loaded) → substitution indocker-compose.ymlonlyenv_filedirective → injected into container environment
Missing env_file aborts the whole run
If a path listed under env_file: doesn't exist, Compose v2 fails with env file ./app.env not found: stat ./app.env: no such file or directory before any container starts. Compose v2.24 (released January 2024) added the long form to make a file optional:
services:
app:
image: node:20
env_file:
- path: ./app.env
required: false # skip silently if missing
- path: ./secrets.envUse required: false for files that only exist in some environments (e.g. a developer-only overrides.env) so the same compose.yml works in CI without stub files.
Variables not updating
# Recreate containers to pick up env changes
docker compose up -d --force-recreate
# Or rebuild if using build-time args
docker compose up -d --buildDebugging current values
# See resolved compose file
docker compose config
# See container environment
docker exec <container> env
# See what .env file is loaded
docker compose config --environmentDid Docker Compose v5 change environment variable handling?
Mostly no — and that is the point worth knowing. Compose jumped straight from v2.x to v5.0.0 in December 2025, deliberately skipping 3.0 and 4.0 so nobody confuses the CLI version with the long-obsolete version: "3" field from old compose files. The headline changes are an official SDK and build delegation to Docker Bake; every .env, env_file, and interpolation behaviour on this page works identically on v2.24+ and v5 (v5.5.0, released 17 August 2026, is current).
Three env-relevant fixes did land, and the last two matter if you rely on required: false. v5.0.2 made --env-file=~/secrets/.env expand the tilde to your home directory instead of treating it as a literal path. v5.2.0 (23 June 2026) fixed docker compose publish ignoring required: false and failing on a missing file anyway. v5.4.0 (3 August 2026) extended that tolerance to scale, watch, and shell completion, which had each kept their own strict check. If you marked a file optional and a subcommand still aborts, you are on a Compose older than v5.4.
If a CI runner pins an older Compose — GitHub-hosted runners only moved to v2.40 in February 2026 — spell env file paths out absolutely and assume required: false is honoured only by up and config.
When should you not use environment variables in Compose?
Environment variables are the default answer for configuration, not the right answer for all of it. Four cases where reaching for environment: is the wrong call:
- Anything genuinely secret. A container's environment is not private. Any process in the container can read
/proc/1/environ,docker inspectprints every value in plaintext to anyone with socket access, and the values land indocker compose configoutput that people paste into issues. Use thesecrets:block, which exposes a file at/run/secrets/instead. - Multi-line or binary values. Default Compose parsing supports quoted multiline values, but Docker run --env-file differs. Binary values do not belong in environment strings. Mount structured credentials as files to avoid parser differences.
- Values that change without a restart. The environment is fixed at container creation. Anything you want to rotate — a feature flag, a log level, a credential on a schedule — needs a mounted file or a config service, because changing it here means
--force-recreateand dropped connections. - Large or deeply structured config. Once you are flattening nested config into
APP__DATABASE__POOL__MAX=20, the environment has stopped paying for itself. Mount a YAML or TOML file and keep the variables for the handful of values that genuinely differ per environment.
The dividing line that holds up in practice: environment variables are for values that differ between deployments and are safe to read in a crash dump. Everything else belongs in a file. The env variables security guide covers the leak paths in more detail.
References
- Docker Docs: Environment variables in Compose — official overview of every method covered above.
- Docker Docs: Environment variable precedence — authoritative ordering when the same variable is defined in multiple places.
- Docker Docs: Use secrets in Compose — recommended approach for production credentials instead of env vars.
- Compose file reference:
env_file— full schema for the directive, including format and per-file options. - The Twelve-Factor App: Config — the canonical argument for storing config in environment variables.