Early on, I kept bumping into the same frustrating problem: I'd set a variable in my .env file, Docker Compose would seem to ignore it, then I'd add it under environment: and it'd suddenly work — but then something else would break. I didn't understand the system.
Turns out Docker Compose doesn't have one mechanism for environment variables. It has three, and they interact in a specific precedence order that's non-obvious until you've been burned by it.
This guide covers all three mechanisms, how they rank against each other, the --env-file CLI flag most developers don't know about, when to switch to Docker Secrets instead, and the fixes for the most common failure modes.
TL;DR — Quick reference
Mechanism
Where defined
Interpolation support
Priority
docker compose run -e
CLI at runtime
Yes (from shell)
Highest
environment: with interpolated value
compose.yml
Yes — reads from shell / .env
2nd
environment: with plain literal
compose.yml
No
3rd
env_file: directive
compose.yml → external file
No (inside the file)
4th
Image ENV directive
Dockerfile
No
Lowest
The Three Mechanisms
1. environment: — inline in compose.yml
Define variables directly in your compose file:
yaml
services:app:environment:-DB_HOST=postgres-DB_PORT=5432# Or mapping syntax:API_URL:http://localhost:${HOST_PORT}
The key feature here is interpolation: values can reference shell variables or variables from your .env file using ${VAR} syntax. If HOST_PORT=3000 is set in your shell or .env, the above resolves to http://localhost:3000 at runtime.
Use environment: for:
Service-specific config that doesn't need to be shared across services
Values that depend on shell interpolation
Overrides on top of a shared env_file:
The downside: literal secrets in your compose file end up in version control. Never put passwords or tokens here as hardcoded values — use interpolation from a git-ignored .env file instead.
2. env_file: — load from external files
Tell Compose to load variables from one or more external files:
yaml
services:app:env_file:-./common.env-./app.env
The file format is simple — one KEY=value per line, # for comments:
Multiple files and load order: files listed later override those listed earlier. So if both common.env and app.env define DB_HOST, the value from app.env wins.
The required field (Compose v2.24.0+): by default, Compose throws an error if a listed env file doesn't exist. You can make files optional:
yaml
env_file:-path:./default.envrequired:true# default — error if missing-path:./override.envrequired:false# silently skip if missing
Useful for environment-specific overrides that may not exist in all deployment contexts.
The interpolation limitation:${VAR} syntax does not work inside .env files. If you write API_URL=${BASE_URL}/api inside an env file, Compose treats it as a literal string — BASE_URL won't expand. Use environment: in the compose file for anything that needs interpolation.
3. The automatic .env pickup
This is the one that catches most people. Docker Compose automatically loads a .env file from your project root (next to your compose.yaml) without any configuration. You don't need to declare it anywhere.
This file serves a different purpose from env_file: — it's primarily a source for variable interpolation inside compose.yaml itself, not a direct injection into the container environment.
env
# .env — auto-loaded from project root
HOST_PORT=3000
DB_PASSWORD=secret
yaml
# compose.yaml — uses values from .env via interpolationservices:app:ports:-"${HOST_PORT}:3000"environment:-DB_PASSWORD=${DB_PASSWORD}
If you don't specify --env-file and haven't set COMPOSE_DISABLE_ENV_FILE=true, this automatic loading always happens. It's documented behavior, not a side effect.
The automatic .env file is not the same as the env_file: directive. The .env auto-load provides variables for interpolation in compose.yaml. The env_file: directive injects variables directly into the container's environment. Both work simultaneously — they serve different purposes.
The Full Precedence Order
When the same variable is defined in multiple places, Docker Compose resolves it in this order (highest wins):
docker compose run -e VAR=value — CLI flag at runtime
environment: or env_file: with the value interpolated from shell or .env
environment: with a plain literal value
env_file: directive
ENV in the Dockerfile
A concrete example — DB_HOST defined in three places:
Result: the container sees DB_HOST=from-environment because environment: (level 3) beats env_file: (level 4).
Run docker compose run -e DB_HOST=from-cli app bash and the container sees from-cli — level 1 beats everything.
The --env-file CLI Flag
Most developers know about the env_file: directive in compose.yaml. Fewer know about the --env-file flag on the CLI itself — and they're different things.
bash
# Override the default .env with a specific file at runtime
docker compose --env-file ./config/.env.staging up
This flag replaces the automatic .env pickup for that command — it doesn't merge with it. It changes which file Compose uses as the interpolation source, not which variables get injected into containers.
You can pass multiple --env-file flags in one command. Later files override earlier ones:
bash
docker compose --env-file .env --env-file .env.override up
The practical use case: switching between environments without editing files.
bash
# Development
docker compose --env-file envs/dev.env up
# Staging
docker compose --env-file envs/staging.env up
Each file sets different values for DB_HOST, API_URL, etc. — same compose file, different environments, no file editing required.
--env-file (CLI flag) changes the interpolation source for compose.yaml. env_file: (compose.yaml directive) injects variables into the container. They look similar but operate at different layers.
When to Use Docker Secrets Instead
Env files work fine for most config. But there's a security boundary worth knowing: environment variables are visible in docker inspect.
Run docker inspect <container> on any running container and you'll see the full Env array in the output — including anything you passed via environment: or env_file:. For non-sensitive config this is fine. For database passwords, API keys, and tokens in production, it's a problem.
Docker Compose supports secrets, which are mounted as files under /run/secrets/<n> inside the container rather than exposed as environment variables:
Your app reads the secret from /run/secrets/db_password at runtime.
env_file vs secrets — when to use which:
env_file:
Docker Secrets
Visible in docker inspect
Yes (Env array)
No (mount only)
File format
KEY=value
Raw value (entire file)
Local dev complexity
Low
Low (Compose v2)
Production (Swarm/K8s)
⚠️ Avoid for secrets
✅ Native support
Best for
Non-sensitive config
Passwords, tokens, keys
For local development, the overhead difference is small. For production, if you're using Docker Swarm or Kubernetes, secrets are the right tool for anything sensitive.
Troubleshooting: env_file Not Working
Three failure modes come up constantly.
1. Quoted values are treated as literals
This is the most common one. In a .env file, quotes are not string delimiters — they're literal characters.
env
# This sets DB_PASSWORD to: "secret" (with the quotes)
DB_PASSWORD="secret"
# This sets DB_PASSWORD to: secret (correct)
DB_PASSWORD=secret
If your app is receiving "secret" instead of secret, this is why. Strip the quotes.
2. Path is relative to the compose file, not your terminal
The path in env_file: resolves relative to the compose.yaml location, not your current working directory. If you run docker compose from a parent directory, paths that look correct may not resolve.
yaml
# This resolves relative to where compose.yaml livesenv_file:-./config/app.env
If in doubt, use an absolute path or always run docker compose from the same directory as your compose.yaml.
3. UTF-8 BOM on Windows-created files
If you created the .env file on Windows (Notepad, certain editors), it may have a UTF-8 BOM (byte order mark) at the start of the file. Docker Compose can't parse this — the first variable name gets garbled or the file fails silently.
Fix: open the file in VS Code, check the encoding indicator in the bottom-right corner, and re-save as UTF-8 (not UTF-8 with BOM).
The Power Combo
The most maintainable pattern combines all three mechanisms in the right roles:
yaml
services:app:env_file:-./common.env# shared config across services-path:./local.envrequired:false# optional local overridesenvironment:-NODE_ENV=${NODE_ENV}# interpolated from .env or shell-DEBUG=false# service-specific, non-sensitive
# common.env (can be committed if non-sensitive)
DB_HOST=postgres
DB_PORT=5432
REDIS_URL=redis://cache:6379
The .env provides interpolation values for the compose file. common.env holds shared non-sensitive config. environment: handles service-specific values and anything needing shell interpolation. For actual secrets in production, graduate to Docker Secrets.
And for switching between dev/staging/prod: docker compose --env-file envs/staging.env up — no file editing, no mistakes.