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 — thedocker-compose.ymlfile is usually committed to Git. Secrets here will be exposed in the repository history. Always useenv_fileorsecretsfor 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:
- Shell environment variables (override those in the file)
- The
.envfile in the current directory - The
.envfile in the parent directory envvariables 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 | .env | env_file |
|---|---|---|
| Used for | Substitution in YAML | Injecting into containers |
| Format | Simple, KEY=VALUE | More flexible, can be JSON |
| Loading | Automatic | Explicit per service |
| Multiple files | One per project | Many per service |
| Good for | Version variables, host ports | Service 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:
- Is the variable in
environmentorenv_file? - Does the env_file exist at the correct path?
- Is the variable name correct (case-sensitive)?
- Any typos in the name?
Variables Change at Runtime #
Symptom: in-container variables don’t match what was set. Check:
- Does the Dockerfile have an
ENVthat overrides? - Is there an
entrypointscript modifying env? - Does
commandoverride the set env?
Variables Have Different Values per Environment #
Symptom: configuration is correct in development but wrong in production. Check:
- Are you running the right file?
docker compose -f ...determines which file is used. - Do the override files merge correctly?
version: "3.8"at the top level vs in the override. - Is
.envloaded from the right directory? Compose reads.envin the CWD, not at thedocker-compose.ymlpath.
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 case | Pattern |
|---|---|
| Fixed per-service configuration | Hardcoded in YAML |
| Per-environment configuration | env_file with multiple files |
| Image tag / port substitution | Automatic .env |
| Local secrets (dev) | .env in .gitignore |
| Production secrets | secrets: + a secret manager |
| Variables from CI/CD | Shell 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) →.envin.gitignoreorsecrets:.
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_fileis for injecting files into containers.required: falsefor optional files.- The automatic
.envis for substitution in YAML (image tags, ports, version variables). Not for injecting into containers.secrets:is for sensitive data that must not be visible indocker inspect. Safer than environment variables.build argsfor build-time variables,ENVin 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
.envalone.- Don’t store secrets in images — no
ENV SECRET=...in Dockerfiles.