The third factor of the 12-Factor App methodology states: “Store config in the environment.” Simple advice that’s surprisingly easy to get wrong.

The Core Principle

Configuration that varies between environments (dev, staging, production) should come from environment variables, not code. This includes:

  • Database connection strings
  • API keys and secrets
  • Feature flags
  • Service URLs
  • Port numbers
  • Log levels

What stays in code: application logic, default behaviors, anything that doesn’t change between deploys.

Loading Environment Variables

Shell Basics

1
2
3
4
5
6
7
8
# Set for current session
export DATABASE_URL="postgres://user:pass@localhost/db"

# Set for single command
DATABASE_URL="postgres://..." ./myapp

# From a file
export $(grep -v '^#' .env | xargs)

The .env File Pattern

1
2
3
4
5
6
7
8
9
# .env.example (committed to repo)
DATABASE_URL=postgres://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=your-api-key-here
LOG_LEVEL=debug

# .env (gitignored, local overrides)
DATABASE_URL=postgres://dev:devpass@localhost:5432/myapp_dev
API_KEY=actual-dev-key

Always commit .env.example, never commit .env.

Language-Specific Loading

Python:

1
2
3
4
5
6
7
8
import os
from dotenv import load_dotenv

load_dotenv()  # Load .env file

DATABASE_URL = os.getenv("DATABASE_URL")
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
PORT = int(os.getenv("PORT", "8080"))

Node.js:

1
2
3
4
5
6
7
require('dotenv').config();

const config = {
  databaseUrl: process.env.DATABASE_URL,
  port: parseInt(process.env.PORT, 10) || 3000,
  debug: process.env.DEBUG === 'true',
};

Go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import (
    "os"
    "strconv"
)

func getEnv(key, fallback string) string {
    if value, ok := os.LookupEnv(key); ok {
        return value
    }
    return fallback
}

var (
    DatabaseURL = os.Getenv("DATABASE_URL")
    Port        = getEnv("PORT", "8080")
    Debug       = getEnv("DEBUG", "false") == "true"
)

Validation at Startup

Fail fast if required config is missing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# config.py
import os
import sys

REQUIRED_VARS = [
    "DATABASE_URL",
    "SECRET_KEY",
    "API_KEY",
]

def validate_config():
    missing = [var for var in REQUIRED_VARS if not os.getenv(var)]
    if missing:
        print(f"Missing required environment variables: {', '.join(missing)}")
        sys.exit(1)

# Call at startup
validate_config()
1
2
3
4
5
6
7
8
// config.js
const required = ['DATABASE_URL', 'SECRET_KEY', 'API_KEY'];

const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
  console.error(`Missing required environment variables: ${missing.join(', ')}`);
  process.exit(1);
}

Type Coercion

Environment variables are always strings. Handle conversions explicitly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import os

class Config:
    # Strings (direct)
    DATABASE_URL = os.getenv("DATABASE_URL")
    
    # Integers
    PORT = int(os.getenv("PORT", "8080"))
    WORKERS = int(os.getenv("WORKERS", "4"))
    
    # Booleans
    DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
    
    # Lists
    ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost").split(",")
    
    # Optional with None default
    SENTRY_DSN = os.getenv("SENTRY_DSN")  # None if not set

Docker and Containers

Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
FROM python:3.11-slim

# Build-time args (not available at runtime)
ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}

# Runtime defaults (can be overridden)
ENV PORT=8080 \
    LOG_LEVEL=info \
    WORKERS=4

COPY . /app
WORKDIR /app

CMD ["python", "main.py"]

Docker Compose

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
version: '3.8'
services:
  app:
    build: .
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
      - REDIS_URL=redis://redis:6379
      - LOG_LEVEL=debug
    env_file:
      - .env.local
    ports:
      - "${HOST_PORT:-8080}:8080"

Kubernetes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  LOG_LEVEL: "info"
  WORKERS: "4"
---
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  DATABASE_URL: "postgres://user:secret@db:5432/myapp"
  API_KEY: "super-secret-key"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  template:
    spec:
      containers:
        - name: app
          image: myapp:latest
          envFrom:
            - configMapRef:
                name: app-config
            - secretRef:
                name: app-secrets
          env:
            # Override or add individual vars
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name

Secrets vs Config

Not all environment variables are equal:

Config (non-sensitive):

  • Log levels
  • Feature flags
  • Service URLs (internal)
  • Worker counts
  • Timeouts

Secrets (sensitive):

  • Database passwords
  • API keys
  • Encryption keys
  • OAuth tokens

Secret Management Options

Development: .env files (gitignored)

CI/CD: Pipeline secrets (GitHub Actions, GitLab CI, etc.)

1
2
3
4
5
6
7
8
9
# GitHub Actions
jobs:
  deploy:
    steps:
      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: ./deploy.sh

Production: Dedicated secrets managers

1
2
3
4
5
6
7
# AWS Secrets Manager
aws secretsmanager get-secret-value --secret-id myapp/prod | \
  jq -r '.SecretString' | jq -r 'to_entries[] | "export \(.key)=\(.value)"'

# HashiCorp Vault
vault kv get -format=json secret/myapp/prod | \
  jq -r '.data.data | to_entries[] | "export \(.key)=\(.value)"'

Environment-Specific Patterns

Layered Configuration

1
2
3
4
5
6
7
8
# Base config (all environments)
source .env.base

# Environment-specific overrides
source .env.${ENVIRONMENT:-development}

# Local overrides (optional, gitignored)
[ -f .env.local ] && source .env.local

Prefix Namespacing

1
2
3
4
# Avoid collisions in shared environments
MYAPP_DATABASE_URL=postgres://...
MYAPP_REDIS_URL=redis://...
MYAPP_LOG_LEVEL=debug
1
2
3
4
5
6
7
8
import os

PREFIX = "MYAPP_"

def get_config(key, default=None):
    return os.getenv(f"{PREFIX}{key}", default)

DATABASE_URL = get_config("DATABASE_URL")

Common Mistakes

Mistake 1: Secrets in Docker Images

1
2
3
4
5
# WRONG - secret baked into image
ENV API_KEY=super-secret-key

# RIGHT - set at runtime
ENV API_KEY=""

Mistake 2: Logging Secrets

1
2
3
4
5
# WRONG
logger.info(f"Connecting with {DATABASE_URL}")

# RIGHT
logger.info(f"Connecting to database at {urlparse(DATABASE_URL).hostname}")

Mistake 3: No Defaults for Optional Config

1
2
3
4
5
# WRONG - crashes if not set
TIMEOUT = int(os.environ["TIMEOUT"])

# RIGHT - sensible default
TIMEOUT = int(os.getenv("TIMEOUT", "30"))

Mistake 4: Trusting .env in Production

1
2
3
4
5
6
# WRONG - loads .env unconditionally
load_dotenv()

# RIGHT - only in development
if os.getenv("ENVIRONMENT") != "production":
    load_dotenv()

Testing with Environment Variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import os
import pytest

@pytest.fixture
def mock_env(monkeypatch):
    """Set up test environment variables."""
    monkeypatch.setenv("DATABASE_URL", "postgres://test:test@localhost/test")
    monkeypatch.setenv("API_KEY", "test-key")
    monkeypatch.setenv("DEBUG", "true")

def test_config_loading(mock_env):
    # Import after env is set
    from myapp import config
    assert config.DEBUG is True
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Jest
describe('config', () => {
  const originalEnv = process.env;

  beforeEach(() => {
    process.env = { ...originalEnv };
  });

  afterAll(() => {
    process.env = originalEnv;
  });

  it('loads database URL', () => {
    process.env.DATABASE_URL = 'postgres://test@localhost/test';
    const config = require('./config');
    expect(config.databaseUrl).toBe('postgres://test@localhost/test');
  });
});

Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Check if variable is set
[ -z "${VAR}" ] && echo "VAR is not set"

# Default value
${VAR:-default}

# Required (exit if not set)
${VAR:?VAR must be set}

# Export all from file
set -a; source .env; set +a

# List all env vars with prefix
env | grep "^MYAPP_"

# Unset variable
unset VAR

Environment variables are the bridge between your code and its deployment context. Get them right, and the same code runs everywhere. Get them wrong, and you’re debugging config issues at 3 AM.

Keep secrets out of code, validate at startup, and fail fast when something’s missing. Your future self will appreciate it.