Environment Variable #

Environment variables are the most common way to configure applications in container environments. Docker Compose provides several mechanisms for defining and injecting environment variables into services: hardcoded in YAML, env_file, the .env file, and variable substitution.

This article covers each mechanism, when to use it, and best practices for managing configuration across development, staging, and production.

How to Define Environment Variables #

There are four main ways, each with different use cases.

1. Hardcoded in docker-compose.yml #

The most direct way: write env vars in the service’s environment field.

services:
  api:
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
      - DEBUG=false

Or with map syntax:

services:
  api:
    environment:
      NODE_ENV: production
      DATABASE_URL: postgres://user:pass@db:5432/myapp
      DEBUG: "false"

Good for: non-sensitive configuration that doesn’t change between environments (e.g. service names, internal ports, default values).

Not good for: secrets (passwords, API keys), configuration that differs per environment, frequently-changing data.

Don’t hardcode secrets in YAML — the docker-compose.yml file is usually committed to Git. Secrets here will be exposed in the repository history. Always use env_file or secrets for sensitive data.

2. env_file #

The env_file field reads a file and injects its variables into the service.

services:
  api:
    env_file:
      - .env
      - .env.production

The .env file:

DATABASE_URL=postgres://user:pass@db:5432/myapp
REDIS_URL=redis://cache:6379
API_KEY=sk-test123

Each KEY=VALUE line in the file becomes an env var in the container.

Extra options:

services:
  api:
    env_file:
      - path: ./config/.env
        required: false   # default true; if false and the file is missing, the error is ignored
        format: json      # alternative format

required: false is useful for optional environment variables. format: json allows JSON for more complex structures.

Good for: per-environment configuration (dev/staging/prod), secrets you don’t want in Git, configuration shared across several services.

.env files are usually in .gitignore to prevent committing secrets.

3. The Automatic .env File #

Docker Compose automatically reads the .env file in the same directory as docker-compose.yml. Variables in this file can be used for substitution in YAML.

# docker-compose.yml
services:
  api:
    image: myapp:${VERSION:-latest}
    ports:
      - "${PORT:-8080}:8080"

The .env file:

VERSION=1.2.3
PORT=9000

When docker compose up runs, Compose reads .env, then substitutes ${VERSION} with 1.2.3 and ${PORT} with 9000. ${VAR:-default} provides a default if the var isn’t set.

Important: the automatic .env file is only used for substitution in YAML, not for injecting into containers. To inject into containers, still use env_file or environment.

The .env loading order:

  1. Shell environment variables (override those in the file)
  2. The .env file in the current directory
  3. The .env file in the parent directory
  4. env variables in the shell

Good for: configuration that differs per developer (local paths, ports), variables for YAML substitution.

4. Variable Substitution in the Environment #

You can also reference shell environment variables in a service’s environment.

services:
  api:
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - API_KEY=${API_KEY}

Compose reads DATABASE_URL and API_KEY from the current shell and injects them into the container. This is useful for passing secrets from CI/CD environments or local shells without storing them in files.

Good for: secrets from CI/CD environments, configuration already set in the user’s shell, per-deployment dynamic values.

Passing Build Args #

Environment variables can also be passed to the Docker build via args.

services:
  api:
    build:
      context: ./api
      args:
        - NODE_ENV=production
        - VERSION=1.2.3

In the Dockerfile:

ARG NODE_ENV=development
ARG VERSION=latest

ENV NODE_ENV=$NODE_ENV
ENV VERSION=$VERSION

RUN npm ci --only=production

build args are only available at build time, not runtime. ENV in the Dockerfile makes them runtime environment variables.

Secrets vs Environment Variables #

Docker Compose has a secrets: field that’s safer than environment: for sensitive data.

services:
  api:
    image: myapp
    secrets:
      - db_password
      - api_key

secrets:
  db_password:
    file: ./secrets/db_password.txt
  api_key:
    file: ./secrets/api_key.txt

The db_password.txt file contains the password (plain text), mounted into the container at /run/secrets/db_password. The application reads that file.

In Compose V2 (non-Swarm): secrets are stored on the host filesystem and mounted into containers. Not encrypted, but not exposed in docker inspect like environment variables.

In Docker Swarm: secrets are encrypted and distributed to all nodes. Only containers with permission can read them.

When to use secrets vs env:

  • Secrets: passwords, API keys, TLS certificates, data that must not be visible in docker inspect.
  • Env vars: non-sensitive configuration, flags (production/development), service names, ports.

The .env File vs env_file #

It’s important to distinguish the two.

Aspect.envenv_file
Used forSubstitution in YAMLInjecting into containers
FormatSimple, KEY=VALUEMore flexible, can be JSON
LoadingAutomaticExplicit per service
Multiple filesOne per projectMany per service
Good forVersion variables, host portsService runtime configuration

A combination example:

# docker-compose.yml
services:
  api:
    image: myapp:${VERSION}        # from .env (substitution)
    ports:
      - "${HOST_PORT}:8080"        # from .env (substitution)
    env_file:
      - .env.runtime               # injects into the container
  
  # .env (for substitution)
  # VERSION=1.2.3
  # HOST_PORT=8080
  
  # .env.runtime (for injection)
  # DATABASE_URL=postgres://...
  # REDIS_URL=redis://...

Best Practices #

Separate Configuration per Environment #

# docker-compose.yml (base)
services:
  api:
    build: .
    environment:
      - NODE_ENV=production

# docker-compose.dev.yml (override for development)
services:
  api:
    environment:
      - NODE_ENV=development
    volumes:
      - ./src:/app/src

# docker-compose.prod.yml (override for production)
services:
  api:
    environment:
      - DATABASE_URL=${PROD_DATABASE_URL}
    deploy:
      replicas: 3
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up

Secrets in .env, Not in YAML #

The .env file goes in .gitignore, not committed. When deploying, the .env file is injected via other mechanisms (CI/CD variables, secret managers, files mounted from the host).

# .gitignore
.env
.env.*
!.env.example

The .env.example file is a committed template for new developers to reference.

Validate Variables #

For applications requiring certain variables, validate at startup.

// Node.js
const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
const missing = required.filter(v => !process.env[v]);
if (missing.length) {
  console.error(`Missing env vars: ${missing.join(', ')}`);
  process.exit(1);
}

Don’t Store Secrets in Images #

Make sure the Dockerfile doesn’t store secrets in image layers.

# BAD - secret baked into the image
ENV API_KEY=sk-test123

# GOOD - secret injected at runtime
# (no ENV for secrets)

Use .dockerignore to exclude secret files from the build context.

Use a Secret Manager in Production #

For production, don’t rely only on .env or Compose secrets:. Use a proper secret manager:

  • AWS Secrets Manager
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault

Integrate with the application via SDK or an init container.

A Complete Example #

A docker-compose.yml with complete environment configuration:

services:
  api:
    build: ./api
    image: myregistry.com/api:${VERSION:-latest}
    ports:
      - "${HOST_PORT:-8080}:8080"
    environment:
      - NODE_ENV=${NODE_ENV:-production}
      - LOG_LEVEL=${LOG_LEVEL:-info}
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=${REDIS_URL}
    env_file:
      - .env.runtime
    secrets:
      - api_key
      - jwt_secret
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=${DB_NAME}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    restart: unless-stopped

secrets:
  api_key:
    file: ./secrets/api_key.txt
  jwt_secret:
    file: ./secrets/jwt_secret.txt

volumes:
  db-data:

The .env file (for substitution):

VERSION=1.2.3
HOST_PORT=8080
NODE_ENV=production
LOG_LEVEL=info
DB_USER=app
DB_PASSWORD=secret
DB_NAME=myapp
REDIS_PASSWORD=redissecret
DATABASE_URL=postgres://app:pass@db:5432/myapp
REDIS_URL=redis://:redissecret@cache:6379

The .env.runtime file (for injection):

APP_VERSION=1.2.3
SENTRY_DSN=https://...
FEATURE_FLAGS=newUI,betaSearch

This configuration separates:

  • Image tags & ports: from .env (for versioning)
  • Connection configuration: from .env (for substitution)
  • Runtime configuration: from .env.runtime (for injection)
  • Secrets: from ./secrets/ files

Supporting Tooling #

dotenv (Various Languages) #

Most languages have libraries for reading .env files. This makes applications portable — runnable with or without Docker.

# Python (python-dotenv)
from dotenv import load_dotenv
import os

load_dotenv()  # read the .env file
database_url = os.getenv("DATABASE_URL")
// Node.js (dotenv)
require('dotenv').config();
const databaseUrl = process.env.DATABASE_URL;
// Go (godotenv or built-in)
import "github.com/joho/godotenv"
// In main:
godotenv.Load()

docker-compose CLI for Debugging #

# Show the env vars that will be injected into a service
docker compose run --rm api env

# Inspect a running service
docker compose exec api env

# See resolved values
docker compose config | grep -A 5 "DATABASE_URL"

The docker compose config tool is very useful for debugging — it shows the final file after all substitutions and merges.

Validate with docker compose config #

# Strict validation
docker compose config --quiet

If there are syntax errors or invalid references, the exit code is non-zero. Fits CI/CD pipelines.

# .github/workflows/ci.yml
- name: Validate docker-compose
  run: docker compose config --quiet

Troubleshooting #

Variables Not Injected #

Symptom: the application can’t read variables. Check:

  1. Is the variable in environment or env_file?
  2. Does the env_file exist at the correct path?
  3. Is the variable name correct (case-sensitive)?
  4. Any typos in the name?

Variables Change at Runtime #

Symptom: in-container variables don’t match what was set. Check:

  1. Does the Dockerfile have an ENV that overrides?
  2. Is there an entrypoint script modifying env?
  3. Does command override the set env?

Variables Have Different Values per Environment #

Symptom: configuration is correct in development but wrong in production. Check:

  1. Are you running the right file? docker compose -f ... determines which file is used.
  2. Do the override files merge correctly? version: "3.8" at the top level vs in the override.
  3. Is .env loaded from the right directory? Compose reads .env in the CWD, not at the docker-compose.yml path.

Variables Containing Special Characters #

Characters like $, \, and newlines need escaping.

# The $ character
KEY=value_with_$$dollar

# Use single quotes
KEY='value with $dollar'

# Newline
KEY="line1\nline2"  # literal \n, not a newline

For passwords with special characters, secrets: is safer than env vars.

Pattern Recap #

Choose a pattern by use case:

Use casePattern
Fixed per-service configurationHardcoded in YAML
Per-environment configurationenv_file with multiple files
Image tag / port substitutionAutomatic .env
Local secrets (dev).env in .gitignore
Production secretssecrets: + a secret manager
Variables from CI/CDShell env vars
Variables from local overrides.env in the working directory
The golden rule: Configuration that’s the same for everyone (default ports, service names, stable image tags) → hardcoded or .env. Configuration that differs per person/per environment (local paths, secrets, database credentials) → .env in .gitignore or secrets:.

Summary #

  • Environment variables in Compose have 4 mechanisms: hardcoded in YAML, env_file, the automatic .env, and shell environment.
  • Hardcoded fits non-sensitive configuration that stays constant. Don’t hardcode secrets.
  • env_file is for injecting files into containers. required: false for optional files.
  • The automatic .env is for substitution in YAML (image tags, ports, version variables). Not for injecting into containers.
  • secrets: is for sensitive data that must not be visible in docker inspect. Safer than environment variables.
  • build args for build-time variables, ENV in the Dockerfile for runtime.
  • Separate configuration per environment with override files: -f docker-compose.yml -f docker-compose.dev.yml.
  • Validate mandatory variables at application startup so it fails fast if configuration is missing.
  • Production: use a secret manager (AWS Secrets Manager, Vault) rather than .env alone.
  • Don’t store secrets in images — no ENV SECRET=... in Dockerfiles.

← Previous: Multi-Container   Next: Volume & Network →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact