Fix: Docker Compose Environment Variables Not Loading from .env File
Quick Answer
How to fix Docker Compose not loading environment variables from .env files — why variables are empty or undefined inside containers, the difference between env_file and variable substitution, and how to debug env var issues.
The Error
Your docker-compose.yml references environment variables, but inside the container they are empty or undefined:
docker compose exec app printenv DATABASE_URL
# (empty output — variable not set)Or the compose file itself uses variable substitution and warns:
WARN[0000] The "DATABASE_URL" variable is not set. Defaulting to a blank string.Or the application crashes because it cannot read a required environment variable that you are sure is in .env:
Error: DATABASE_URL environment variable is requiredOr after changing .env, the containers still use old values:
docker compose up # Still shows old DATABASE_URL valueWhy This Happens
Docker Compose has two distinct ways to use .env files, and confusing them is the root cause of most variable issues:
Variable substitution in
docker-compose.yml— Compose automatically reads.env(in the same directory as the compose file) to substitute${VAR}placeholders in the compose file itself. This happens on the host, before containers start.Environment variables inside containers — Variables are only passed into containers if you explicitly configure them via
environment:orenv_file:in the service definition. Reading.envfor substitution does not automatically inject those variables into the container environment.
Other common causes:
- Wrong
.envfile location —.envmust be in the same directory asdocker-compose.yml, not in a subdirectory. - Stale containers — containers started with
docker compose upwithout--force-recreateafter changing.envkeep the old environment. - Variable names with export prefix —
export DATABASE_URL=valuein.envdoes not work; Compose expects bareKEY=valueformat. - Quoting issues —
DATABASE_URL="postgres://..."with quotes includes the quotes in the value.
Fix 1: Understand the Two env Variable Mechanisms
Mechanism A — Compose file substitution (host-side):
# docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${DB_NAME} # Substituted from .env on the HOST
POSTGRES_PASSWORD: ${DB_PASS} # before the container starts# .env (same directory as docker-compose.yml)
DB_NAME=myapp
DB_PASS=secret123This sets POSTGRES_DB=myapp in the container — but only because it is listed under environment:. The .env file is used to resolve ${DB_NAME}, not to inject variables directly.
Mechanism B — env_file injects variables directly into the container:
# docker-compose.yml
services:
app:
image: myapp:latest
env_file:
- .env # All KEY=VALUE pairs in this file are injected into the container
- .env.local # Multiple files are merged (later files override earlier)With env_file, every KEY=VALUE line in .env becomes an environment variable inside the container — no need to list them individually under environment:.
Combine both mechanisms:
services:
app:
image: myapp:latest
env_file:
- .env # Injects all vars into container
environment:
NODE_ENV: production # Override specific vars
API_URL: ${EXTERNAL_API_URL} # Also use substitution for compose-level varsFix 2: Pass Variables Explicitly with environment:
If you want explicit control over exactly which variables reach the container:
# docker-compose.yml
services:
app:
build: .
environment:
# Pass a hardcoded value
NODE_ENV: production
# Pass from the host shell environment
DATABASE_URL: ${DATABASE_URL}
# Pass from host with a default if not set
LOG_LEVEL: ${LOG_LEVEL:-info}
# Pass a host variable with the same name (shorthand)
SECRET_KEY: # No value — inherits from host environment or .env substitutionVerify the variable is set on the host before running compose:
# Check host environment
echo $DATABASE_URL
# Or check what compose resolves to (dry run)
docker compose config
# Shows the fully resolved docker-compose.yml with all substitutions appliedFix 3: Fix .env File Location and Format
Check the file location:
# .env must be alongside docker-compose.yml
ls -la
# Should show:
# .env
# docker-compose.yml
# If you have a different filename, specify it explicitly
docker compose --env-file ./config/.env.production upCheck the file format — no export, no surrounding quotes:
# Correct format
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
API_KEY=abc123
# Wrong — 'export' prefix is a shell syntax, not supported by Compose
export DATABASE_URL=postgres://user:pass@localhost:5432/mydb
# Wrong — surrounding quotes are included in the value
DATABASE_URL="postgres://user:pass@localhost:5432/mydb"
# The container receives: "postgres://user:pass@localhost:5432/mydb" (with quotes)
# Correct for values with spaces or special characters — use no quotes
DATABASE_URL=postgres://user:pass@localhost:5432/mydbNote: Double quotes in
.envare stripped by some tools (likedotenvlibraries) but kept literally by Docker Compose. Always use unquoted values in.envfiles used by Docker Compose, or test withdocker compose configto verify the resolved value.
Validate your .env file:
# Show all variables Compose would resolve
docker compose config | grep -A5 environment
# Print a specific variable's resolved value
docker compose run --rm app printenv DATABASE_URLFix 4: Recreate Containers After Changing .env
Changing .env does not automatically update running containers. You must recreate them:
# Stop and remove existing containers, then start fresh
docker compose down
docker compose up -d
# Or force-recreate without removing volumes
docker compose up -d --force-recreate
# Verify the new value is live
docker compose exec app printenv DATABASE_URLCommon Mistake: Running
docker compose restartafter changing.env.restartdoes not recreate containers — it only stops and starts the existing container with its original environment. Usedown+upor--force-recreate.
Fix 5: Use Multiple .env Files for Different Environments
# docker-compose.yml — base config
services:
app:
image: myapp
env_file:
- .env
# docker-compose.override.yml — development overrides (loaded automatically)
services:
app:
env_file:
- .env
- .env.development# Development (loads docker-compose.yml + docker-compose.override.yml automatically)
docker compose up
# Production (only use the base file)
docker compose -f docker-compose.yml up
# Staging (explicit env file)
docker compose --env-file .env.staging upStructure for multiple environments:
.env # Default values (committed to git — no secrets)
.env.development # Dev-specific overrides (not committed)
.env.production # Production values (not committed — use secrets manager)
.env.example # Template with all keys, no values (committed to git)Fix 6: Debug Environment Variables Inside a Running Container
Print all environment variables in a running container:
# All variables
docker compose exec app env
# Specific variable
docker compose exec app printenv DATABASE_URL
# From outside, without exec
docker inspect app-container-name | grep -A5 '"Env"'Run a temporary container with the same environment to debug:
# One-shot container with your env_file — does not start the app
docker compose run --rm app env | sort
# Or start a shell
docker compose run --rm app sh
# Inside: echo $DATABASE_URLCheck if variables are set at the time the process reads them:
Some frameworks (like Node.js with dotenv) load .env themselves at runtime. If you are using env_file in Compose AND dotenv in the app, the app’s .env inside the container might override or conflict with the Compose-injected variables:
// If your app has dotenv, it reads /app/.env (inside the container)
require('dotenv').config();
// This .env is different from the .env on your host that Compose reads
// Either remove dotenv and rely on Compose env_file, or mount the .env fileMount the .env file into the container if the app reads it directly:
services:
app:
image: myapp
volumes:
- ./.env:/app/.env:ro # App reads its own .env at runtimeFix 7: Secrets — Avoid Storing Sensitive Values in .env
For production, use Docker secrets or a secrets manager instead of .env files:
# docker-compose.yml — using Docker secrets
services:
app:
image: myapp
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt # On host — outside version control# In your application — read from the secrets file
const dbPassword = fs.readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim();For Docker Swarm or production environments, use external secrets:
secrets:
db_password:
external: true # Managed by Docker Swarm or Kubernetes secretsStill Not Working?
Run docker compose config to see the resolved compose file. This shows exactly what values Compose will use after substitution — if a variable shows as blank there, it was not set in .env or the host environment:
docker compose config
# Look for empty values like: DATABASE_URL: ''Check for BOM or encoding issues in .env. Files saved with a BOM (Byte Order Mark) from Windows editors can cause the first variable to be misread:
file .env # Should say "ASCII text" not "UTF-8 Unicode (with BOM)"
hexdump -C .env | head # BOM appears as EF BB BF at the startFix with:
sed -i '1s/^\xEF\xBB\xBF//' .envCheck for trailing whitespace in values:
cat -A .env | grep "DATABASE_URL"
# DATABASE_URL=postgres://...$ (correct — $ marks end of line with no trailing space)
# DATABASE_URL=postgres://... $ (wrong — trailing space before $)For related Docker issues, see Fix: dotenv Not Loading and Fix: Docker Compose depends_on Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AWS ECS Task Failed to Start
How to fix ECS tasks that fail to start — port binding errors, missing IAM permissions, Secrets Manager access, essential container exit codes, and health check failures.
Fix: Docker Multi-Stage Build COPY --from Failed
How to fix Docker multi-stage build errors — COPY --from stage not found, wrong stage name, artifacts not at expected path, and BuildKit caching issues.
Fix: Linux OOM Killer Killing Processes (Out of Memory)
How to fix Linux OOM killer terminating processes — reading oom_kill logs, adjusting oom_score_adj, adding swap, tuning vm.overcommit, and preventing memory leaks.
Fix: Certbot Certificate Renewal Failed (Let's Encrypt)
How to fix Certbot certificate renewal failures — domain validation errors, port 80 blocked, nginx config issues, permissions, and automating renewals with systemd or cron.