env.dev

Docker Compose Environment Variables: The Complete Guide

Use environment variables in Docker Compose: .env auto-load, env_file, --env-file, multi-environment patterns, substitution, and secrets.

By env.dev Updated

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.

FormWhat it doesScopePrecedence
environment: (inline)Sets vars directly on the containerSingle service containerOverrides service env_file; run -e can override it
env_file: (YAML)Loads one or more files into the containerContainer environmentLower than environment:; later files override earlier ones
.env (auto-loaded)Provides values for ${VAR} in the YAMLCompose file interpolation onlyFeeds substitution; not seen by the container
--env-file (CLI)Replaces .env for substitution at parse timeCompose file interpolation onlyOverrides .env for substitution; not seen by the container
${VAR} substitutionExpands ${VAR} in YAML using shell + .env / --env-fileCompose file onlyShell 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: and env_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 -e overrides service configuration; environment overrides env_file, which overrides image ENV. The host shell is not injected automatically.
  • A missing env_file: path aborts the whole run — use the long form with required: 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:

yaml
services:
  app:
    image: node:20
    environment:
      - NODE_ENV=production
      - PORT=3000
      - DATABASE_URL=postgres://db:5432/myapp

You can also use the map syntax (without the dash):

yaml
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:

ini
# .env
POSTGRES_VERSION=16
APP_PORT=3000

Reference these variables in docker-compose.yml with ${} syntax:

yaml
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:

yaml
services:
  app:
    image: node:20
    env_file:
      - ./app.env
      - ./secrets.env

Where app.env contains:

ini
# app.env
NODE_ENV=production
PORT=3000
API_KEY=sk-abc123

Key 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_file parsing; 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:

bash
# 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 -d

This 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:

bash
# 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 -d

Both 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:

text
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)
yaml
# 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.env

Run with different environments:

bash
# Development (uses .env automatically)
docker compose up

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

# Production
docker compose --env-file .env.production up -d

How 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:

yaml
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=myapp

Which 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.

yaml
# compose.yaml — Compose v2.24+ / v5
services:
  app:
    image: alpine:3.22
    env_file: app.env
    environment:
      MODE: ${MODE:-fallback}
bash
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
# cli

With 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:

yaml
services:
  app:
    image: myapp:${TAG:-latest}
    ports:
      - "${PORT:?PORT must be set}:3000"
    environment:
      - DEBUG=${DEBUG:-false}
SyntaxBehavior
${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:

bash
docker compose config

This 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:

ini
# .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:

VariableWhat it controlsDefault
COMPOSE_PROJECT_NAMEPrefix on every container, network, and volumeDirectory basename
COMPOSE_FILEWhich Compose file(s) to load, separator-delimitedcompose.yaml in cwd
COMPOSE_ENV_FILESComma-separated env files, replacing .env.env in project dir
COMPOSE_DISABLE_ENV_FILESet to 1 to ignore the default .env entirely0 (auto-load on)
COMPOSE_PROFILESWhich service profiles activate on upnone active
COMPOSE_PATH_SEPARATORSeparator used inside COMPOSE_FILE: on Unix, ; on Windows
COMPOSE_REMOVE_ORPHANSAuto-remove orphaned containers on update0 (leave them)
COMPOSE_PARALLEL_LIMITMax concurrent calls to the engineunbounded
COMPOSE_ANSIANSI colour output: auto, never, alwaysauto
COMPOSE_PROGRESSProgress renderer: auto, tty, plain, json, quietauto

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:

ini
# .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-key

Add to .gitignore:

text
.env
.env.production
.env.staging
*.env
!.env.example

A connection string like DATABASE_URL embeds the password in plaintext, so keep it out of the committed file. For production, consider Docker secrets:

yaml
services:
  app:
    image: myapp:latest
    secrets:
      - db_password
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

What 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 in docker-compose.yml only
  • env_file directive → 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:

yaml
services:
  app:
    image: node:20
    env_file:
      - path: ./app.env
        required: false   # skip silently if missing
      - path: ./secrets.env

Use 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

bash
# Recreate containers to pick up env changes
docker compose up -d --force-recreate

# Or rebuild if using build-time args
docker compose up -d --build

Debugging current values

bash
# See resolved compose file
docker compose config

# See container environment
docker exec <container> env

# See what .env file is loaded
docker compose config --environment

Did 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 inspect prints every value in plaintext to anyone with socket access, and the values land in docker compose config output that people paste into issues. Use the secrets: 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-recreate and 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

Was this helpful?

Frequently Asked Questions

Can I use multiple .env files at once with Docker Compose?

Yes. Use explicit files to avoid depending on default discovery. Pass --env-file repeatedly (later files override earlier ones) or set COMPOSE_ENV_FILES to a comma-separated list. Both replace the default .env rather than adding to it. For container-level files, env_file in the YAML also accepts a list.

What is the difference between environment and env_file in Docker Compose?

environment sets variables inline in the YAML. env_file loads variables from a file. Both inject into the container. environment values take priority over env_file values.

Does docker compose --env-file affect env_file in the YAML?

They select different files. --env-file supplies interpolation values; service env_file selects container inputs. Expressions in service configuration may depend on the interpolation environment.

Do I need docker-compose.yml or compose.yml?

Both work. Docker Compose V2 (the docker compose command) prefers compose.yml but supports both. Use whichever your team prefers.

How do I pass host environment variables to a container?

List the variable name without a value in the environment section (e.g. - MY_HOST_VAR). Docker Compose passes the host's current value of MY_HOST_VAR into the container.

Did Docker Compose v5 change how .env files work?

No. Compose jumped from v2.x straight to v5.0.0 (December 2025), skipping 3.0 and 4.0 to avoid confusion with the obsolete version: field in compose files. All .env, env_file, and interpolation behaviour is unchanged. Three fixes did land: v5.0.2 expands a tilde in --env-file paths, v5.2.0 made docker compose publish honour env_file required: false, and v5.4.0 extended that to scale, watch, and shell completion.

How do I stop Docker Compose from auto-loading the .env file?

Set COMPOSE_DISABLE_ENV_FILE=1 (or true) and Compose ignores the default .env entirely. To swap in different files instead, set COMPOSE_ENV_FILES to a comma-separated list, which behaves like passing --env-file for each entry.

Can a .env file reference its own variables?

Yes. Compose interpolates .env files themselves, so a later line can reference an earlier key — COMPOSE_DEBUG=${DEV_MODE:-false} works. The catch is that the shell still wins over the file, so an exported value on a developer machine silently rewrites every line that depends on it. Check with docker compose config.

Why did my Compose database appear to lose its data after renaming the folder?

It did not. COMPOSE_PROJECT_NAME defaults to the directory basename and prefixes every container, network, and volume. Rename the directory and Compose sees a brand-new project, leaving the old named volumes orphaned under the previous prefix. docker volume ls still shows them. Set COMPOSE_PROJECT_NAME explicitly on anything long-lived.

Stay up to date

Get notified about new guides, tools, and cheatsheets.