Configuration management sounds simple until you’re debugging why production is reading from the staging database at 3am. Here’s how to structure configuration so environments stay isolated and secrets stay secret.

The Twelve-Factor Baseline

The Twelve-Factor App got it right: store config in environment variables. But that’s just the starting point. Real systems need layers.

RSEDuenencvftriaierumtolesntmEMecnanovntnia-frgsioepgnremcie(innVftaicucoVldatcer,oinaAfbWilSgesSfSiMl,esetc.)HLiogwheessttpprriioorriittyy

Each layer overrides the one below it. Defaults live in code, environment-specific values in config files, secrets in a secrets manager, and runtime overrides in environment variables.

Pattern 1: Typed Configuration Classes

Don’t sprinkle os.getenv() throughout your code. Centralize configuration into typed classes:

 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
from pydantic_settings import BaseSettings
from typing import Optional
from functools import lru_cache

class DatabaseConfig(BaseSettings):
    host: str = "localhost"
    port: int = 5432
    name: str = "app_dev"
    user: str = "postgres"
    password: str  # Required, no default
    
    class Config:
        env_prefix = "DB_"

class AppConfig(BaseSettings):
    environment: str = "development"
    debug: bool = False
    log_level: str = "INFO"
    
    database: DatabaseConfig = DatabaseConfig()
    
    class Config:
        env_prefix = "APP_"

@lru_cache
def get_config() -> AppConfig:
    return AppConfig()

Benefits:

  • Validation at startup (fail fast if config is wrong)
  • Type hints and IDE autocomplete
  • Single source of truth
  • Easy testing with mock configs

Pattern 2: Environment Detection

Never hardcode environment names in business logic. Detect once, use everywhere:

 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
import os
from enum import Enum

class Environment(Enum):
    DEVELOPMENT = "development"
    STAGING = "staging"
    PRODUCTION = "production"
    TEST = "test"

def get_environment() -> Environment:
    env_name = os.getenv("APP_ENVIRONMENT", "development").lower()
    try:
        return Environment(env_name)
    except ValueError:
        raise ValueError(
            f"Unknown environment: {env_name}. "
            f"Valid options: {[e.value for e in Environment]}"
        )

# Usage
env = get_environment()

if env == Environment.PRODUCTION:
    # Production-specific behavior
    enable_strict_security()
elif env == Environment.DEVELOPMENT:
    # Dev conveniences
    enable_debug_toolbar()

Pattern 3: Secrets Manager Integration

Secrets shouldn’t live in environment variables on disk. Fetch them at runtime:

 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
import boto3
from functools import lru_cache
import json

class SecretsManager:
    def __init__(self):
        self._client = boto3.client('secretsmanager')
        self._cache = {}
    
    def get_secret(self, secret_name: str) -> dict:
        if secret_name not in self._cache:
            response = self._client.get_secret_value(SecretId=secret_name)
            self._cache[secret_name] = json.loads(response['SecretString'])
        return self._cache[secret_name]

# Integration with config
class DatabaseConfig(BaseSettings):
    host: str = "localhost"
    port: int = 5432
    name: str = "app"
    
    @property
    def credentials(self) -> dict:
        # Fetch from secrets manager on first access
        return secrets_manager.get_secret(f"db/{get_environment().value}")
    
    @property
    def password(self) -> str:
        return self.credentials["password"]

Pattern 4: Feature Flags Separate from Config

Configuration is for infrastructure. Feature flags are for behavior. Keep them separate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# config.py - Infrastructure
class Config:
    database_url: str
    redis_url: str
    log_level: str

# features.py - Behavior
class Features:
    new_checkout_flow: bool = False
    dark_mode: bool = True
    ai_recommendations: bool = False
    
    @classmethod
    def for_environment(cls, env: Environment) -> "Features":
        if env == Environment.PRODUCTION:
            return cls(
                new_checkout_flow=False,  # Not ready yet
                ai_recommendations=True,
            )
        else:
            return cls(
                new_checkout_flow=True,  # Test in staging
                ai_recommendations=True,
            )

Feature flags can come from a service like LaunchDarkly, a database, or simple environment-based defaults. The key is separating “where does the database live” from “should we show the new UI.”

Pattern 5: Validation at Startup

Fail fast. Don’t discover missing configuration when the first user hits that code path:

 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
def validate_config(config: AppConfig) -> None:
    errors = []
    
    # Check required secrets are present
    if not config.database.password:
        errors.append("DB_PASSWORD is required")
    
    # Check values are sensible
    if config.environment == "production" and config.debug:
        errors.append("DEBUG must be False in production")
    
    # Check connectivity (optional but useful)
    try:
        test_database_connection(config.database)
    except Exception as e:
        errors.append(f"Database connection failed: {e}")
    
    if errors:
        raise ConfigurationError(
            "Configuration validation failed:\n" + 
            "\n".join(f"  - {e}" for e in errors)
        )

# In your app startup
config = get_config()
validate_config(config)  # Crashes immediately if misconfigured

Pattern 6: Configuration Diffing

Know what changed between environments:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def diff_configs(base: AppConfig, compare: AppConfig) -> dict:
    """Show differences between two configurations."""
    diffs = {}
    
    for field in base.__fields__:
        base_val = getattr(base, field)
        compare_val = getattr(compare, field)
        
        if base_val != compare_val:
            # Mask secrets
            if "password" in field.lower() or "secret" in field.lower():
                diffs[field] = {"base": "***", "compare": "***", "changed": True}
            else:
                diffs[field] = {"base": base_val, "compare": compare_val}
    
    return diffs

# Usage: Compare staging to production
staging_config = AppConfig(_env_file=".env.staging")
prod_config = AppConfig(_env_file=".env.production")
print(diff_configs(staging_config, prod_config))

Pattern 7: Safe Defaults

Defaults should be safe, not convenient:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class SecurityConfig(BaseSettings):
    # Safe defaults - require explicit opt-in for dangerous settings
    allow_http: bool = False  # HTTPS required by default
    cors_origins: list[str] = []  # No CORS by default
    rate_limit: int = 100  # Conservative default
    session_timeout_minutes: int = 30
    
    # Development overrides must be explicit
    disable_auth: bool = False  # Never True by default
    
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        
        # Extra safety check
        if self.disable_auth and os.getenv("APP_ENVIRONMENT") == "production":
            raise ValueError("Cannot disable auth in production")

The .env File Hierarchy

For local development, use a clear hierarchy:

......eeeeeennnnnnvvvvvv.....ldtppoeerrcvsooaetddlluuoccpttmiieoonnnt.local######SLDTPhoeerAacvsocra-tdtels-udpstaepeldvcemeeicppfrfilrarifaouicitdldcete(sssc((eoccc((moorcgmmmeoiimmtmttiismitttigett(tndeegto)ddier),tdei)dng)onorreeadl,soercrdeotns')texistlocally)

Load order:

1
2
3
4
5
6
from dotenv import load_dotenv

# Load in order - later files override earlier
load_dotenv(".env")
load_dotenv(f".env.{environment}")
load_dotenv(f".env.{environment}.local", override=True)

Anti-Patterns to Avoid

1. Secrets in code or git:

1
2
# NEVER
DATABASE_PASSWORD = "hunter2"

2. Environment detection by hostname:

1
2
3
# FRAGILE
if "prod" in socket.gethostname():
    env = "production"

3. Different config shapes per environment:

1
2
3
4
5
# CONFUSING
if env == "prod":
    config = {"db": {"primary": "...", "replica": "..."}}
else:
    config = {"database_url": "..."}  # Different structure!

4. Reading config at import time:

1
2
3
4
5
6
7
# BAD - runs before environment is set up
DATABASE_URL = os.getenv("DATABASE_URL")  # Module-level

# GOOD - lazy evaluation
@lru_cache
def get_database_url():
    return os.getenv("DATABASE_URL")

Testing Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import pytest
from unittest.mock import patch

def test_production_requires_https():
    with patch.dict(os.environ, {
        "APP_ENVIRONMENT": "production",
        "SECURITY_ALLOW_HTTP": "true"
    }):
        with pytest.raises(ValueError, match="HTTPS required"):
            get_config()

def test_config_loads_from_secrets_manager(mock_secrets):
    mock_secrets.return_value = {"password": "test123"}
    config = get_config()
    assert config.database.password == "test123"

Summary

Good configuration management:

  • Centralizes all config in typed, validated classes
  • Separates secrets from regular config
  • Fails fast on missing or invalid values
  • Makes environment differences explicit and auditable
  • Defaults to safe values

Your 3am self will thank you when the production database URL is definitely, verifiably, unmistakably not pointing at staging.