Docker Compose gives you multiple ways to inject environment variables into containers: the environment directive, the env_file directive, the root-level .env file, and build-time ARG values. Each behaves differently with distinct precedence rules, security implications, and use cases. This guide breaks down exactly when to use which, with copy-paste docker-compose.yml examples for every scenario.
Quick Answer: env_file for Secrets, environment for Config
If you just need a fast rule of thumb:
- environment: Use environment for non-sensitive, service-specific config that you want visible in your Compose file (port numbers, log levels, feature flags).
- env_file: Use env_file for secrets and grouped variables you want to keep out of version control (API keys, database passwords, third-party credentials).
- .env: Use the root .env file for Compose file interpolation only (image tags, port mappings, replica counts).
- ARG: Use ARG for build-time values that should NOT exist at runtime (build tool versions, dependency flags).
# Quick reference — same service using all methods:
services:
api:
build:
context: .
args:
NODE_VERSION: 20 # ARG: build-time only
image: myapp:${TAG:-latest} # .env: Compose interpolation
env_file:
- .env.secrets # env_file: secrets (gitignored)
environment:
NODE_ENV: production # environment: inline config
LOG_LEVEL: info
PORT: "3000"environment: Inline and Map Syntax
The environment directive defines key-value pairs directly in your docker-compose.yml. These values are visible in version control and override values from env_file.
List Syntax (Array)
Uses KEY=VALUE strings in an array. Most common in tutorials and quick setups:
services:
api:
image: node:20-alpine
environment:
- NODE_ENV=production
- DB_HOST=postgres
- DB_PORT=5432
- LOG_LEVEL=info
- CACHE_TTL=3600Map Syntax (Dictionary)
Uses YAML key-value mapping. Cleaner for large configurations and supports YAML anchors:
# Map syntax — cleaner for large configs
services:
api:
image: node:20-alpine
environment:
NODE_ENV: production
DB_HOST: postgres
DB_PORT: "5432" # Quotes needed for numeric strings
LOG_LEVEL: info
CACHE_TTL: "3600"
# YAML anchors work with map syntax
x-common-env: &common-env
NODE_ENV: production
LOG_LEVEL: info
TZ: UTC
worker:
image: node:20-alpine
environment:
<<: *common-env # Merge common variables
QUEUE_NAME: tasks
CONCURRENCY: "4"Override Behavior
Values in environment always override the same key from env_file. This is by design, allowing you to set defaults in a file and override specific values inline:
# .env.shared (loaded via env_file)
DB_HOST=shared-database
DB_PORT=5432
DB_NAME=myapp
LOG_LEVEL=warn
# docker-compose.yml
services:
api:
image: node:20-alpine
env_file:
- .env.shared # DB_HOST=shared-database, LOG_LEVEL=warn
environment:
DB_HOST: custom-db # OVERRIDES env_file! DB_HOST=custom-db
LOG_LEVEL: debug # OVERRIDES env_file! LOG_LEVEL=debug
# DB_PORT and DB_NAME keep their env_file values
# Final result inside container:
# DB_HOST=custom-db (from environment, overrode env_file)
# DB_PORT=5432 (from env_file, not overridden)
# DB_NAME=myapp (from env_file, not overridden)
# LOG_LEVEL=debug (from environment, overrode env_file)Shell Passthrough
List a variable name without a value to pass the host shell variable into the container:
# On your host machine:
export API_KEY=sk-abc123
export AWS_REGION=us-east-1
# docker-compose.yml
services:
api:
image: node:20-alpine
environment:
- API_KEY # Passes host $API_KEY into container
- AWS_REGION # Passes host $AWS_REGION into container
- CI # Empty string if $CI is not set on host
- DEBUG # Empty string if $DEBUG is not set on hostenv_file: External File Loading
The env_file directive loads variables from one or more external files into the container environment. Unlike environment, these files can be gitignored to keep secrets out of version control.
.env File Format Rules
# .env.api — env_file format rules
# Lines starting with # are comments
# Blank lines are ignored
# Basic KEY=VALUE (no spaces around =)
DB_HOST=postgres
DB_PORT=5432
# Values with spaces — use quotes
APP_NAME="My API Service"
GREETING='Hello World'
# No variable substitution! This is LITERAL:
# BASE_URL=${DOMAIN} # WRONG — will be literal "${DOMAIN}"
BASE_URL=https://api.example.com
# Multi-line values (double quotes only)
RSA_KEY="-----BEGIN RSA KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA KEY-----"
# export prefix is stripped (compatible with shell source)
export SECRET_KEY=mysecretkey
# Empty values
EMPTY_VAR=
ALSO_EMPTY=''Multiple Files and Merge Order
You can specify multiple files. Later files override earlier files for the same key:
# .env.defaults
DB_HOST=localhost
DB_PORT=5432
LOG_LEVEL=info
# .env.overrides
DB_HOST=production-db # Overrides .env.defaults
LOG_LEVEL=error # Overrides .env.defaults
# docker-compose.yml
services:
api:
image: node:20-alpine
env_file:
- .env.defaults # Loaded first
- .env.overrides # Loaded second, overrides duplicates
# Final result:
# DB_HOST=production-db (from .env.overrides)
# DB_PORT=5432 (from .env.defaults, not overridden)
# LOG_LEVEL=error (from .env.overrides)Path Resolution
File paths in env_file are relative to the docker-compose.yml file location, NOT the current working directory. This is a common source of confusion:
# Project structure:
my-project/
docker-compose.yml # Compose file lives here
.env.api # Same directory — OK
config/
.env.db # Subdirectory — relative to compose file
../shared/
.env.common # Parent directory — also relative
# docker-compose.yml
services:
api:
env_file:
- .env.api # ./my-project/.env.api
- config/.env.db # ./my-project/config/.env.db
- ../shared/.env.common # ./shared/.env.common
# WRONG: Paths are NOT relative to where you run docker compose from!
# If you run "docker compose up" from a different directory,
# paths are still relative to the docker-compose.yml location.Optional env_file (Compose v2.24+)
By default, a missing env_file causes an error. Use the required field to make it optional:
# docker-compose.yml
services:
api:
image: node:20-alpine
env_file:
- .env.shared # Required — error if missing
- path: .env.local
required: false # Optional — no error if missing
- path: .env.secrets
required: false # Optional — useful for dev
# Use case: .env.local exists only on developer machines
# for personal overrides, never committed to git..env File (Root Level): Auto-Loaded by Docker Compose
Docker Compose automatically reads a file named .env in the same directory as your docker-compose.yml (or the project directory). This file serves a special purpose that is frequently misunderstood.
What .env Actually Does
- YES: The .env file provides values for ${VARIABLE} substitution inside docker-compose.yml itself.
- NO: It does NOT automatically inject variables into containers.
- TIP: To get .env values into containers, you must also use environment or env_file.
How .env Works (and Doesn't Work)
# .env (root level, auto-loaded)
TAG=3.2.1
EXTERNAL_PORT=8080
REPLICAS=3
DB_PASSWORD=supersecret
# docker-compose.yml
services:
api:
image: myapp:${TAG} # WORKS: Interpolated to myapp:3.2.1
ports:
- "${EXTERNAL_PORT}:3000" # WORKS: Interpolated to 8080:3000
deploy:
replicas: ${REPLICAS} # WORKS: Interpolated to 3
environment:
- DB_PASSWORD # DOES NOT WORK the way you think!
# This passes the HOST $DB_PASSWORD, not the .env value.
# If host has no $DB_PASSWORD, the container gets an empty string.
# To pass .env values into the container, do one of these:
- DB_PASSWORD=${DB_PASSWORD} # Option 1: Explicit interpolation
# OR:
# env_file:
# - .env # Option 2: Load entire .env fileUsing --env-file to Override
You can point Compose to a different file for interpolation:
# Default: reads .env from project directory
docker compose up
# Use a different file for Compose interpolation:
docker compose --env-file .env.staging up
# Multiple env files (Compose v2.24+):
docker compose --env-file .env --env-file .env.local up
# This affects ONLY ${} interpolation in docker-compose.yml.
# It does NOT affect env_file directives or container variables.Comparison Table: env_file vs environment vs .env vs ARG
Here is a side-by-side comparison of all four methods:
| Feature | environment | env_file | .env (root) | ARG (build) |
|---|---|---|---|---|
| Purpose | Direct container env vars | Load env vars from file(s) | Compose file interpolation | Build-time variables |
| Scope | Runtime (container) | Runtime (container) | Compose file only | Build only (unless passed to ENV) |
| In docker-compose.yml? | Yes | No | No | Yes |
| Gitignore-able? | No | Yes | Yes | No |
| Good for secrets? | No | Partial | No | No |
| Precedence | Highest | Medium | Lowest (interpolation only) | Build-time only |
| Supports ${} syntax? | In values only | Not inside the file itself | N/A | N/A |
Variable Substitution: ${VAR:-default}, ${VAR:?error}
Docker Compose supports shell-style variable substitution with powerful default and error-handling syntax. These work in docker-compose.yml values using variables from .env or the host shell.
| Syntax | Result | Example |
|---|---|---|
| ${VAR} | Value of VAR, empty string if unset | image: myapp:${TAG} |
| ${VAR:-default} | Value of VAR, or "default" if unset/empty | image: myapp:${TAG:-latest} |
| ${VAR-default} | Value of VAR, or "default" if unset (allows empty) | LOG_LEVEL: ${LOG:-} |
| ${VAR:?error msg} | Value of VAR, or EXIT with error if unset/empty | DB_HOST: ${DB_HOST:?Must set DB_HOST} |
| ${VAR?error msg} | Value of VAR, or EXIT with error if unset (allows empty) | API_KEY: ${API_KEY?Missing API_KEY} |
| ${VAR:+replacement} | "replacement" if VAR is set and non-empty, else empty | DEBUG: ${DEBUG:+true} |
Practical Example with Defaults and Validation
# .env
TAG=3.2.1
# DB_HOST is intentionally NOT set
# docker-compose.yml
services:
api:
image: myapp:${TAG:-latest} # -> myapp:3.2.1
environment:
DB_HOST: ${DB_HOST:?Error: DB_HOST is required} # -> exits with error!
DB_PORT: ${DB_PORT:-5432} # -> 5432 (default)
LOG_LEVEL: ${LOG_LEVEL:-info} # -> info (default)
DEBUG: ${DEBUG:+true} # -> empty (DEBUG not set)
REPLICAS: ${REPLICAS:-1} # -> 1 (default)
db:
image: postgres:${PG_VERSION:-16} # -> postgres:16 (default)
environment:
POSTGRES_DB: ${DB_NAME:-myapp} # -> myapp (default)
POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD} # -> exits with error!Priority Order: environment > env_file > .env > Dockerfile ENV
When the same variable is defined in multiple places, Docker Compose applies a strict precedence order. Understanding this prevents hours of debugging.
From highest to lowest priority:
- 1. docker compose run -e KEY=VALUE (CLI override)
- 2. Host shell environment variables
- 3. environment directive in docker-compose.yml
- 4. env_file directive (later files override earlier files)
- 5. .env file (for Compose interpolation only)
- 6. Dockerfile ENV instruction
Priority Demonstration
# Dockerfile
ENV DB_HOST=dockerfile-db # Priority 6 (lowest)
# .env (root)
DB_HOST=dotenv-db # Priority 5 (Compose interpolation only)
# .env.file (loaded via env_file)
DB_HOST=envfile-db # Priority 4
# docker-compose.yml
services:
api:
build: .
env_file:
- .env.file # DB_HOST=envfile-db
environment:
DB_HOST: compose-db # Priority 3 — WINS over env_file
# On host shell:
export DB_HOST=shell-db # Priority 2 — WINS over environment
# CLI override:
docker compose run -e DB_HOST=cli-db api # Priority 1 — WINS over everything
# Result with all defined:
# docker compose run -e -> DB_HOST=cli-db
# Without CLI override -> DB_HOST=shell-db
# Without shell export -> DB_HOST=compose-db
# Without environment: -> DB_HOST=envfile-db
# Without env_file: -> DB_HOST=dockerfile-dbReal-World Setup: Dev, Staging, Production
Here is a complete, production-tested multi-environment setup that uses all the techniques above.
Project Structure
my-project/
docker-compose.yml # Base config (shared across environments)
docker-compose.override.yml # Dev overrides (auto-loaded)
docker-compose.staging.yml # Staging overrides
docker-compose.prod.yml # Production overrides
.env # Dev defaults (Compose interpolation)
.env.staging # Staging defaults
.env.prod # Production defaults
.env.secrets # Secrets (gitignored)
.env.secrets.example # Template for secrets (committed)
.gitignore # Ignores .env.secrets, .env.local, etc.Base docker-compose.yml
# docker-compose.yml — shared base config
services:
api:
build:
context: .
args:
NODE_VERSION: ${NODE_VERSION:-20}
image: myapp:${TAG:-latest}
ports:
- "${API_PORT:-3000}:3000"
env_file:
- path: .env.secrets
required: false
environment:
NODE_ENV: ${NODE_ENV:-development}
DB_HOST: ${DB_HOST:-postgres}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-myapp}
REDIS_URL: redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
postgres:
image: postgres:${PG_VERSION:-16}
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DB_NAME:-myapp}
POSTGRES_USER: ${DB_USER:-myapp}
env_file:
- path: .env.secrets
required: false
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-myapp}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:Development: docker-compose.override.yml
This file is auto-loaded by docker compose up in development:
# docker-compose.override.yml — auto-loaded in dev
services:
api:
build:
target: development
volumes:
- .:/app # Hot reload
- /app/node_modules
environment:
NODE_ENV: development
LOG_LEVEL: debug
DEBUG: "true"
ports:
- "9229:9229" # Node.js debugger
postgres:
ports:
- "5432:5432" # Expose DB for local tools
environment:
POSTGRES_PASSWORD: devpassword # OK for dev only!
# .env (dev defaults)
TAG=latest
API_PORT=3000
NODE_ENV=development
DB_HOST=postgres
DB_NAME=myapp_devStaging: docker-compose.staging.yml
# docker-compose.staging.yml
services:
api:
build:
target: production
environment:
NODE_ENV: staging
LOG_LEVEL: info
deploy:
replicas: 2
resources:
limits:
memory: 512M
cpus: "0.5"
postgres:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
# .env.staging
TAG=3.2.1-rc.1
API_PORT=3000
NODE_ENV=staging
DB_NAME=myapp_stagingProduction: docker-compose.prod.yml
# docker-compose.prod.yml
services:
api:
build:
target: production
restart: unless-stopped
environment:
NODE_ENV: production
LOG_LEVEL: warn
deploy:
replicas: 4
resources:
limits:
memory: 1G
cpus: "1.0"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
postgres:
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
deploy:
resources:
limits:
memory: 2G
redis:
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
secrets:
db_password:
file: ./secrets/db_password.txt
# .env.prod
TAG=3.2.1
API_PORT=3000
NODE_ENV=production
DB_NAME=myapp_prodCommands per Environment
# Development (auto-loads docker-compose.override.yml)
docker compose up
# Staging
docker compose -f docker-compose.yml -f docker-compose.staging.yml \
--env-file .env.staging up -d
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
--env-file .env.prod up -d
# Verify resolved config before deploying:
docker compose -f docker-compose.yml -f docker-compose.prod.yml \
--env-file .env.prod configSecurity Best Practices
Environment variables are inherently insecure for sensitive data. Follow these practices to minimize risk.
1. Always .gitignore Your .env Files
# .gitignore
.env.secrets
.env.local
.env.*.local
secrets/
*.pem
*.key2. Commit a Template, Not the Real File
# .env.secrets.example (committed to git)
# Copy this file to .env.secrets and fill in real values
DB_PASSWORD=
API_KEY=
JWT_SECRET=
SMTP_PASSWORD=
AWS_SECRET_ACCESS_KEY=
# .env.secrets (gitignored — never committed)
DB_PASSWORD=RealSuperSecretP@ssw0rd
API_KEY=sk-prod-abc123xyz789
JWT_SECRET=your-256-bit-secret-here
SMTP_PASSWORD=sendgrid-api-key
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCY3. Use Docker Secrets for Production
Docker secrets mount sensitive data as files at /run/secrets/ instead of exposing them as environment variables:
# docker-compose.yml with Docker secrets
services:
api:
image: myapp:latest
secrets:
- db_password
- api_key
environment:
# App reads secrets from files instead of env vars
DB_PASSWORD_FILE: /run/secrets/db_password
API_KEY_FILE: /run/secrets/api_key
postgres:
image: postgres:16
secrets:
- db_password
environment:
# PostgreSQL natively supports _FILE suffix
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt4. Be Aware of docker inspect
Environment variables are visible via docker inspect. Anyone with Docker access can read them:
# Anyone with Docker access can see ALL environment variables:
docker inspect myapp-api-1 --format '{{range .Config.Env}}{{println .}}{{end}}'
# Output includes secrets if passed via environment:
# DB_PASSWORD=RealSuperSecretP@ssw0rd <-- EXPOSED!
# API_KEY=sk-prod-abc123xyz789 <-- EXPOSED!
# Docker secrets are NOT visible via inspect.
# They only exist as files inside the container at /run/secrets/5. Rotate Secrets per Environment
Never use the same passwords, API keys, or tokens across dev, staging, and production. Generate unique values for each.
# Generate unique secrets per environment:
# Dev
openssl rand -base64 32 > secrets/dev/db_password.txt
openssl rand -hex 32 > secrets/dev/jwt_secret.txt
# Staging
openssl rand -base64 32 > secrets/staging/db_password.txt
openssl rand -hex 32 > secrets/staging/jwt_secret.txt
# Production
openssl rand -base64 32 > secrets/prod/db_password.txt
openssl rand -hex 32 > secrets/prod/jwt_secret.txtDebugging: docker compose config, docker inspect
When environment variables are not working as expected, use these systematic debugging steps.
Step 1: See the Resolved Compose File
docker compose config shows the fully interpolated Compose file with all variables resolved:
# See the fully resolved docker-compose.yml:
docker compose config
# With a specific env file:
docker compose --env-file .env.prod config
# Show only specific service:
docker compose config --services
docker compose config api
# Output shows all variables with their final values,
# so you can verify interpolation worked correctly.Step 2: Check Container Environment
See all environment variables actually set inside a running container:
# List all env vars in a running container:
docker compose exec api env
# Search for a specific variable:
docker compose exec api env | grep DB_HOST
# Or using printenv:
docker compose exec api printenv DB_HOSTStep 3: Inspect a Stopped Container
Check environment variables on a container that has exited:
# Inspect a stopped/exited container:
docker inspect myapp-api-1 --format '{{range .Config.Env}}{{println .}}{{end}}'
# Or as JSON:
docker inspect myapp-api-1 --format '{{json .Config.Env}}' | jq .Step 4: Verify .env File Location
The .env file must be in the project directory (where docker-compose.yml is). Verify:
# Check that .env exists in the right directory:
ls -la .env
# Check Compose project directory:
docker compose ls
# Explicitly specify the env file:
docker compose --env-file ./my-custom.env configStep 5: Check for Overrides
Host shell variables override .env values. Check what your shell has set:
# Check host shell for conflicting variables:
echo $DB_HOST
echo $NODE_ENV
printenv | grep -i db_
# Unset a conflicting variable:
unset DB_HOST
# Then re-run:
docker compose configDebug Command Cheatsheet
| Command | Purpose |
|---|---|
| docker compose config | Show resolved Compose file with all variables |
| docker compose config --format json | Resolved config as JSON (pipe to jq) |
| docker compose exec <svc> env | List env vars inside running container |
| docker compose exec <svc> printenv VAR | Get specific variable value |
| docker inspect <container> --format '{{.Config.Env}}' | Env vars of any container (running or stopped) |
| docker compose --env-file FILE config | Test with a specific .env file |
| docker compose logs <svc> 2>&1 | head | Check for startup errors related to missing vars |
Frequently Asked Questions
What is the difference between env_file and .env in Docker Compose?
env_file is a directive that loads variables directly into the container environment from an external file. The .env file (root level) is automatically read by Docker Compose for variable substitution inside docker-compose.yml itself, but does NOT inject variables into containers. They serve different purposes: env_file is for container configuration, .env is for Compose file templating.
Can I use both environment and env_file for the same service?
Yes, and this is a common pattern. Variables from env_file are loaded first, then environment values override any duplicate keys. This lets you keep defaults in a file and override specific values inline. For example, set shared config in .env.shared via env_file, then override DB_HOST in environment for a specific service.
Why is my .env variable not reaching the container?
The root .env file is only used for ${} interpolation in docker-compose.yml. It does NOT automatically set variables inside containers. To pass .env values into a container, you must either: (1) use env_file to point to the .env file explicitly, or (2) use environment with ${VAR} syntax to interpolate the value. This is the #1 source of confusion with Docker Compose environment variables.
How do I handle secrets in Docker Compose without environment variables?
Use Docker secrets. Define secrets in your docker-compose.yml pointing to local files, then grant services access. Secrets are mounted as files at /run/secrets/<name> inside the container. Many official images support the _FILE suffix convention (e.g., POSTGRES_PASSWORD_FILE=/run/secrets/db_password). This prevents secrets from appearing in docker inspect, process listings, or logs.
Does env_file support variable substitution like ${VAR}?
No. Unlike docker-compose.yml, files loaded via env_file do NOT support ${} variable substitution. Each line is treated as a literal KEY=VALUE pair. If you need variable expansion, define the variables in the environment directive using ${VAR} syntax, which will be interpolated from .env or host shell variables.