CoursePython · Application Architecture and Production Readiness · part 72 of 79
Part 72 · Application Architecture and Production Readiness

Lesson 2: Configuration and Secrets

4 min read·9 Sept 2026

Pydantic Settings and environment tiers

Module 1 introduced the settings object. Production needs it to handle several environments without separate code paths.

python
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.

python
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.

python
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.