Docker Environment Variables and .env Files Guide
Audience: DevOps engineers, developers configuring containers
WHAT
Best practices for managing environment variables in Docker containers with 12-factor app compliance.
WHY
Incorrect environment variable handling causes configuration drift, security leaks, and hard-to-debug deployments.
HOW
Table of Contents
- Overview
- 12-Factor App Compliance
- Environment Variable Patterns
- Working with .env Files
- Docker Compose Patterns
- Error Handling
- Security Best Practices
- Examples
Overview
This guide covers best practices for managing environment variables in Docker containers, with a focus on maintaining 12-factor app compliance and security.
12-Factor App Compliance
The 12-Factor App methodology defines best practices for building cloud-native applications. Key principles for Docker configuration:
Factor III: Config
- Store configuration in environment variables
- Never hardcode credentials or environment-specific values
- Configuration should be strictly separated from code
Factor V: Build, Release, Run
- Strictly separate build and run stages
- Same Docker image must work across all environments
- Configuration injected at runtime, not build time
Anti-Pattern: Build-Time Configuration
# WRONG: Baking environment config into image
ARG DATABASE_URL
ENV DATABASE_URL=${DATABASE_URL}
Correct Pattern: Runtime Configuration
# CORRECT: Configuration provided at runtime
ENV NODE_ENV=production # Safe default
# DATABASE_URL injected via docker run or compose
Environment Variable Patterns
1. Safe Defaults in Dockerfile
# Non-sensitive defaults that apply to all environments
ENV NODE_ENV=production
ENV PORT=3000
ENV LOG_LEVEL=info
ENV WORKERS=4
2. Required External Configuration
# Document required environment variables
# These MUST be provided at runtime
# Option 1: Documentation only
# ENV DATABASE_URL (required - set via .env or compose)
# Option 2: Reference secret files
ENV DATABASE_PASSWORD_FILE=/run/secrets/db_password
# Option 3: Fail fast if not provided
# (handled in application code or entrypoint script)
3. Build Arguments (Use Carefully)
# ONLY for build-time needs, never for runtime config
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# OK: Version selection
ARG BUILDKIT_INLINE_CACHE=1
# NOT OK: Runtime configuration
# ARG API_KEY (never do this!)
Working with .env Files
File Structure
project/
├── .env # Default, auto-loaded by docker-compose
├── .env.example # Template with all variables (commit this)
├── .env.defaults # Safe defaults (commit this)
├── .env.local # Local development (gitignore)
├── .env.production # Production values (gitignore)
└── .env.staging # Staging values (gitignore)
.env.example (Committed to Repository)
# Database Configuration
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=myapp
DATABASE_USER=appuser
DATABASE_PASSWORD=changeme
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
# Application Settings
JWT_SECRET=change-this-secret-key
API_KEY=your-api-key-here
.env.defaults (Safe Defaults - Committed)
# Non-sensitive defaults
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
CACHE_TTL=3600
MAX_CONNECTIONS=100
.env.production (Never Commit)
# Real production values
DATABASE_PASSWORD=xK9#mP2$vL5@nQ8
JWT_SECRET=highly-secure-random-string-here
API_KEY=sk-proj-real-api-key
STRIPE_SECRET_KEY=sk_live_actual_key
Docker Compose Patterns
Basic env_file Usage
services:
app:
image: myapp:latest
env_file: .env # Loads .env file
Multiple env Files with Priority
services:
app:
image: myapp:latest
env_file:
- .env.defaults # Loaded first
- .env.production # Overrides defaults
environment:
- PORT=8080 # Highest priority
Priority order (highest to lowest):
- Docker Secrets (when application reads from secret files)
environment:section in docker-compose.yml- Last
env_filein list - First
env_filein list .envfile (auto-loaded)- Shell environment variables
Optional env Files (Docker Compose 2.24.0+)
services:
app:
image: myapp:latest
env_file:
- path: .env.defaults
required: true # Must exist
- path: .env.local
required: false # Optional, won't error if missing
Variable Substitution with Defaults
services:
app:
image: myapp:latest
environment:
# Use value from .env or shell, fallback to default
- NODE_ENV=${NODE_ENV:-production}
- PORT=${PORT:-3000}
- DATABASE_URL=${DATABASE_URL:-postgresql://localhost/db}
# Require variable (error if not set)
- API_KEY=${API_KEY:?Error: API_KEY is required}
- JWT_SECRET=${JWT_SECRET:?Error: JWT_SECRET must be set}
Environment-Specific Compose Files
# docker-compose.yml (base)
services:
app:
image: myapp:latest
env_file: .env.defaults
# docker-compose.prod.yml (production overrides)
services:
app:
env_file:
- .env.defaults
- .env.production
deploy:
replicas: 3
Usage:
# Development
docker-compose up
# Production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up
Error Handling
What Happens When .env Files Are Missing?
Required File Missing (Default Behavior)
env_file: .env.production # Will fail if missing
Error:
ERROR: Couldn't find env file: /path/to/.env.production
Graceful Handling Options
- Optional Files
env_file: - path: .env.production required: false - Default Values
environment: - DATABASE_URL=${DATABASE_URL:-postgresql://localhost/myapp} - Error Messages
environment: - SECRET_KEY=${SECRET_KEY:?Error: SECRET_KEY is required for production} - Validation Script ```bash #!/bin/bash
validate-env.sh
required_vars=(“DATABASE_URL” “JWT_SECRET” “API_KEY”)
for var in “${required_vars[@]}”; do if [ -z “${!var}” ]; then echo “Error: $var is not set” exit 1 fi done
## Using Secrets with Environment Variables
### Secrets Override Pattern
Secrets can override `.env` values for sensitive data while keeping non-sensitive config in environment variables:
```yaml
# docker-compose.yml
services:
app:
image: myapp:latest
env_file: .env # Contains: DATABASE_PASSWORD=dev-password
secrets:
- db_password # Contains: production-password
environment:
# Non-sensitive from .env
NODE_ENV: ${NODE_ENV:-production}
PORT: ${PORT:-3000}
# Tell app where to find the secret (overrides .env value)
DATABASE_PASSWORD_FILE: /run/secrets/db_password
# Fallback if no secret exists
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
secrets:
db_password:
file: ./secrets/db_password # This takes precedence!
Application Integration
// Smart configuration loader - secrets override env vars
function getConfig(name) {
// 1. Check for secret file FIRST (highest priority)
const secretFile = process.env[`${name}_FILE`];
if (secretFile && fs.existsSync(secretFile)) {
return fs.readFileSync(secretFile, 'utf8').trim();
}
// 2. Fallback to environment variable
return process.env[name];
}
// Usage
const dbPassword = getConfig('DATABASE_PASSWORD');
// Returns secret file content if exists, otherwise env var
Hybrid Development/Production Pattern
# Development: uses .env file
services:
postgres:
image: postgres:16
env_file: .env.development
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Production: uses secrets (overrides .env)
services:
postgres:
image: postgres:16
env_file: .env.defaults # Non-sensitive only
secrets:
- db_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
# Fallback (rarely used in production)
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
Best Practice: Separation of Concerns
# .env.defaults (committed to repo)
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
CACHE_TTL=3600
# .env.development (gitignored)
DATABASE_PASSWORD=dev-password
API_KEY=dev-key
# Production: secrets override sensitive values
services:
app:
env_file:
- .env.defaults # Non-sensitive config
secrets:
- db_password # Overrides DATABASE_PASSWORD
- api_key # Overrides API_KEY
environment:
# Point to secrets for sensitive data
DATABASE_PASSWORD_FILE: /run/secrets/db_password
API_KEY_FILE: /run/secrets/api_key
Security Best Practices
1. Never Commit Sensitive Data
# .gitignore
.env
.env.local
.env.production
.env.staging
.env.*.local
# Keep these
!.env.example
!.env.defaults
2. Use Docker Secrets for Production
services:
app:
image: myapp:latest
secrets:
- db_password
- jwt_secret
environment:
- DATABASE_PASSWORD_FILE=/run/secrets/db_password
- JWT_SECRET_FILE=/run/secrets/jwt_secret
secrets:
db_password:
external: true
jwt_secret:
external: true
3. Validate Environment Variables
# entrypoint.sh
#!/bin/sh
set -e
# Check required variables
: ${DATABASE_URL:?DATABASE_URL is required}
: ${JWT_SECRET:?JWT_SECRET is required}
# Start application
exec "$@"
4. Use Least Privilege Principle
environment:
# Read-only database user for reports
- REPORTS_DB_USER=reports_readonly
- REPORTS_DB_PASS=${REPORTS_PASSWORD}
# Full access for main app
- APP_DB_USER=app_user
- APP_DB_PASS=${APP_PASSWORD}
Examples
Example 1: Node.js Application
# docker-compose.yml
services:
node-app:
build: .
env_file:
- path: .env.defaults
required: true
- path: .env.local
required: false
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=${PORT:-3000}
- DATABASE_URL=${DATABASE_URL:?Database URL is required}
ports:
- "${PORT:-3000}:${PORT:-3000}"
Example 2: Multi-Service Stack
# docker-compose.yml
x-common-variables: &common-variables
LOG_LEVEL: ${LOG_LEVEL:-info}
TZ: ${TZ:-UTC}
services:
postgres:
image: postgres:16
env_file: .env.postgres
environment:
<<: *common-variables
POSTGRES_DB: ${DB_NAME:-myapp}
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:?Database password required}
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
environment:
<<: *common-variables
command: redis-server --requirepass ${REDIS_PASSWORD:-redis123}
app:
build: .
depends_on:
- postgres
- redis
env_file:
- .env.defaults
- .env.${ENVIRONMENT:-local}
environment:
<<: *common-variables
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
ports:
- "${APP_PORT:-3000}:3000"
volumes:
postgres_data:
Example 3: Development vs Production
# .env.development
NODE_ENV=development
LOG_LEVEL=debug
DATABASE_HOST=localhost
API_MOCK=true
# .env.production
NODE_ENV=production
LOG_LEVEL=error
DATABASE_HOST=prod-db.example.com
API_MOCK=false
# Usage
services:
app:
image: myapp:latest
env_file: .env.${DEPLOY_ENV:-development}
Example 4: Kubernetes ConfigMap (Alternative to .env)
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
NODE_ENV: production
LOG_LEVEL: info
PORT: "3000"
---
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
data:
database-url: cG9zdGdyZXNxbDovL3VzZXI6cGFzc0BkYi9teWFwcA==
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
template:
spec:
containers:
- name: app
image: myapp:latest
envFrom:
- configMapRef:
name: app-config
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: app-secrets
key: database-url
Summary
✅ DO:
- Store all configuration in environment variables
- Use .env files for local development
- Provide .env.example templates
- Use runtime configuration injection
- Implement proper secret management
- Validate required variables at startup
❌ DON’T:
- Bake environment-specific values into images
- Commit .env files with real values
- Use ARG for runtime configuration
- Hardcode sensitive data anywhere
- Mix build-time and runtime configuration
By following these patterns, your Docker applications will be:
- Portable: Same image works everywhere
- Secure: Secrets never in code or images
- Maintainable: Clear configuration structure
- 12-Factor Compliant: Following cloud-native best practices