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.

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):

  1. Variables set directly in the shell environment
  2. Variables in environment: block in compose file
  3. Variables from env_file: file
  4. Variables from .env file (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

MethodPasses to container?Substitutes in compose file?Use for
environment: blockService-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:

  1. Put non-sensitive defaults in .env (commit it)
  2. Override sensitive values in .env.local (gitignored)
  3. Use env_file: [.env, .env.local] in the compose file to pass variables to containers
  4. Use ${VAR:?error} for required variables so you catch missing config early
  5. Run docker compose config before 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.