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 importNode 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=10Copy 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 + envconsulDirect-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 NAMEDSecret hygiene scan in CI: gitleaks detect --no-banner (fails the build on committed secrets).
Gotchas
- Env vars are strings:
DEBUG=Falseis truthy as a raw string — always parse through the schema, neverbool(os.environ.get(...)). .envloaded 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.