.env Files
Compose pulls environment values from .env files, CLI flags, and the host environment in a defined order. Knowing the precedence and the difference between Composes own variables and the variables your container sees stops the "why is X set in compose.yaml but missing inside the container?" confusion.
env_file, environment, variable interpolation, secrets
EXAMPLE
# compose.yaml — three places env vars live
services:
api:
image: ${IMAGE_API:-shop/api:dev} # Composes interpolation; uses .env or shell
environment:
NODE_ENV: ${NODE_ENV:-production} # Container env: from interpolated host var
DB_HOST: db # Container env: literal
APP_VERSION: '1.4.0'
env_file:
- ./envs/common.env # Container env: file
- ./envs/api.env # later file overrides earlier
secrets:
- api_signing_key # mounted at /run/secrets/api_signing_key
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD?required} # FAILS if .env / shell does not set DB_PASSWORD
POSTGRES_DB: shop
POSTGRES_USER: shop
secrets:
api_signing_key:
file: ./secrets/api_signing_key.txt
# ===== Precedence — first match wins for variable interpolation =====
# 1) Shell environment when running 'docker compose ...'
# 2) --env-file flag value(s)
# 3) The default .env file in the project root
# 4) The default specified after :- in compose.yaml (${X:-default})
# ===== env_file vs environment =====
# environment: keys/values inside compose.yaml; can use Compose interpolation
# env_file: a file of KEY=VALUE; loaded into the container
# MULTIPLE files allowed; later overrides earlier
# Both inject into the CONTAINER. The interpolation step has already happened.
# ===== Composes interpolation operators =====
# ${X} error if X is empty
# ${X:-default} use 'default' if X is unset OR empty
# ${X-default} use 'default' if X is unset (empty is fine)
# ${X:?msg} FAIL with 'msg' if X is unset or empty (use for required prod values)
# ${X?msg} FAIL with 'msg' if X is unset (empty allowed)
# ${X:+value} use 'value' if X is set AND non-empty
# ${X+value} use 'value' if X is set
# ===== Confirm what compose ACTUALLY sees =====
docker compose config
# Prints the fully-interpolated YAML; if a var is wrong, you see it here.
# ===== Confirm what the CONTAINER sees =====
docker compose exec api env | sort
# ===== .env files vs --env-file =====
# .env in project root is auto-loaded by Compose.
# Use --env-file when you have per-environment files:
docker compose --env-file ./envs/staging.env up -d
docker compose --env-file ./envs/prod.env up -d
# ===== Secrets (top-level 'secrets:' block) =====
# - Mounted to /run/secrets/<name> inside the container as a file
# - Not part of environment by default; the app reads the file
# - Avoid putting secrets in 'environment:' — they leak via 'docker inspect',
# container logs, and crash dumps
# ===== Multiple environments — override files =====
# compose.yaml : base
# compose.dev.yaml : dev tweaks (mounts source, exposes DB port)
# compose.prod.yaml : prod tweaks (no mounts, tighter limits)
docker compose -f compose.yaml -f compose.dev.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml up -d
# ===== Common pitfalls =====
# - Setting NODE_ENV in .env but expecting it in the COMPOSE-FILE interpolation
# only (not the container). .env values DO appear in both via interpolation,
# but only env_file values guarantee they reach the container regardless of
# interpolation typos.
# - Quoting values in .env: do NOT wrap in quotes; Compose does not strip them.
# - Trailing whitespace in .env lines — bites you on multi-line values.
# - Using 'environment:' for production passwords; switch to 'secrets:'.
# - Forgetting that --env-file replaces the default .env (it does not stack).
# ===== Checklist before pushing to prod =====
# 1) Every secret comes from --env-file <env-specific> OR 'secrets:'
# 2) Use ${VAR:?msg} for variables that MUST be set
# 3) 'docker compose config' shows the fully resolved YAML
# 4) No plain passwords inside compose.yaml itself
# 5) Per-env override files are committed (without the secret values)
Why it matters
Use `${VAR:?required}` on every value that must be set in production. The failure mode flips from "container starts, then crashes on first request" to "compose refuses to start with a clear message" — and the deploy never lands in the half-broken state that comes from a silently-empty env var.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# .env at repo root
POSTGRES_PASSWORD=secret
# referenced via ${POSTGRES_PASSWORD} in compose.yaml
Try it Yourself »
Discussion
Loading…