Fix: Docker Compose Environment Variables Not Loading from .env File
Part of: Docker, DevOps & Infrastructure
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.
The mental model that fails most teams is treating .env as a single “secrets file” that magically reaches every service. It does not. Compose treats .env as a configuration file for the compose run itself. The values in it become available to the YAML parser, the same way a shell variable becomes available to envsubst. Whether those values reach a container’s process environment is a separate, explicit decision made via environment: or env_file:.
The second source of confusion is that the same word — environment — refers to three different surfaces. The shell environment of the developer who runs docker compose up, the substitution context Compose builds from .env plus that shell, and the container’s /proc/<pid>/environ. Compose merges the first two when it parses the YAML, then writes a subset into the third based on your service definitions. A variable can be present in any one of these surfaces without being present in the others, and docker compose config is the only reliable way to see what Compose actually resolved.
Finally, encoding and formatting bugs amplify the confusion. A .env file saved as UTF-16 from Notepad, a CR/LF line ending from a Windows editor, or an accidentally quoted value all produce silent failures — Compose does not validate .env and does not warn when a value looks suspicious. The container starts with a malformed variable and the application crashes far downstream, usually with an unhelpful “URL parse error” or “invalid token” message.
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 secretsIn Production: Incident Lens
Missing environment variables are responsible for a disproportionate share of bad deploys. The deploy pipeline runs docker compose up -d, the container starts, the application reads DATABASE_URL, gets an empty string, parses it as "postgres://", and either crashes on the first request or — much worse — connects to whatever the empty string happens to alias to. Either way, you find out when your error budget is already on fire.
Blast radius. A missing env var has two failure modes, and the second is the dangerous one. Mode A: the container exits during startup. The orchestrator restarts it, sees a crash loop, and marks the service unhealthy. Your healthcheck fails, traffic does not route, and an alert fires. Mode B: the container starts with a malformed but non-empty value — for example, a stale staging URL committed to .env.example. The application connects, talks to the wrong backend, and writes production data into staging or vice versa. Mode B can run for hours before anyone notices because every metric looks green.
Alert on startup health, not just runtime errors. Every service should expose a /health endpoint that verifies critical environment variables resolve to real, reachable resources — not just “not empty.” The healthcheck should ping the database, verify the auth secret is at least N bytes, and return 503 if any required env var fails validation. Configure Compose’s healthcheck block to wait on that endpoint so docker compose up --wait exits non-zero when configuration is broken. CI gates that run docker compose up --wait --abort-on-container-exit against a smoke-test compose file catch most of these before they reach production.
Recovery. When a service crashes on startup with an env-var error, do not edit .env on the running host and hope. Roll back to the previous compose project state first (docker compose down && git checkout <prev-sha> && docker compose up -d), restore service, then diagnose. The most common cause is a freshly added required variable that was committed to .env.example but never to the deploy host’s real .env. The recovery script is one-liner: diff <(grep -oE '^[A-Z_]+' .env.example) <(grep -oE '^[A-Z_]+' .env) shows the missing keys.
Preventive design. Commit .env.example with every variable name your services touch (no values) and write an entrypoint script that fails fast if any required variable is missing. A 20-line shell function — for each name in a hardcoded list, exit 1 if printenv returns empty — turns a silent miscommit into a loud, debuggable error during docker compose up. Add a CI step that runs docker compose --env-file .env.example config and asserts no WARN[0000] The "X" variable is not set lines appear in stderr; that single check would have prevented most env-var incidents we have seen. For secrets, do not put them in .env at all — use Docker secrets, AWS Secrets Manager, or Vault, and let .env carry only the names of the secrets to fetch.
Still 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 $)Inspect CRLF line endings on Windows-edited files. A file edited on Windows and copied to a Linux host can carry \r\n line endings. Compose treats the trailing \r as part of the value, so DATABASE_URL=postgres://host\r becomes a connection string that no driver can parse. Run file .env and look for “ASCII text, with CRLF line terminators” — fix with dos2unix .env or sed -i 's/\r$//' .env.
Watch for variable-name collisions with your shell. If you export DATABASE_URL in your shell, that value wins over .env during substitution but does not propagate into containers unless you list it under environment:. The result: docker compose config shows the shell value but the container sees the .env value (or nothing). When debugging, run Compose from a clean shell: env -i PATH=$PATH docker compose config shows what a fresh CI runner would see.
Confirm env_file paths are interpreted relative to the compose file, not your CWD. A nested env_file: ../secrets/.env works from the project root but breaks when CI runs Compose from a different directory. Use absolute paths or run Compose with --project-directory set explicitly.
For related Docker issues, see Fix: dotenv Not Loading, Fix: Docker Compose depends_on Not Working, Fix: Docker Compose Healthcheck Not Working, and Fix: Docker Compose Service Failed to Build.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Docker Container Keeps Restarting
How to fix a Docker container that keeps restarting — reading exit codes, debugging CrashLoopBackOff, fixing entrypoint errors, missing env vars, out-of-memory kills, and restart policy misconfiguration.
Fix: Coolify Not Working — Deployment Failing, SSL Not Working, or Containers Not Starting
How to fix Coolify self-hosted PaaS issues — server setup, application deployment, Docker and Nixpacks builds, environment variables, SSL certificates, database provisioning, and GitHub integration.
Fix: Docker Secrets Not Working — BuildKit --secret Not Mounting, Compose Secrets Undefined, or Secret Leaking into Image
How to fix Docker secrets — BuildKit secret mounts in Dockerfile, docker-compose secrets config, runtime vs build-time secrets, environment variable alternatives, and verifying secrets don't leak into image layers.
Fix: Docker Compose Healthcheck Not Working — depends_on Not Waiting or Always Unhealthy
How to fix Docker Compose healthcheck issues — depends_on condition service_healthy, healthcheck command syntax, start_period, custom health scripts, and debugging unhealthy containers.