Environment Variables in Docker Compose Without the Confusion
by Eric Hanson, Backend Developer at Clean Systems Consulting
The variable that should be overriding but isn't
You've set LOG_LEVEL=debug in your shell. Your docker-compose.yml references ${LOG_LEVEL}. You run docker compose up, exec into the container, and echo $LOG_LEVEL prints info. The .env file in the project directory has LOG_LEVEL=info. Compose read the .env file instead of your shell variable, and you just spent twenty minutes debugging why your debug logs aren't appearing.
This is the precedence problem. Docker Compose has five or six ways to set environment variables and they override each other in a specific order that's not always intuitive.
The precedence order, top to bottom
From highest to lowest priority:
- Values set in the
environment:block of the service definition (hardcoded) - Variables from the shell environment where
docker composeruns - Variables from the
.envfile in the project directory (or the--env-filefile) - Compose file variable substitution defaults (
${VAR:-default})
Wait — the shell environment is higher priority than .env? Yes. But only for variable substitution in the Compose file itself, not for what the container receives.
The confusion comes from two distinct phases:
Phase 1: Compose file evaluation. Before Compose starts any containers, it reads the Compose file and substitutes variables. For this substitution, shell environment variables take precedence over the .env file.
Phase 2: Container environment. What gets passed into the container depends on how the service's environment: block is configured.
The conflict in the example: if LOG_LEVEL=info is in .env and the Compose file has:
environment:
LOG_LEVEL: ${LOG_LEVEL}
Then the Compose file evaluation uses the shell's LOG_LEVEL=debug to evaluate ${LOG_LEVEL}, so the service gets LOG_LEVEL=debug. Actually this would work correctly. The problem arises when the environment: block directly references the .env file via env_file::
env_file:
- .env
With env_file:, the file is read and its contents are passed directly into the container. Shell variables don't override this — env_file: is not a substitution mechanism, it's a direct pass-through.
The three mechanisms and when to use each
environment: with variable substitution — the most flexible:
services:
app:
environment:
DATABASE_URL: ${DATABASE_URL}
LOG_LEVEL: ${LOG_LEVEL:-info} # default of info if not set
DEBUG: ${DEBUG:-false}
Values come from shell environment or .env file (shell wins). Defaults are expressed inline. This is the most readable and recommended approach for most cases.
env_file: — for passing many variables from a file:
services:
app:
env_file:
- .env
- .env.local # optional local overrides
env_file: reads each file line by line and passes KEY=VALUE pairs directly into the container. Multiple files are processed in order; later files override earlier ones. Shell variables do NOT override env_file: values.
Use env_file: when you have many variables and don't want to enumerate them in the Compose file. But be aware: you lose the ability to see which variables a service uses just by reading the Compose file.
Hardcoded values — only for non-sensitive, rarely-changing values:
environment:
TZ: Asia/Singapore
LANG: en_US.UTF-8
The .env file: what it does and doesn't do
The .env file is read by Compose before the Compose file is evaluated. Its purpose is to provide values for variable substitution in the Compose file itself — things like image tags, port numbers, and other values you want to be configurable without changing the file.
# .env
APP_VERSION=1.4.2
DB_PORT=5432
# docker-compose.yml
services:
app:
image: your-registry/your-app:${APP_VERSION}
db:
ports:
- "${DB_PORT}:5432"
The .env file is NOT automatically passed into containers. The variables are available for Compose file substitution, but a service won't have APP_VERSION in its environment unless you explicitly put it in the environment: block.
This trips people up constantly. docker compose exec app env doesn't show .env variables unless they appear in an environment: block.
A practical setup that avoids confusion
Use the .env file for Compose-level configuration (image versions, host ports). Use a separate file for application secrets, loaded via env_file: or explicit environment: entries:
.env # Compose config, committed as .env.example, gitignored
.env.app # Application env vars, gitignored
.env.example # Template for both, committed
services:
app:
image: your-registry/your-app:${APP_VERSION}
env_file:
- .env.app
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # Compose-level, overridable from shell
ports:
- "${APP_PORT:-8080}:8080"
.env.example documents everything:
# Compose configuration
APP_VERSION=latest
APP_PORT=8080
LOG_LEVEL=info
# Application secrets (put these in .env.app)
DATABASE_URL=postgresql://user:password@db:5432/mydb
JWT_SECRET=changeme
Debugging variable resolution
When you're not sure what a service will receive:
# Show what Compose resolves variables to (Compose file substitution)
docker compose config
# Show environment variables inside a running container
docker compose exec app env | sort
# Show what env_file Compose would load
docker compose config --format json | jq '.services.app.environment'
docker compose config outputs the fully-resolved Compose file with all substitutions applied. This is the ground truth for what Compose will actually do.
The variable precedence for containers, precisely
Once you know the above, the container environment precedence is:
environment:block values (highest — explicitly set in Compose file)env_file:values (in order of files listed, later overrides earlier)- Variables with defaults not overridden by any of the above
Shell environment variables only participate via the Compose file evaluation phase (step 1 in the Compose file substitution chain). Once the environment: block is evaluated, shell variables don't directly influence the container — only what ended up in the environment: block matters.
Write this down somewhere your team can find it. The number of incidents I've seen caused by "my environment variable isn't being passed to the container" is significant, and the answer is almost always a misunderstanding of which mechanism takes precedence.