Docker Environment Management: .env Files, Secrets and Configuration Best Practices
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)
.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):
- Compose CLI
-eor shell environment:DB_PASS=secret docker compose up - environment key in docker-compose.yml (with substituted values)
- env_file key in docker-compose.yml (last file wins for duplicates)
- .env file (only for variable substitution in the Compose file itself)
- 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 |
.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.