← Back to DevBytes

Docker Compose Environment Variables: Production Guide

What Are Docker Compose Environment Variables?

Docker Compose environment variables are key-value pairs that inject configuration into your containers at runtime. They allow you to decouple application configuration from container images, making your deployments flexible across different environments without rebuilding images.

In Docker Compose, environment variables flow through three distinct channels:

Understanding the distinction between these channels is critical for production deployments. A common pitfall is confusing variable substitution in the compose file with runtime environment variables inside containers.

Why Environment Variables Matter in Production

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In production, you face strict requirements around security, auditability, and environment parity. Environment variables solve several critical problems:

Without proper environment variable management, you risk leaking credentials, deploying with wrong configurations, or coupling images to specific environments — all of which violate the build once, deploy anywhere principle.

How to Use Environment Variables in Docker Compose

The environment Directive

The most direct method: define variables inline or reference them from the host shell. This works well for non-sensitive, service-specific configuration.

# docker-compose.yml
services:
  api:
    image: myapp:latest
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
      - PORT=3000
      - DB_HOST=postgres
    ports:
      - "3000:3000"

You can also reference host environment variables using the ${VAR} syntax, but note this is compose file substitution — the value is baked into the compose file before containers start:

services:
  api:
    image: myapp:latest
    environment:
      - API_KEY=${API_KEY}          # Pulled from host shell or .env
      - DB_PASSWORD=${DB_PASSWORD}  # Same substitution mechanism

The env_file Directive

For managing larger sets of variables, use the env_file directive to point to an external file. This keeps your compose file clean and lets you maintain separate variable files per environment.

# docker-compose.yml
services:
  api:
    image: myapp:latest
    env_file:
      - ./config/api.env
      - ./config/common.env
    ports:
      - "3000:3000"
# ./config/api.env
NODE_ENV=production
LOG_LEVEL=info
PORT=3000
DB_HOST=postgres
DB_PORT=5432
DB_USER=app_user
DB_PASSWORD=supersecret123
REDIS_URL=redis://redis:6379/0
API_TIMEOUT_MS=5000

Multiple env_file entries merge in the order specified. If the same key appears in multiple files, the last one wins — but the environment directive always overrides env_file.

Variable Substitution in Compose Files

Docker Compose supports ${VARIABLE} placeholders throughout the compose YAML — not just in environment, but also in image tags, ports, volumes, and any other value. This is powerful for keeping one compose file that adapts to different environments.

# docker-compose.yml (generic template)
services:
  api:
    image: ${DOCKER_REGISTRY}/myapp:${APP_TAG}
    environment:
      - LOG_LEVEL=${LOG_LEVEL}
    ports:
      - "${HOST_PORT}:3000"
    volumes:
      - ${DATA_DIR}:/data
    deploy:
      replicas: ${REPLICA_COUNT}

These substitutions happen when you run docker compose up, docker compose config, or any compose command. The values are resolved before the compose file is processed.

Using .env Files for Compose Substitution

Docker Compose automatically reads a file named .env in the same directory as your compose file (or the project root specified by --project-directory). This file supplies values for ${VARIABLE} placeholders in the compose YAML — it does NOT set environment variables inside containers unless you explicitly pass them through.

# .env (in project root, same directory as docker-compose.yml)
DOCKER_REGISTRY=ghcr.io
APP_TAG=3.2.1-prod
LOG_LEVEL=warn
HOST_PORT=80
DATA_DIR=/mnt/production-data
REPLICA_COUNT=3

When you run docker compose up, all ${VARIABLE} references in the compose file resolve against this .env file. You can also specify a custom env file path with the --env-file flag:

docker compose --env-file ./config/production.env up -d

Shell Environment Variables

Variables exported in your shell also participate in compose file substitution. However, .env file values take precedence over shell variables. This means if you have APP_TAG both in your shell and in .env, the .env value wins.

# Export in shell, then run compose
export DOCKER_REGISTRY=private-registry.example.com
export APP_TAG=latest
docker compose up -d

# But if .env also contains APP_TAG=3.2.1-prod, that .env value wins

Precedence Rules — The Full Picture

Understanding the merge order prevents subtle production bugs. Here is the complete precedence chain from lowest to highest priority:

  1. Default values specified with ${VAR:-default} syntax
  2. Variables from the project's .env file (or custom --env-file)
  3. Variables set in the shell environment
  4. Variables from multiple env_file entries (merged left to right, last wins)
  5. Variables defined inline under the environment directive

A practical example demonstrating precedence:

# .env file
LOG_LEVEL=info
DB_PASSWORD=from_dotenv

# docker-compose.yml
services:
  api:
    image: myapp:latest
    env_file:
      - ./config/api.env   # Contains: LOG_LEVEL=debug, DB_PASSWORD=from_env_file
    environment:
      - LOG_LEVEL=warn     # This OVERRIDES everything above
      # DB_PASSWORD not overridden here, so env_file value wins over .env

In the container, LOG_LEVEL will be warn (environment directive wins), and DB_PASSWORD will be from_env_file (env_file wins over .env).

Best Practices for Production

Never Hardcode Secrets

This is the cardinal rule. Credentials, tokens, and keys must never appear as literal strings in compose files. Use variable substitution or a secrets manager.

# WRONG — secrets visible in compose file and VCS diff
services:
  db:
    environment:
      - POSTGRES_PASSWORD=SuperSecret123!

# CORRECT — secret pulled from external source
services:
  db:
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}

Use Separate .env Files Per Environment

Maintain distinct variable files for development, staging, and production. Never use a single .env file across all environments.

# Project structure
project/
├── docker-compose.yml
├── env/
│   ├── development.env
│   ├── staging.env
│   └── production.env
└── config/
    ├── api.dev.env
    ├── api.staging.env
    └── api.prod.env

# Deployment command
docker compose --env-file ./env/production.env up -d

Your CI/CD pipeline should select the correct env file based on the deployment target.

Use Secrets Management for Sensitive Data

For high-security production environments, plain env files are insufficient. Docker Swarm offers native secrets, and for compose-based deployments, integrate with HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault. A common pattern is to fetch secrets at container startup using an init script.

# Production entrypoint wrapper script
#!/bin/sh
# Fetch secrets from vault and export as env vars before starting app
export DB_PASSWORD=$(vault read -field=password secret/db/production)
export API_KEY=$(vault read -field=key secret/api/production)
exec node /app/server.js
# docker-compose.yml
services:
  api:
    image: myapp:latest
    entrypoint: ["/usr/local/bin/secret-init.sh"]
    volumes:
      - ./secret-init.sh:/usr/local/bin/secret-init.sh:ro

Validate Variables at Startup

Missing or malformed environment variables should cause an immediate, loud failure — not a cryptic crash 20 minutes later. Add validation logic to your application entrypoint or use a validation wrapper.

# validation script snippet
REQUIRED_VARS="DB_HOST DB_PORT DB_USER DB_PASSWORD REDIS_URL"
for var in $REQUIRED_VARS; do
  if [ -z "${!var}" ]; then
    echo "FATAL: Required environment variable $var is not set"
    exit 1
  fi
done

# Validate numeric values
if ! [[ "$DB_PORT" =~ ^[0-9]+$ ]]; then
  echo "FATAL: DB_PORT must be numeric, got: $DB_PORT"
  exit 1
fi

echo "Environment validation passed"
exec "$@"

Keep Compose Files Generic

A production compose file should be a template with no environment-specific values baked in. Use ${VARIABLE} substitution for everything that differs between environments — image tags, replica counts, resource limits, volume paths, and network names.

# Good: generic compose file
services:
  api:
    image: ${REGISTRY}/api:${API_VERSION}
    deploy:
      replicas: ${API_REPLICAS}
    environment:
      - LOG_LEVEL=${LOG_LEVEL}
    resources:
      limits:
        memory: ${API_MEMORY_LIMIT}

# Bad: production-specific values hardcoded
services:
  api:
    image: ghcr.io/mycompany/api:v3.2.1
    deploy:
      replicas: 3
    environment:
      - LOG_LEVEL=warn
    resources:
      limits:
        memory: 2048M

Document All Variables

Create a reference document listing every variable, its purpose, valid values, defaults, and whether it's required. This is invaluable for onboarding and incident response. Include it as a VARIABLES.md or a commented template env file.

# ============================================
# Production Environment Variables Reference
# ============================================
# Required:
#   DB_HOST        - PostgreSQL hostname (e.g., pg-prod.internal)
#   DB_PASSWORD    - Database password (min 16 chars)
#   REDIS_URL      - Redis connection string
#
# Optional with defaults:
#   LOG_LEVEL=info - One of: debug, info, warn, error
#   PORT=3000      - HTTP listen port
#   METRICS_PORT=9090 - Prometheus metrics port
#
# Resource tuning:
#   API_MEMORY_LIMIT=1024M
#   API_REPLICAS=3
#
# ============================================

Use ARG for Build-Time Variables

When you need values during image build (e.g., installing private packages, setting compile-time flags), use Docker ARG instructions combined with the args directive in compose — not ENV.

# Dockerfile
ARG NPM_REGISTRY_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_REGISTRY_TOKEN}" > ~/.npmrc \
    && npm install \
    && rm ~/.npmrc

# docker-compose.yml
services:
  api:
    build:
      context: .
      args:
        - NPM_REGISTRY_TOKEN=${NPM_TOKEN}

Build args are ephemeral — they do not persist in the final image (unlike ENV). This prevents accidental secret leakage in image layers.

Complete Production Example

Below is a full, production-oriented Docker Compose setup demonstrating all the practices discussed above. It uses a generic compose file, environment-specific variable files, and proper separation of concerns.

# docker-compose.yml — Generic template, no hardcoded values
version: "3.9"

services:
  postgres:
    image: postgres:${PG_VERSION:-15}
    environment:
      POSTGRES_USER: ${PG_USER}
      POSTGRES_PASSWORD: ${PG_PASSWORD}
      POSTGRES_DB: ${PG_DATABASE}
    volumes:
      - pgdata:/var/lib/postgresql/data
    deploy:
      resources:
        limits:
          memory: ${PG_MEMORY_LIMIT:-512M}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DATABASE}"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:${REDIS_VERSION:-7-alpine}
    command: redis-server --requirepass ${REDIS_PASSWORD}
    deploy:
      resources:
        limits:
          memory: ${REDIS_MEMORY_LIMIT:-256M}
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    image: ${DOCKER_REGISTRY}/myapp-api:${API_VERSION}
    env_file:
      - ./config/common.env
      - ./config/api.env
    environment:
      - NODE_ENV=production
      - DB_HOST=postgres
      - REDIS_HOST=redis
    ports:
      - "${API_HOST_PORT}:3000"
      - "${METRICS_PORT:-9090}:9090"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    deploy:
      replicas: ${API_REPLICAS:-2}
      resources:
        limits:
          memory: ${API_MEMORY_LIMIT:-1024M}

  worker:
    image: ${DOCKER_REGISTRY}/myapp-worker:${WORKER_VERSION}
    env_file:
      - ./config/common.env
      - ./config/worker.env
    environment:
      - NODE_ENV=production
      - REDIS_HOST=redis
    depends_on:
      redis:
        condition: service_healthy
    deploy:
      replicas: ${WORKER_REPLICAS:-1}
      resources:
        limits:
          memory: ${WORKER_MEMORY_LIMIT:-512M}

volumes:
  pgdata:

Here is the production-specific env file that provides values for all the ${VARIABLE} placeholders in the compose template:

# ./env/production.env — Production values
# Docker registry and image tags
DOCKER_REGISTRY=ghcr.io/mycompany
API_VERSION=3.2.1
WORKER_VERSION=3.2.1
PG_VERSION=15
REDIS_VERSION=7-alpine

# PostgreSQL
PG_USER=prod_user
PG_DATABASE=app_production
PG_MEMORY_LIMIT=2048M

# Redis
REDIS_MEMORY_LIMIT=512M

# API service
API_HOST_PORT=80
METRICS_PORT=9090
API_REPLICAS=4
API_MEMORY_LIMIT=2048M

# Worker service
WORKER_REPLICAS=3
WORKER_MEMORY_LIMIT=1024M

Secrets are kept in a separate file that is never committed to version control and is injected at deployment time via a secrets manager or encrypted CI/CD variables:

# ./config/common.env — Shared runtime variables (no secrets here)
LOG_LEVEL=warn
TZ=UTC
ENABLE_COMPRESSION=true

# ./config/api.env — API-specific runtime vars (no secrets)
API_TIMEOUT_MS=5000
MAX_BODY_SIZE=10mb
CORS_ORIGINS=https://app.example.com

Secrets are fetched and injected at container startup:

# ./scripts/init-secrets.sh — Mounted as entrypoint wrapper
#!/bin/sh
set -e

# Fetch secrets from AWS Secrets Manager (example)
SECRETS=$(aws secretsmanager get-secret-value \
  --secret-id production/myapp \
  --query SecretString \
  --output text)

# Export each secret as an environment variable
PG_PASSWORD=$(echo "$SECRETS" | jq -r '.PG_PASSWORD')
REDIS_PASSWORD=$(echo "$SECRETS" | jq -r '.REDIS_PASSWORD')

export PG_PASSWORD REDIS_PASSWORD

# Validate critical variables
for var in PG_PASSWORD REDIS_PASSWORD; do
  if [ -z "${!var}" ]; then
    echo "FATAL: Secret $var is empty — cannot start"
    exit 1
  fi
done

echo "Secrets loaded and validated successfully"
exec "$@"

The final deployment command brings everything together:

# Production deployment script
#!/bin/bash
set -euo pipefail

# Load production env file for compose substitution
docker compose \
  --env-file ./env/production.env \
  --project-name myapp-prod \
  up -d --build --force-recreate --remove-orphans

# Verify all containers are healthy
docker compose --project-name myapp-prod ps --format json | \
  jq -e '.[] | select(.Health != "healthy")' && \
  echo "ERROR: Unhealthy containers detected" && exit 1

echo "Deployment complete — all services healthy"

Conclusion

Environment variables in Docker Compose are the backbone of secure, flexible production deployments. By separating the generic compose template from environment-specific values, keeping secrets out of source control, and validating variables at startup, you build a deployment system that is both robust and auditable. The key takeaways are: use .env files for compose file substitution, use env_file for container runtime variables, never hardcode secrets, maintain per-environment configuration files, and always validate required variables before your application starts. With these practices in place, you can confidently deploy the same container image from development through staging to production, knowing that the only difference is the configuration it receives — exactly as it should be.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles