Environment variables are the 12-factor way to configure applications. But “just use env vars” glosses over real complexity. Here’s how to do it well.

The Basics Done Right

Type Coercion

Environment variables are always strings. Handle conversion explicitly:

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

def get_env_bool(key: str, default: bool = False) -> bool:
    value = os.getenv(key, "").lower()
    if value in ("true", "1", "yes", "on"):
        return True
    if value in ("false", "0", "no", "off"):
        return False
    return default

def get_env_int(key: str, default: int = 0) -> int:
    try:
        return int(os.getenv(key, default))
    except ValueError:
        return default

# Usage
DEBUG = get_env_bool("DEBUG", False)
PORT = get_env_int("PORT", 8080)
WORKERS = get_env_int("WORKERS", 4)

Required vs Optional

Fail fast on missing required config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def get_env_required(key: str) -> str:
    value = os.getenv(key)
    if value is None:
        raise RuntimeError(f"Required environment variable {key} is not set")
    return value

# Application startup
DATABASE_URL = get_env_required("DATABASE_URL")
SECRET_KEY = get_env_required("SECRET_KEY")

# Optional with sensible defaults
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")

Structured Configuration

Config Classes

Group related settings:

 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
from dataclasses import dataclass
from typing import Optional

@dataclass
class DatabaseConfig:
    url: str
    pool_size: int = 5
    pool_timeout: int = 30
    
    @classmethod
    def from_env(cls) -> "DatabaseConfig":
        return cls(
            url=get_env_required("DATABASE_URL"),
            pool_size=get_env_int("DB_POOL_SIZE", 5),
            pool_timeout=get_env_int("DB_POOL_TIMEOUT", 30),
        )

@dataclass  
class AppConfig:
    debug: bool
    port: int
    database: DatabaseConfig
    redis_url: Optional[str]
    
    @classmethod
    def from_env(cls) -> "AppConfig":
        return cls(
            debug=get_env_bool("DEBUG", False),
            port=get_env_int("PORT", 8080),
            database=DatabaseConfig.from_env(),
            redis_url=os.getenv("REDIS_URL"),
        )

# Single point of configuration loading
config = AppConfig.from_env()

Validation at Startup

Catch misconfigurations before serving traffic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def validate_config(config: AppConfig) -> None:
    errors = []
    
    if config.port < 1 or config.port > 65535:
        errors.append(f"Invalid PORT: {config.port}")
    
    if config.database.pool_size < 1:
        errors.append(f"Invalid DB_POOL_SIZE: {config.database.pool_size}")
    
    if not config.database.url.startswith(("postgres://", "postgresql://")):
        errors.append("DATABASE_URL must be a PostgreSQL connection string")
    
    if errors:
        raise RuntimeError("Configuration errors:\n" + "\n".join(errors))

# On startup
config = AppConfig.from_env()
validate_config(config)

Secret Management

Never Log Secrets

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

class SecretString(str):
    """String that doesn't reveal itself in logs."""
    def __repr__(self) -> str:
        return "SecretString(***)"
    
    def __str__(self) -> str:
        return "***"
    
    @property
    def value(self) -> str:
        return super().__str__()

# Usage
API_KEY = SecretString(get_env_required("API_KEY"))
logging.info(f"Config loaded: API_KEY={API_KEY}")  # Logs: API_KEY=***
requests.get(url, headers={"Authorization": API_KEY.value})  # Uses real value

Separate Secret Sources

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

def get_secret(key: str) -> str:
    """Load secret from file (Docker secrets) or env var."""
    # Try Docker secrets first
    secret_file = Path(f"/run/secrets/{key.lower()}")
    if secret_file.exists():
        return secret_file.read_text().strip()
    
    # Fall back to env var
    value = os.getenv(key)
    if value is None:
        raise RuntimeError(f"Secret {key} not found in /run/secrets or environment")
    return value

DATABASE_PASSWORD = get_secret("DATABASE_PASSWORD")

Docker Patterns

Compose with .env

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# docker-compose.yml
services:
  api:
    image: myapp:latest
    env_file:
      - .env
      - .env.local  # Overrides, git-ignored
    environment:
      # Explicit overrides
      - LOG_LEVEL=DEBUG
1
2
3
4
5
6
7
8
# .env (committed, defaults)
PORT=8080
LOG_LEVEL=INFO
WORKERS=4

# .env.local (git-ignored, secrets)
DATABASE_URL=postgres://user:pass@localhost/db
SECRET_KEY=dev-secret-key

Multi-Stage Variable Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Build args (available during build)
ARG NODE_ENV=production

# Environment vars (available at runtime)
ENV NODE_ENV=${NODE_ENV}
ENV PORT=8080

# Don't bake secrets into images!
# BAD: ENV API_KEY=secret123
# GOOD: Pass at runtime via -e or env_file

Kubernetes Patterns

ConfigMaps for Non-Secrets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  LOG_LEVEL: "INFO"
  WORKERS: "4"
  CACHE_TTL: "3600"
---
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    envFrom:
    - configMapRef:
        name: app-config

Secrets for Sensitive Data

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  DATABASE_URL: "postgres://user:pass@host/db"
  API_KEY: "secret-key-here"
---
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app
    envFrom:
    - secretRef:
        name: app-secrets

Combining Both

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
spec:
  containers:
  - name: app
    envFrom:
    - configMapRef:
        name: app-config
    - secretRef:
        name: app-secrets
    env:
    # Individual overrides
    - name: POD_NAME
      valueFrom:
        fieldRef:
          fieldPath: metadata.name

Anti-Patterns to Avoid

❌ Parsing Complex Structures

1
2
3
4
5
6
# BAD: JSON in env vars
FEATURES='{"dark_mode": true, "beta": false}'

# GOOD: Flat keys
FEATURE_DARK_MODE=true
FEATURE_BETA=false

❌ Mutable Defaults

1
2
3
4
5
6
7
8
# BAD: Mutable default
def get_list(key: str, default=[]) -> list:
    ...

# GOOD: Immutable default
def get_list(key: str, default: tuple = ()) -> list:
    value = os.getenv(key)
    return value.split(",") if value else list(default)

❌ Global State Pollution

1
2
3
4
5
6
7
8
# BAD: Import-time side effects
DATABASE = connect(os.getenv("DATABASE_URL"))  # Runs at import!

# GOOD: Explicit initialization
def create_app():
    config = AppConfig.from_env()
    database = connect(config.database.url)
    return App(config, database)

Testing Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import pytest
from unittest.mock import patch

def test_config_from_env():
    env = {
        "DATABASE_URL": "postgres://test/db",
        "PORT": "9000",
        "DEBUG": "true",
    }
    
    with patch.dict(os.environ, env, clear=True):
        config = AppConfig.from_env()
        
        assert config.port == 9000
        assert config.debug is True
        assert "test/db" in config.database.url

def test_missing_required_var():
    with patch.dict(os.environ, {}, clear=True):
        with pytest.raises(RuntimeError, match="DATABASE_URL"):
            AppConfig.from_env()

The Principles

  1. Fail fast: Validate all config at startup
  2. Type safely: Convert strings explicitly
  3. Group logically: Related settings in config classes
  4. Separate secrets: Different handling for sensitive data
  5. Test thoroughly: Config bugs are runtime bugs

Configuration is code. Treat it that way.