Skip to content

Fix: Docker Compose Environment Variables Not Loading from .env File

FixDevs · (Updated: )

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 required

Or after changing .env, the containers still use old values:

docker compose up  # Still shows old DATABASE_URL value

Why This Happens

Docker Compose has two distinct ways to use .env files, and confusing them is the root cause of most variable issues:

  1. 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.

  2. Environment variables inside containers — Variables are only passed into containers if you explicitly configure them via environment: or env_file: in the service definition. Reading .env for 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 .env file location.env must be in the same directory as docker-compose.yml, not in a subdirectory.
  • Stale containers — containers started with docker compose up without --force-recreate after changing .env keep the old environment.
  • Variable names with export prefixexport DATABASE_URL=value in .env does not work; Compose expects bare KEY=value format.
  • Quoting issuesDATABASE_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=secret123

This 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 vars

Fix 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 substitution

Verify 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 applied

Fix 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 up

Check 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/mydb

Note: Double quotes in .env are stripped by some tools (like dotenv libraries) but kept literally by Docker Compose. Always use unquoted values in .env files used by Docker Compose, or test with docker compose config to 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_URL

Fix 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_URL

Common Mistake: Running docker compose restart after changing .env. restart does not recreate containers — it only stops and starts the existing container with its original environment. Use down + up or --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 up

Structure 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_URL

Check 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 file

Mount 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 runtime

Fix 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 secrets

In 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 start

Fix with:

sed -i '1s/^\xEF\xBB\xBF//' .env

Check 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles