Configuration management is one of the most common sources of Docker deployment failures. Hardcoded values, leaked secrets, environment-specific bugs, and variable precedence confusion all trace back to poor configuration practices. This guide covers every mechanism Docker provides for injecting configuration into containers and establishes best practices that scale from a single developer machine to production clusters.

The .env File

Docker Compose automatically reads a .env file in the project directory and uses its values for variable substitution in docker-compose.yml.

# .env file (project root)
POSTGRES_VERSION=16
POSTGRES_PASSWORD=dev-password-only
APP_PORT=8080
NODE_ENV=production
LOG_LEVEL=info
# docker-compose.yml - variables from .env are substituted
services:
  db:
    image: postgres:${POSTGRES_VERSION}
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

  app:
    image: myapp:latest
    ports:
      - "${APP_PORT}:8080"
    environment:
      NODE_ENV: ${NODE_ENV}
      LOG_LEVEL: ${LOG_LEVEL}
      DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/mydb

.env File Syntax Rules

# Comments start with #
# Each line is KEY=VALUE

# Simple values
APP_NAME=myapp
APP_PORT=8080

# Values with spaces need quotes
APP_DESCRIPTION="My Application Server"

# No spaces around = sign
GOOD=value
# BAD = value  (this will NOT work as expected)

# Multi-line values are NOT supported in Docker .env files
# Use single line or escape mechanisms

# Empty values
EMPTY_VAR=
# Unset variable (line omitted entirely)
Warning: The .env file should NEVER be committed to version control if it contains secrets. Add it to .gitignore and provide a .env.example template instead.

Docker Compose Environment Options

Docker Compose provides multiple ways to set environment variables inside containers. Understanding the differences is crucial:

The environment Key

services:
  app:
    image: myapp:latest
    environment:
      # Map syntax (recommended - explicit)
      NODE_ENV: production
      LOG_LEVEL: debug
      # Can reference .env or shell variables
      DATABASE_URL: postgres://app:${DB_PASS}@db:5432/mydb

      # List syntax (alternative)
      # - NODE_ENV=production
      # - LOG_LEVEL=debug

The env_file Key

services:
  app:
    image: myapp:latest
    env_file:
      - ./common.env        # Shared config
      - ./app.env            # App-specific config
      - ./secrets.env        # Secrets (not in git)
    environment:
      # These OVERRIDE values from env_file
      LOG_LEVEL: debug

Variable Precedence

When the same variable is defined in multiple places, Docker Compose uses this precedence order (highest to lowest):

  1. Compose CLI -e or shell environment: DB_PASS=secret docker compose up
  2. environment key in docker-compose.yml (with substituted values)
  3. env_file key in docker-compose.yml (last file wins for duplicates)
  4. .env file (only for variable substitution in the Compose file itself)
  5. Dockerfile ENV instruction (lowest priority)
Source Scope Priority Use Case
Shell environment Compose substitution + container Highest CI/CD overrides, temporary changes
environment: Container only High Service-specific configuration
env_file: Container only Medium Shared config across services
.env file Compose file substitution only Low Default values for Compose variables
Dockerfile ENV Image default Lowest Fallback defaults
Tip: A common source of confusion is that the .env file is used for Compose file interpolation (${VAR} syntax), while env_file: injects variables directly into the container. They serve different purposes despite both being "env files."

The 12-Factor App Configuration Pattern

The third factor of the 12-factor app methodology states: "Store config in the environment." This means configuration that varies between environments (dev, staging, production) should be injected via environment variables, not stored in files within the application.

# Application reads configuration from environment
# Python example
import os

DATABASE_URL = os.environ["DATABASE_URL"]       # Required - crash if missing
LOG_LEVEL = os.environ.get("LOG_LEVEL", "info")  # Optional with default
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
PORT = int(os.environ.get("PORT", "8080"))
// Go example
func loadConfig() Config {
    return Config{
        DatabaseURL: mustGetEnv("DATABASE_URL"),
        LogLevel:    getEnvOrDefault("LOG_LEVEL", "info"),
        Port:        getEnvOrDefault("PORT", "8080"),
        Debug:       getEnvOrDefault("DEBUG", "false") == "true",
    }
}

func mustGetEnv(key string) string {
    val := os.Getenv(key)
    if val == "" {
        log.Fatalf("Required environment variable %s is not set", key)
    }
    return val
}

Docker Secrets vs Environment Variables

Environment variables have a critical security flaw: they are visible to anyone who can inspect the container.

# Anyone with Docker access can see all environment variables
docker inspect mycontainer --format '{{json .Config.Env}}' | jq .
# [
#   "DATABASE_URL=postgres://admin:s3cret@db:5432/mydb",
#   "API_KEY=sk-live-abc123xyz789",
#   "REDIS_PASSWORD=r3d1s-p@ss"
# ]

# They also appear in /proc inside the container
docker exec mycontainer cat /proc/1/environ | tr '\0' '\n'

Docker secrets provide a more secure alternative:

# Docker Swarm secrets (encrypted at rest, transmitted over mTLS)
echo "s3cret-passw0rd" | docker secret create db_password -

# Reference in Compose (Swarm mode)
services:
  app:
    image: myapp:latest
    secrets:
      - db_password
    environment:
      # Application reads from the secret file instead
      DATABASE_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    external: true
# For non-Swarm environments, use file-based secrets with Compose
services:
  app:
    image: myapp:latest
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
# The secret is mounted at /run/secrets/db_password inside the container

Reading File-Based Secrets in Applications

# Python pattern for _FILE suffix convention
import os

def get_secret(env_var):
    """Read a secret from file if _FILE variant exists, else from env."""
    file_path = os.environ.get(f"{env_var}_FILE")
    if file_path and os.path.exists(file_path):
        with open(file_path, 'r') as f:
            return f.read().strip()
    return os.environ.get(env_var)

db_password = get_secret("DATABASE_PASSWORD")

Many official Docker images (PostgreSQL, MySQL, MariaDB) support the _FILE suffix convention natively. For example, POSTGRES_PASSWORD_FILE=/run/secrets/db_password tells the PostgreSQL image to read the password from that file.

envsubst for Configuration Templates

envsubst substitutes environment variables in text files, useful for generating config files at container startup:

# nginx.conf.template
server {
    listen ${NGINX_PORT};
    server_name ${NGINX_HOST};

    location / {
        proxy_pass http://${UPSTREAM_HOST}:${UPSTREAM_PORT};
    }
}
# Dockerfile
FROM nginx:alpine
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
# The official nginx image runs envsubst automatically on templates/
# Manual envsubst usage in entrypoint
#!/bin/sh
envsubst '${NGINX_PORT} ${NGINX_HOST} ${UPSTREAM_HOST} ${UPSTREAM_PORT}' \
  < /etc/nginx/nginx.conf.template \
  > /etc/nginx/nginx.conf
exec nginx -g 'daemon off;'

Per-Environment Configuration

A robust multi-environment setup uses a base Compose file with environment-specific overrides:

# docker-compose.yml (base)
services:
  app:
    image: myapp:latest
    environment:
      APP_NAME: myapp

  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
# docker-compose.dev.yml (development overrides)
services:
  app:
    build: .
    volumes:
      - .:/app
    environment:
      NODE_ENV: development
      LOG_LEVEL: debug
      DEBUG: "true"
    ports:
      - "8080:8080"
      - "9229:9229"  # Debug port

  db:
    environment:
      POSTGRES_PASSWORD: devpass
    ports:
      - "5432:5432"  # Expose DB for local tools
# docker-compose.prod.yml (production overrides)
services:
  app:
    restart: always
    environment:
      NODE_ENV: production
      LOG_LEVEL: warn
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

  db:
    restart: always
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    deploy:
      resources:
        limits:
          memory: 1G

secrets:
  db_password:
    file: ./secrets/db_password.txt
# Usage
# 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 -d

Framework-Specific Patterns

Node.js / Express

// config.js - centralized configuration
const config = {
  port: parseInt(process.env.PORT || '3000', 10),
  nodeEnv: process.env.NODE_ENV || 'development',
  database: {
    url: process.env.DATABASE_URL || 'postgres://localhost:5432/mydb',
    pool: parseInt(process.env.DB_POOL_SIZE || '10', 10),
  },
  redis: {
    url: process.env.REDIS_URL || 'redis://localhost:6379',
  },
  isDev: process.env.NODE_ENV !== 'production',
};

module.exports = config;

Spring Boot (Java)

# application.yml - Spring Boot reads env vars automatically
# SPRING_DATASOURCE_URL env var maps to spring.datasource.url
spring:
  datasource:
    url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/mydb}
    username: ${DB_USER:postgres}
    password: ${DB_PASSWORD:}
  redis:
    host: ${REDIS_HOST:localhost}
    port: ${REDIS_PORT:6379}

server:
  port: ${SERVER_PORT:8080}

logging:
  level:
    root: ${LOG_LEVEL:INFO}

Security Considerations

  • Never commit .env files containing real secrets to version control
  • Use .env.example as a template with placeholder values
  • Prefer Docker secrets over environment variables for sensitive data
  • Restrict docker inspect access through RBAC (usulnet enforces this with its three-tier role system)
  • Rotate secrets regularly and audit access logs
  • Encrypt .env files at rest if stored on disk (use tools like SOPS, age, or git-crypt)
# Using SOPS to encrypt .env files
# Encrypt
sops --encrypt --age $(cat ~/.config/sops/age/keys.txt | grep "public key" | awk '{print $NF}') \
  .env.production > .env.production.enc

# Decrypt
sops --decrypt .env.production.enc > .env.production

# The encrypted file is safe to commit to git

Best practice: Treat configuration as part of your deployment artifact. Use a consistent naming convention for environment variables, validate all required variables at application startup (fail fast if missing), and document every variable in your .env.example file with comments explaining its purpose and valid values.