Lesson 2: Configuration and Secrets
Pydantic Settings and environment tiers
Module 1 introduced the settings object. Production needs it to handle several environments without separate code paths.
from typing import Literal
from pydantic import Field, PostgresDsn, RedisDsn, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
Environment = Literal["local", "test", "staging", "production"]
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="forbid",
)
environment: Environment = "local"
# Infrastructure
database_url: PostgresDsn
redis_url: RedisDsn
db_pool_size: int = Field(default=10, ge=1, le=100)
# Providers
provider_name: str = "fast"
provider_api_key: str = Field(min_length=10)
embedding_model: str
generation_model: str
# Budgets, from Module 10
max_output_tokens: int = Field(default=4096, ge=1)
daily_cost_cap_usd: Decimal = Field(default=Decimal("100"), gt=0)
# Behaviour
log_level: str = "INFO"
enable_debug_endpoints: bool = False
@model_validator(mode="after")
def production_requires_stricter_settings(self) -> "Settings":
if self.environment != "production":
return self
if self.enable_debug_endpoints:
raise ValueError("debug endpoints must be disabled in production")
if self.log_level == "DEBUG":
raise ValueError("DEBUG logging in production risks logging sensitive data")
return self
Three things worth noting.
One class, many environments. The environment is a field rather than a branch in the code, and environment-specific rules live in a validator where they are visible and testable.
The cross-environment validator is the useful part. Debug endpoints enabled in production and DEBUG logging in production are two real incidents this prevents, and the second connects directly to Module 11's rule about never logging content.
Types that validate. PostgresDsn rejects a malformed URL at startup rather than at first query.
Precedence, highest first: real environment variables, then .env, then defaults. Production reads only from the environment, since Module 1 established that .env files are a local convenience.
Fail-fast validation at startup
Module 1 covered why. Production adds one requirement: validate the whole world at startup, not only the settings object.
async def verify_startup(settings: Settings, deps: Dependencies) -> None:
"""Confirm every dependency is reachable before accepting traffic."""
checks: list[tuple[str, Awaitable[None]]] = [
("database", deps.repository.ping()),
("redis", deps.cache.ping()),
("retriever", deps.retriever.ping()),
("provider", deps.provider.check_credentials()),
]
failures: list[str] = []
for name, check in checks:
try:
async with asyncio.timeout(5):
await check
except Exception as exc:
failures.append(f"{name}: {type(exc).__name__}: {exc}")
if failures:
raise StartupError("dependency checks failed:\n" + "\n".join(failures))
A process that starts successfully and fails on the first request wastes a deployment and produces user-visible errors. A process that refuses to start fails the deployment, which the rollout then stops, and nobody sees anything.
Note the timeout on each check, following Module 5. A startup check that hangs is worse than one that fails, because a hung process may pass a liveness probe while being unable to serve.
Report all failures, not the first. Fixing four configuration problems one deployment at a time is a bad afternoon.
Secrets handling and key rotation
Module 1's rule stands: a secret never enters a tracked file. Production adds where they come from and how they change.
Where secrets come from, in order of preference. A secrets manager, read at startup or injected by the platform. Platform-managed environment variables, meaning your orchestrator's secret mechanism. Environment variables set by a deployment process. Never a file in the image, and never a build argument, since both persist in image layers that travel wherever the image goes.
Rotation is the part usually left undesigned until an incident forces it.
class RotatableCredential:
"""Holds a current and a previous key so rotation causes no downtime."""
def __init__(self, current: str, previous: str | None = None) -> None:
self._current = current
self._previous = previous
def current(self) -> str:
return self._current
def all_valid(self) -> list[str]:
"""For verifying inbound credentials during an overlap window."""
return [k for k in (self._current, self._previous) if k]
The rotation procedure, which is the same overlap shape as Module 7's expand-and-contract migration. Issue a new key while the old one still works. Deploy configuration containing both. Switch outbound calls to the new key. Confirm nothing is using the old one, which requires that you can observe it. Revoke the old key. Remove it from configuration.
Design for rotation before you need it. The moment you need to rotate is usually the moment a key has leaked, and a system requiring downtime to change a key will not be rotated on the schedule anyone intended.
Never log a secret, including in a repr, a settings dump, or an error message. Module 7 excluded it from __repr__ and Module 11 added the redaction filter. Both exist because this leaks by accident, not by intent.