# Patterns — Managing Configuration ## Contents - Typed settings with pydantic-settings - .env.example catalog format - Secret manager integration - Config-value-driven behavior (no env-name branches) - Startup validation test - Gotchas ## Typed settings with pydantic-settings ```python # pip install pydantic-settings from pydantic import Field, PostgresDsn, SecretStr from pydantic_settings import BaseSettings class Settings(BaseSettings): # Required in every environment — no default means boot fails without it. database_url: PostgresDsn secret_key: SecretStr # SecretStr never repr()s its value # Safe dev defaults; prod overrides via env. log_level: str = "DEBUG" payments_sandbox: bool = True request_timeout_s: float = Field(10.0, gt=0) # upstream p99 ~3s; 10s bounds hangs model_config = {"env_file": ".env", "frozen": True} settings = Settings() # raises with every missing/invalid key named, at import ``` Node equivalent: `znv`/`zod` — parse `process.env` through a schema once, export the frozen result. ## .env.example catalog format ```bash # --- Required ----------------------------------------------------------- DATABASE_URL=postgresql://app:app@localhost:5432/app # primary database SECRET_KEY=change-me # session signing; generate: openssl rand -hex 32 # --- Optional (defaults shown) ------------------------------------------ LOG_LEVEL=DEBUG # DEBUG|INFO|WARN|ERROR; prod: INFO PAYMENTS_SANDBOX=true # false only in production REQUEST_TIMEOUT_S=10 ``` Copy to `.env` for local dev; `.env` stays in `.gitignore`. ## Secret manager integration Injected-env pattern (works with AWS/GCP/Vault/Doppler — the app stays ignorant): ```bash # deploy layer resolves secrets into env; app just reads env aws secretsmanager get-secret-value --secret-id prod/app --query SecretString ... # or: doppler run -- python -m app / vault agent + envconsul ``` Direct-fetch escape hatch (only when the platform can't inject): ```python def load_secret(name: str) -> str: import boto3 return boto3.client("secretsmanager").get_secret_value(SecretId=name)["SecretString"] ``` Rules either way: fetched at boot, held in memory only, never written to disk or logs. ## Config-value-driven behavior ```python # ❌ environment-name branching — untestable matrix, staging drift if os.environ.get("ENV") == "staging": client = SandboxPayments() # ✅ capability flag — set PAYMENTS_SANDBOX=true wherever sandbox is wanted client = SandboxPayments() if settings.payments_sandbox else LivePayments() ``` ## Startup validation test ```python import subprocess, sys def test_boot_fails_without_database_url(monkeypatch): env = {k: v for k, v in os.environ.items() if k != "DATABASE_URL"} proc = subprocess.run([sys.executable, "-c", "import app.settings"], env=env, capture_output=True, text=True) assert proc.returncode != 0 assert "database_url" in proc.stderr.lower() # the key is NAMED ``` Secret hygiene scan in CI: `gitleaks detect --no-banner` (fails the build on committed secrets). ## Gotchas - Env vars are strings: `DEBUG=False` is truthy as a raw string — always parse through the schema, never `bool(os.environ.get(...))`. - `.env` loaded in production shadows real env injection and hides misconfiguration; enable env_file only outside prod or ensure real env wins (pydantic-settings: real env takes precedence by default). - Default-then-override dicts (`config.update(prod_config)`) make the effective value untraceable; one flat schema, one source. - Printing the settings object at boot is a classic secret leak — use `SecretStr`/redacted repr. - Rotating a secret must not require a rebuild — if it does, the secret is baked into the image (wrong layer). - Feature-flag creep: a "flag" older than one quarter is config wearing a costume — promote it or delete it.