Environment variables are how Docker Compose containers receive configuration — database URLs, API keys, feature flags, port numbers. Getting this right means your stack works identically in development, staging, and production without changing a single line in your compose file.
This guide covers every method for passing environment variables in Docker Compose, their security implications, and the patterns that actually work in production.
Four Ways to Set Environment Variables
1. Inline in docker-compose.yml
services:
web:
image: nginx
environment:
- APP_ENV=production
- PORT=8080
Or using map syntax (equivalent):
environment:
APP_ENV: production
PORT: "8080"
This is fine for non-sensitive values like feature flags, port numbers, or application modes. Never put passwords or API keys here — they end up in version control.
2. Using the .env file (automatic loading)
Docker Compose automatically reads a .env file in the same directory as docker-compose.yml. Variables defined there are available for substitution in the compose file itself:
.env:
POSTGRES_VERSION=16
APP_PORT=8080
docker-compose.yml:
services:
db:
image: postgres:${POSTGRES_VERSION}
web:
ports:
- "${APP_PORT}:8080"
Important: variables in .env are substituted into the compose file before it’s processed. They are not automatically passed into containers as environment variables.
To also pass them into the container, you must reference them explicitly:
services:
web:
environment:
- APP_PORT=${APP_PORT}
3. env_file directive
env_file loads an environment file directly into a container:
services:
web:
image: your-app
env_file:
- .env
- .env.local
Every line in the file becomes an environment variable inside the container. This is the cleanest approach for passing many variables without listing them one by one.
Key difference from .env: env_file passes variables directly to the container process. The .env file performs variable substitution in the compose file. You can (and often should) use both.
4. Shell environment variables
Docker Compose inherits variables from your shell:
export API_KEY=secret123
docker compose up
Reference them in the compose file:
environment:
API_KEY: ${API_KEY}
If API_KEY is set in both your shell and .env, the shell takes precedence.
Variable Substitution Syntax
Docker Compose supports several substitution patterns:
environment:
# Basic substitution
DB_HOST: ${DB_HOST}
# Default value if variable is unset or empty
PORT: ${PORT:-8080}
# Default value only if unset (not if empty)
PORT: ${PORT-8080}
# Error if variable is unset or empty
API_KEY: ${API_KEY:?API_KEY is required}
# Error only if unset (empty string is allowed)
API_KEY: ${API_KEY?API_KEY must be defined}
The :? pattern is particularly useful — it makes docker compose up fail with a clear error message if a required variable is missing, rather than starting with a broken configuration.
Recommended File Structure
myproject/
├── docker-compose.yml
├── docker-compose.override.yml # dev overrides (auto-loaded)
├── docker-compose.prod.yml # production overrides
├── .env # default values, safe to commit
├── .env.local # local overrides, NEVER commit
└── .gitignore # must include .env.local
.env (commit this):
# Application
APP_NAME=myapp
APP_ENV=development
PORT=8080
# Database — override these in .env.local
POSTGRES_USER=myapp
POSTGRES_DB=myapp_dev
POSTGRES_VERSION=16
.env.local (never commit):
POSTGRES_PASSWORD=my_actual_secure_password
API_SECRET=sk_live_xxxxxxxxxxxxx
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxx
.gitignore:
.env.local
.env.production
*.secret
docker-compose.override.yml
Docker Compose automatically merges docker-compose.override.yml when it exists. Use this for development-only settings:
docker-compose.yml (base, works in all environments):
services:
web:
image: your-app:${APP_VERSION:-latest}
env_file: .env
environment:
APP_ENV: ${APP_ENV:-production}
docker-compose.override.yml (development only, auto-loaded):
services:
web:
volumes:
- ./src:/app/src
environment:
APP_ENV: development
DEBUG: "true"
ports:
- "8080:8080"
docker-compose.prod.yml (production, explicit):
services:
web:
restart: unless-stopped
environment:
APP_ENV: production
Run in production:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Environment Variable Priority
When the same variable is set in multiple places, Docker Compose resolves it in this order (highest wins):
- Variables set directly in the shell environment
- Variables in
environment:block in compose file - Variables from
env_file:file - Variables from
.envfile (compose variable substitution)
Understanding this prevents silent misconfigurations — a variable set in your shell will always override what’s in the file.
Viewing Resolved Configuration
Before deploying, verify that Docker Compose resolves variables correctly:
# Show the final merged compose configuration
docker compose config
# Check environment for a specific service
docker compose run --rm web env | sort
docker compose config is your most important debugging tool for environment variable issues. It shows the final YAML after all substitution and merging is applied.
Passing Variables to Build Stage
Environment variables in environment: are only available at runtime, not during docker build. For build-time variables, use args:
services:
web:
build:
context: .
args:
NODE_ENV: production
BUILD_VERSION: ${BUILD_VERSION:-dev}
Dockerfile:
ARG NODE_ENV=development
ARG BUILD_VERSION=dev
RUN echo "Building $BUILD_VERSION for $NODE_ENV"
Build args are baked into the image — they don’t change at container start.
Secrets vs Environment Variables
For genuinely sensitive values (passwords, private keys, tokens), environment variables have a significant drawback: they’re visible to every process inside the container and appear in docker inspect output.
For stronger isolation, Docker Compose supports secrets:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: myuser
POSTGRES_DB: mydb
secrets:
- db_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
The secret is mounted as a file at /run/secrets/db_password. Many official images (including postgres) support _FILE variants that read from a file rather than a direct environment variable. This keeps the password out of docker inspect.
Common Mistakes
Variable not passed to container
# WRONG — this only makes APP_PORT available for compose file substitution
# .env: APP_PORT=8080
services:
web:
image: nginx
# APP_PORT is NOT in the container environment
# CORRECT — explicitly pass it
services:
web:
image: nginx
environment:
- APP_PORT=${APP_PORT}
Quotes in .env files
# WRONG — quotes are included in the value
APP_NAME="MyApp" # value is: "MyApp" (with quotes)
# CORRECT — no quotes
APP_NAME=MyApp # value is: MyApp
Docker Compose strips quotes in some contexts but not all. The safest approach is to not use quotes in .env files.
Missing .env.local from .gitignore
If you accidentally commit .env.local with real credentials, immediately rotate every exposed secret — git history is permanent.
Environment Variables in n8n and WordPress
If you’re self-hosting n8n for workflow automation, environment variables control nearly everything:
services:
n8n:
image: docker.n8n.io/n8nio/n8n:latest
env_file: .env
environment:
- N8N_HOST=${N8N_HOST}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- NODE_ENV=production
- WEBHOOK_URL=https://${N8N_HOST}/
- GENERIC_TIMEZONE=Europe/Berlin
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
- DB_POSTGRESDB_USER=${POSTGRES_USER}
- DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
n8n on a Hetzner Cloud instance with this configuration costs ~€10/month total (server + storage). New accounts get €20 free credits — enough to run it free for the first two months. For US-based teams, DigitalOcean offers a similar setup with $200 free credits.
Debugging Environment Variables
Container environment isn’t what you expected? Debug it:
# List all environment variables in a running container
docker compose exec web env | sort
# Check a specific variable
docker compose exec web printenv DATABASE_URL
# Run a one-off container and inspect env
docker compose run --rm web env
If a variable appears empty, trace it through the priority chain: shell → environment: → env_file: → .env.
Quick Reference
| Method | Passes to container? | Substitutes in compose file? | Use for |
|---|---|---|---|
environment: block | ✅ | ❌ | Service-specific values |
env_file: | ✅ | ❌ | Many variables, keep compose clean |
.env file | ❌ (by default) | ✅ | Compose file variables, version pinning |
| Shell export | ✅ (if referenced) | ✅ | CI/CD, overrides |
Summary
The pattern that works in every environment:
- Put non-sensitive defaults in
.env(commit it) - Override sensitive values in
.env.local(gitignored) - Use
env_file: [.env, .env.local]in the compose file to pass variables to containers - Use
${VAR:?error}for required variables so you catch missing config early - Run
docker compose configbefore deploying to verify final values
This approach keeps secrets out of version control, makes environment differences explicit, and gives you a clean override mechanism for local development without touching the base compose file.