The Three-Way Fight
You’ve got an env var set in your .env file. You’ve also got it in your docker-compose.yml under environment:. And maybe your host system has it too. When the container starts, which one wins? Spoiler: it’s probably not the one you think.
This is the kind of mistake that only shows up on a Tuesday in production when someone deployed the stack differently than you tested it locally.
The Precedence Order (Highest to Lowest)
This is the actual order Docker Compose uses:
- Command-line override —
docker-compose run --env VAR=value - Shell environment variables —
export VAR=valuethen run compose .envfile in the Compose directory —.envnext todocker-compose.ymlenv_file:in the Compose file — explicitly loaded filesenvironment:in the Compose file — hardcoded values
Let me break down what that means in practice.
The .env File Gotcha
You’ve got this setup:
DATABASE_HOST=localhostDATABASE_PORT=5432DEBUG=falseversion: '3.8'services: app: image: myapp:latest environment: DATABASE_HOST: ${DATABASE_HOST} DATABASE_PORT: ${DATABASE_PORT} DEBUG: "true"What happens?
DATABASE_HOSTgetslocalhost(from.env)DATABASE_PORTgets5432(from.env)DEBUGgets"true"(hardcoded in compose,.envis ignored for this one)
The hardcoded DEBUG: "true" in your Compose file overrides the .env setting. This trips people up constantly.
When Your Shell Environment Wins
You’re on your dev machine. You do:
export DATABASE_HOST=prod.example.comdocker-compose upYour shell environment is higher priority than .env. The container gets prod.example.com even though .env says localhost.
This is actually useful for overrides, but it’s dangerous if you’re not careful. You might forget you exported something six hours ago and wonder why your containers are connecting to the wrong database.
Check what you’ve got set:
env | grep DATABASEExplicit env_file: Loading
You can load a specific file:
version: '3.8'services: app: image: myapp:latest env_file: .env.production environment: DEBUG: "false"This loads variables from .env.production, but anything in environment: still wins.
Load multiple files in order:
env_file: - .env - .env.local - .env.productionFiles are loaded left-to-right, so .env.production overwrites .env.local, which overwrites .env.
Then environment: wins over all of them.
The Real-World Disaster
You’re deploying to production. Your team has:
# docker-compose.yml (checked in)version: '3.8'services: db: image: postgres:15 environment: POSTGRES_PASSWORD: "dev_password_123"Someone forgot to create a .env file on the prod server. Container starts with dev_password_123.
Meanwhile, your security audit finds a hardcoded dev password in your database.
Better approach:
version: '3.8'services: db: image: postgres:15 environment: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}Then create .env (or .env.local and .gitignore it):
# .env.local (gitignored)POSTGRES_PASSWORD: "real_secure_password_here"Or use env_file::
version: '3.8'services: db: image: postgres:15 env_file: .env.secrets environment: POSTGRES_DB: myappDebugging What Actually Got Set
Want to see what your container actually received?
docker-compose exec app env | sortThis shows every env var inside the running container. Compare it to what you expected:
docker-compose configThis outputs the effective Compose config after all variable substitution. If ${VAR} isn’t being replaced, you’ll see it here and know to check precedence.
Best Practices
-
Never hardcode secrets in
docker-compose.yml. Always use${VAR}and load from.envorenv_file:. -
Use
.envfor local dev,.env.productionfor prod. Compose will look for.envby default, but you can specify others withenv_file:. -
Check
docker-compose configbefore deploying. See what actually got substituted. -
Export shell vars only when you need them. They shadow everything else, which is great for quick overrides but terrible for accidental pollution.
-
Be explicit about precedence. If you want to guarantee a value doesn’t get overridden, hardcode it in
environment:. If you want it to be overridable, use${VAR}and set it elsewhere.
Compose V2 and —env-file
Docker Compose V2 added --env-file flag to load a specific file instead of .env:
docker-compose --env-file .env.staging up -dThis replaces the default .env lookup with .env.staging. Useful for environment-specific deployments without editing files:
# Devdocker-compose --env-file .env.local up -d
# Stagingdocker-compose --env-file .env.staging up -d
# Productiondocker-compose --env-file .env.production up -dNote: this flag controls variable substitution in the Compose file itself. The env_file: key inside services still works separately.
Check Before You Deploy
Before every deploy, run:
docker-compose configThis prints the final resolved Compose config with all variables substituted. If you see ${VAR} anywhere, that variable isn’t getting set — and your container will start with a blank or broken value.
This stuff seems simple until it’s 2 AM and your containers are connecting to the wrong database because a shell variable from yesterday’s testing session is still set.