Environment variables are the primary mechanism for configuring containers in Docker Compose. But there are 4 different ways to pass them, each with distinct behaviors, security implications, and gotchas. This guide covers every approach, from simple .env files to production-grade Docker secrets, with real-world patterns for multi-environment setups.
4 Ways to Pass Environment Variables in Docker Compose
Docker Compose supports four distinct methods for injecting environment variables into containers. Understanding when to use each is critical for maintainable, secure configurations.
1. Inline with the environment Directive
Define key-value pairs directly in your docker-compose.yml. Best for non-sensitive, service-specific configuration that rarely changes.
services:
api:
image: node:20-alpine
environment:
NODE_ENV: production
DB_HOST: postgres
DB_PORT: "5432"
LOG_LEVEL: info
# List syntax (also valid)
worker:
image: node:20-alpine
environment:
- NODE_ENV=production
- QUEUE_NAME=tasks
- CONCURRENCY=42. External File with env_file
Load variables from one or more external files. Best for managing groups of related variables and keeping secrets out of version control.
services:
api:
image: node:20-alpine
env_file:
- .env # Shared variables
- .env.api # Service-specific variables
- .env.local # Local overrides (gitignored)
db:
image: postgres:16
env_file:
- .env
- .env.db3. Shell Environment Variable Passthrough
Pass variables from your host shell into the container. Best for CI/CD pipelines and dynamic values.
# In your shell:
export API_KEY=sk-abc123
export DEBUG=true
# docker-compose.yml
services:
api:
image: node:20-alpine
environment:
- API_KEY # Passes host $API_KEY into container
- DEBUG # Passes host $DEBUG into container
- CI # Passes host $CI (empty string if unset)4. Variable Substitution in docker-compose.yml
Interpolate host environment variables directly in your Compose file values. Best for making your Compose file configurable across environments.
# .env file
POSTGRES_VERSION=16
APP_PORT=3000
REPLICAS=3
# docker-compose.yml
services:
db:
image: postgres:${POSTGRES_VERSION} # Resolved from .env or shell
app:
ports:
- "${APP_PORT}:3000" # Resolved from .env or shell
deploy:
replicas: ${REPLICAS} # Resolved from .env or shellComparison Table
| Method | Best For | In VCS? | Secret-Safe? |
|---|---|---|---|
| environment | Non-sensitive defaults | Yes | No |
| env_file | Grouped config, secrets | No (.gitignore) | Partial |
| Shell passthrough | CI/CD, dynamic values | N/A | Partial |
| ${} substitution | Compose file templating | Yes (file), No (.env) | No |
.env File: Syntax, Placement, and Interpolation
Docker Compose automatically reads a file named .env in the same directory as your docker-compose.yml. This is the most common way to externalize configuration.
Syntax Rules
# Comments start with #
# Blank lines are ignored
# Simple key=value (no spaces around =)
DB_HOST=localhost
DB_PORT=5432
# Values with spaces need quotes
APP_NAME="My Docker App"
GREETING='Hello World'
# Multi-line values (double quotes only)
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----"
# Variable expansion (Compose v2.20+)
BASE_URL=https://api.example.com
HEALTH_URL=${BASE_URL}/health
# Export prefix is ignored (compatible with shell source)
export SECRET_KEY=mysecretkey
# Empty values
EMPTY_VAR=
ALSO_EMPTY=''File Placement and Priority
Compose looks for .env in the project directory (where docker-compose.yml lives). You can override this with the --env-file flag:
# Default: reads .env from project directory
docker compose up
# Override with --env-file
docker compose --env-file .env.staging up
# Multiple --env-file flags (Compose v2.24+)
docker compose --env-file .env --env-file .env.local up
# Project directory structure:
my-project/
docker-compose.yml # Compose file
.env # Auto-loaded by Compose
.env.staging # Loaded with --env-file
.env.production # Loaded with --env-fileInterpolation in docker-compose.yml
Variables from .env are available for substitution in docker-compose.yml, but they are NOT automatically injected into containers:
# .env
TAG=3.2.1
EXTERNAL_PORT=8080
# docker-compose.yml
services:
web:
image: myapp:${TAG} # Uses TAG from .env -> myapp:3.2.1
ports:
- "${EXTERNAL_PORT}:80" # Uses EXTERNAL_PORT from .env -> 8080:80
environment:
- TAG # WRONG! This passes host $TAG, not .env TAG
- TAG=${TAG} # RIGHT! Explicitly interpolate .env value
# Key insight:
# .env -> docker-compose.yml interpolation (automatic)
# .env -> container environment (NOT automatic, use env_file)env_file vs environment: Differences and Gotchas
These two directives look similar but behave differently. Understanding the distinctions prevents subtle bugs.
| Directive | Behavior |
|---|---|
| environment: | Inlined in docker-compose.yml. Visible in version control. Supports variable substitution. Overrides env_file. |
| env_file: | Loaded from external file(s). Can be gitignored. No variable substitution inside the file. Lower priority than environment. |
| .env (auto) | Only for Compose file interpolation (${} syntax). NOT injected into containers. Lowest priority. |
Critical Gotchas
- 1. Precedence: environment values override env_file values for the same key. This is intentional and useful for overrides.
- 2. The .env file (auto-loaded) is for Compose file interpolation only. It does NOT inject variables into containers unless you explicitly use env_file or environment.
- 3. env_file paths are relative to the docker-compose.yml file, not the current working directory.
- 4. If an env_file is missing, Compose will fail with an error. Use required: false (Compose v2.24+) to make it optional.
# Precedence demonstration:
# .env.shared
DB_HOST=shared-db
DB_PORT=5432
# docker-compose.yml
services:
api:
env_file:
- .env.shared # DB_HOST=shared-db, DB_PORT=5432
environment:
DB_HOST: override-db # Overrides env_file! DB_HOST=override-db
# DB_PORT not listed here # Keeps env_file value: DB_PORT=5432
# Result inside container:
# DB_HOST=override-db (from environment, overrides env_file)
# DB_PORT=5432 (from env_file)
# Optional env_file (Compose v2.24+):
services:
api:
env_file:
- path: .env.local
required: false # Won't fail if file doesn't existDocker Secrets: File-Based Secrets for Production
Docker secrets provide a more secure alternative to environment variables for sensitive data. Secrets are mounted as files inside the container, not exposed as environment variables.
Step 1: Create Your Secret Files
# Create secret files (don't commit these!)
echo "SuperSecretPassword123" > db_password.txt
echo "sk-prod-abc123xyz789" > api_key.txt
# Add to .gitignore
echo "*.txt" >> .gitignore
echo "secrets/" >> .gitignore
# Alternative: use a secrets directory
mkdir secrets
echo "SuperSecretPassword123" > secrets/db_password
echo "sk-prod-abc123xyz789" > secrets/api_keyStep 2: Define Secrets in docker-compose.yml
# docker-compose.yml
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password # Note: _FILE suffix
secrets:
- db_password # Grant access to this secret
api:
image: myapp:latest
secrets:
- db_password
- api_key
# Secrets available at:
# /run/secrets/db_password
# /run/secrets/api_key
# Top-level secrets definition
secrets:
db_password:
file: ./secrets/db_password # From local file
api_key:
file: ./secrets/api_key # From local file
# Alternative: use environment variable as source
# jwt_secret:
# environment: JWT_SECRET_VALUE # From host env var (Compose v2.23+)Step 3: Read Secrets in Your Application
# Inside the container, secrets are plain text files:
$ docker compose exec api cat /run/secrets/db_password
SuperSecretPassword123
$ docker compose exec api cat /run/secrets/api_key
sk-prod-abc123xyz789
$ docker compose exec api ls -la /run/secrets/
total 8
-r--r--r-- 1 root root 24 Jan 1 00:00 db_password
-r--r--r-- 1 root root 22 Jan 1 00:00 api_keyStep 4: Handle Secrets in Your Code
// Node.js - Read secret from file
const fs = require('fs');
function getSecret(name) {
const secretPath = `/run/secrets/${name}`;
try {
return fs.readFileSync(secretPath, 'utf8').trim();
} catch (err) {
// Fallback to environment variable (for development)
return process.env[name.toUpperCase()];
}
}
const dbPassword = getSecret('db_password');
const apiKey = getSecret('api_key');# Python - Read secret from file
import os
def get_secret(name: str) -> str:
secret_path = f"/run/secrets/{name}"
try:
with open(secret_path) as f:
return f.read().strip()
except FileNotFoundError:
# Fallback to environment variable
return os.environ.get(name.upper(), "")
db_password = get_secret("db_password")
api_key = get_secret("api_key")Why Secrets Over Environment Variables?
- Environment variables can leak via docker inspect, /proc/environ, error logs, and crash dumps
- Secrets are file-based and only accessible inside the container at /run/secrets/
- Secrets support granular access control per service
- Many databases and services natively support _FILE suffix for secret files (e.g., POSTGRES_PASSWORD_FILE)
Variable Substitution: ${VAR:-default} Syntax
Docker Compose supports shell-style variable substitution with default values and error handling.
| Syntax | Result |
|---|---|
| ${VAR} | Value of VAR. Error if unset. |
| ${VAR:-default} | Value of VAR if set, otherwise "default". |
| ${VAR-default} | Value of VAR if set (even if empty), otherwise "default". |
| ${VAR:?error msg} | Value of VAR if set, otherwise exit with "error msg". |
| ${VAR?error msg} | Value of VAR if set (even if empty), otherwise exit with "error msg". |
| ${VAR:+replacement} | "replacement" if VAR is set and non-empty, otherwise empty. |
| ${VAR+replacement} | "replacement" if VAR is set (even if empty), otherwise empty. |
Practical Example
# docker-compose.yml with variable substitution
services:
web:
image: nginx:${NGINX_VERSION:-1.25-alpine} # Default: 1.25-alpine
ports:
- "${WEB_PORT:-80}:80" # Default: 80
- "${SSL_PORT:-443}:443" # Default: 443
volumes:
- ${CONFIG_PATH:-./nginx.conf}:/etc/nginx/nginx.conf:ro
api:
image: ${REGISTRY:-docker.io}/myapp:${TAG:?TAG is required}
# ^ Fails if TAG is not set
environment:
NODE_ENV: ${NODE_ENV:-development}
LOG_LEVEL: ${LOG_LEVEL:-info}
DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASS:?DB_PASS required}@db:5432/${DB_NAME:-myapp}
db:
image: postgres:${PG_VERSION:-16}
volumes:
- ${DATA_DIR:-./data}/postgres:/var/lib/postgresql/dataMulti-Environment Setup: Dev/Staging/Production Patterns
Real projects need different configurations for development, staging, and production. Here are proven patterns.
Pattern 1: Override Files (Recommended)
Use a base docker-compose.yml with environment-specific override files. Compose merges them automatically.
# File structure:
project/
docker-compose.yml # Base configuration
docker-compose.override.yml # Dev overrides (auto-loaded!)
docker-compose.staging.yml # Staging overrides
docker-compose.prod.yml # Production overrides# docker-compose.yml (base)
services:
api:
image: myapp:${TAG:-latest}
restart: unless-stopped
depends_on:
- db
- redis
db:
image: postgres:16
volumes:
- pg-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
pg-data:# docker-compose.override.yml (development - auto-loaded)
services:
api:
build: .
volumes:
- .:/app # Hot reload
- /app/node_modules
ports:
- "3000:3000"
- "9229:9229" # Debugger
environment:
NODE_ENV: development
LOG_LEVEL: debug
DB_HOST: db
db:
ports:
- "5432:5432" # Expose DB for local tools
environment:
POSTGRES_PASSWORD: devpassword # OK for dev only# docker-compose.prod.yml (production)
services:
api:
# No build, no volumes, no debug port
ports:
- "3000:3000"
environment:
NODE_ENV: production
LOG_LEVEL: warn
env_file:
- .env.production
secrets:
- db_password
- api_key
deploy:
replicas: 3
resources:
limits:
cpus: '1.0'
memory: 512M
db:
# No exposed ports
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
secrets:
db_password:
file: ./secrets/db_password
api_key:
file: ./secrets/api_key# Usage:
# Development (auto-loads docker-compose.override.yml)
docker compose up
# Staging
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Validate merged config before deploying
docker compose -f docker-compose.yml -f docker-compose.prod.yml configPattern 2: Environment-Specific .env Files
Use the --env-file flag to load different variable sets per environment.
# .env.development
TAG=latest
APP_PORT=3000
DB_PASSWORD=devpassword
LOG_LEVEL=debug
REPLICAS=1
# .env.staging
TAG=rc-1.2.3
APP_PORT=3000
DB_PASSWORD=staging-secret-pw
LOG_LEVEL=info
REPLICAS=2
# .env.production
TAG=1.2.3
APP_PORT=3000
DB_PASSWORD=prod-ultra-secret-pw
LOG_LEVEL=warn
REPLICAS=3
# Usage:
docker compose --env-file .env.development up # Dev
docker compose --env-file .env.staging up -d # Staging
docker compose --env-file .env.production up -d # ProductionPattern 3: Profiles for Optional Services
Use Compose profiles to include or exclude services per environment.
# docker-compose.yml
services:
api:
image: myapp:${TAG:-latest}
ports:
- "3000:3000"
db:
image: postgres:16
volumes:
- pg-data:/var/lib/postgresql/data
# Dev-only services
adminer:
image: adminer
ports:
- "8080:8080"
profiles:
- dev # Only starts with --profile dev
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025"
- "8025:8025"
profiles:
- dev # Only starts with --profile dev
# Monitoring (staging + production)
prometheus:
image: prom/prometheus
profiles:
- monitoring # Only starts with --profile monitoring
volumes:
pg-data:
# Usage:
docker compose --profile dev up # Includes adminer + mailhog
docker compose --profile monitoring up -d # Includes prometheus
docker compose --profile dev --profile monitoring up # Both profilesSecurity Checklist: What NEVER to Put in Environment Variables
Environment variables are convenient but inherently insecure for sensitive data. Follow these rules:
| NEVER Do This | Do This Instead |
|---|---|
| Hardcode passwords in docker-compose.yml | Use .env files (gitignored) or Docker secrets |
| Commit .env files to version control | Add .env to .gitignore, commit .env.example instead |
| Use environment variables for private keys or certificates | Mount them as files via volumes or Docker secrets |
| Log environment variables in application code | Redact sensitive values in logs |
| Share .env files via Slack, email, or chat | Use a secrets manager (Vault, AWS Secrets Manager, 1Password) |
| Use the same secrets across dev/staging/production | Generate unique secrets per environment |
# .gitignore β essential entries for Docker projects
.env
.env.*
!.env.example
secrets/
*.pem
*.key
*.crt# .env.example β commit this as a template
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=myapp
DB_PASSWORD=CHANGE_ME
# API Keys
API_KEY=CHANGE_ME
JWT_SECRET=CHANGE_ME
# Application
NODE_ENV=development
LOG_LEVEL=debug
APP_PORT=3000Debugging: Why Isn't My Env Var Loading?
Follow this systematic flowchart when an environment variable isn't reaching your container.
Step 1: Is the variable defined?
Check that the variable exists in your .env file, env_file, or environment directive.
Step 2: Is it reaching the container?
Run docker compose exec <service> env to see all environment variables inside the container.
Step 3: Is the .env file in the right place?
The .env file must be in the same directory as docker-compose.yml, or specified with --env-file.
Step 4: Is there a naming conflict?
Check for typos, case sensitivity, and precedence (environment > env_file > .env).
Step 5: Is the Compose file valid?
Run docker compose config to see the fully resolved configuration with all variables substituted.
Step 6: Is the variable being overridden?
Shell environment variables override .env file values. Check with echo $VAR_NAME on your host.
Useful Debug Commands
# 1. See the fully resolved Compose config (all variables substituted)
docker compose config
# 2. Check environment variables inside a running container
docker compose exec api env
# 3. Check a specific variable
docker compose exec api sh -c 'echo $DATABASE_URL'
# 4. Inspect container config (shows env vars, mounts, etc.)
docker inspect <container_id> --format '{{json .Config.Env}}' | jq .
# 5. Check if .env file is being read
docker compose config --format json | jq '.services.api.environment'
# 6. Verify env_file exists and is readable
docker compose config 2>&1 | grep -i "env_file"
# 7. Run a one-off container to test environment
docker compose run --rm api env | sort
# 8. Check for shell variable conflicts
env | grep -i "DB_"
# 9. Validate Compose file syntax
docker compose config --quiet && echo "Valid" || echo "Invalid"
# 10. Show variable substitution warnings
docker compose --verbose up 2>&1 | grep -i "variable"Frequently Asked Questions
What is the difference between .env and env_file in Docker Compose?
The .env file is automatically read by Docker Compose for variable substitution in docker-compose.yml itself. The env_file directive loads variables directly into the container environment. They serve different purposes: .env is for Compose file interpolation, env_file is for container environment injection.
How do I pass environment variables from the host to a Docker container?
You can pass host environment variables by listing the variable name without a value in the environment directive (e.g., "- MY_VAR"), or by using variable substitution syntax like ${MY_VAR} in your docker-compose.yml. The host shell must have the variable set before running docker compose up.
Are Docker environment variables secure?
No, environment variables are not secure for sensitive data. They can be viewed with docker inspect, appear in /proc/environ inside the container, and may be logged by applications. For sensitive data like passwords and API keys, use Docker secrets (file-based) or a dedicated secrets manager.
What is the precedence order for environment variables in Docker Compose?
The precedence from highest to lowest is: 1) Values set with docker compose run -e, 2) Shell environment variables on the host, 3) The environment directive in docker-compose.yml, 4) The env_file directive, 5) Values from the Dockerfile ENV instruction. Higher-precedence values override lower ones.
How do I use different environment variables for development and production?
Use override files: create a base docker-compose.yml with shared config, then docker-compose.override.yml for development and docker-compose.prod.yml for production. Run with docker compose -f docker-compose.yml -f docker-compose.prod.yml up for production. Alternatively, use --env-file to load environment-specific .env files.
How do Docker secrets differ from environment variables?
Docker secrets are mounted as files (at /run/secrets/<secret_name>) rather than set as environment variables. They are more secure because they don't appear in docker inspect, process listings, or crash dumps. Many official Docker images support reading config from secret files using the _FILE suffix convention (e.g., POSTGRES_PASSWORD_FILE).