[FastAPI Series 17] Managing Settings and Secrets Safely

한국어 버전

As a service grows you accumulate environment-specific configs, API keys, and database passwords. A “secret” is any value that must not leak to the outside world. pydantic-settings reads environment variables and .env files, then converts them to Python objects. In this episode we center on pydantic-settings, secret stores, and useful uvicorn flags.

Primer: demystifying configuration

  • .env behaves like a “tiny secret memo pad” on your laptop.
  • A Settings class is the “mail carrier” that reads the memo and distributes it across the app.
  • A secret store is a “cloud vault” the team shares, unlocked only with the right key.

Keeping those roles in mind makes the following code easier to digest.

Install the tooling first:

pip install "pydantic-settings>=2.2"  # or: uv add pydantic-settings

This lesson uses Pydantic v2 syntax (model_config, @field_validator). If you're on v1, swap in the old Config class and @validator decorator.

Key terms

  1. Secret: Values such as DB passwords and API keys that must be injected from .env or a secret store without exposure.
  2. env_prefix: A prefix (for example APP_) added to every environment variable so multiple apps on the same host do not mix values.
  3. env_nested_delimiter: A delimiter that enables nested structures such as APP_FEATURE__BETA=true, keeping complex configs tidy.
  4. Secret store: External services like AWS Secrets Manager or HashiCorp Vault that safeguard production secrets and release them only when required.

Practice card

  • Estimated time: 55 minutes (core) / +30 minutes (optional)
  • Prereqs: Episode 16 CORS project, basic .env usage
  • Goal: Load environment-specific values via pydantic-settings and, if needed, extend into secret stores and key strategies

Core practice: Settings + .env

Define a pydantic-settings class to read environment variables safely.

from pydantic import Field, ValidationError, field_validator
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    env: str = "local"
    database_url: str = "sqlite:///./todo.db"
    redis_url: str | None = None
    secret_key: str = Field(
        ...,  # ellipsis means "required field"
        description="Generate with secrets.token_urlsafe(32). Never commit.",
    )
    uv_reload: bool = True  # custom toggle for uvicorn --reload
    secret_store_name: str | None = None

    model_config = {
        "env_file": ".env",
        "env_prefix": "APP_",
        "env_nested_delimiter": "__",
    }

    @field_validator("secret_key")
    @classmethod
    def enforce_secret_strength(cls, value: str) -> str:
        if len(value) < 32:
            raise ValueError("secret_key must be at least 32 characters")
        return value

try:
    settings = Settings()
except ValidationError as exc:
    raise SystemExit(f"Configuration failed: {exc}")

Values resolve in priority order: environment variables (highest) → .env file → class defaults (lowest). Because environment variables sit on top, CI/CD systems can override .env without touching files. Pydantic reads the file once during startup; restart the app whenever you change .env. env_nested_delimiter lets you declare nested settings such as APP_FEATURE__BETA=true, which immediately tells you which feature it drives.

uvicorn --reload watches Python files only—it will not reload .env changes.

Locally you usually do not export APP_* variables, so .env becomes the primary source. During deployment the pipeline intentionally exports APP_SECRET_KEY, APP_DATABASE_URL, and so on, which override whatever the file contains.

Why 32 bytes? secrets.token_urlsafe(32) yields ~256 bits of entropy, which is enough to sign JWTs or webhooks securely.

# Generate a development key once and paste it into .env.local
from secrets import token_urlsafe
print(token_urlsafe(32))

field_validator is declared as a @classmethod because Pydantic passes the class (not an instance) when validating fields.

If the .env file is missing, Pydantic simply ignores it and falls back to environment variables or defaults—it does not crash.

Your local .env might look like this (and should be listed in .gitignore):

# .env.local
APP_ENV=local
APP_SECRET_KEY=local-only-key-generated-with-secrets.token_urlsafe
APP_DATABASE_URL=sqlite:///./todo.db
APP_REDIS_URL=redis://localhost:6379
# When Settings() loads the file, you can access the same data:
settings.env == "local"
settings.secret_key == "local-only-key-generated-with-secrets.token_urlsafe"
# .gitignore
.env*          # ignore every .env* file (wildcard `*`)
!.env.example  # the leading ! re-includes .env.example so you can commit a template
# .env.example (share with teammates; never real secrets)
APP_ENV=local
APP_SECRET_KEY=change-me-with-token-urlsafe-32
APP_DATABASE_URL=sqlite:///./todo.db
APP_REDIS_URL=redis://localhost:6379

Manage per-environment .env files

.env.local
.env.dev
.env.prod

Your deployment pipeline picks the appropriate file and injects flags such as APP_ENV=prod. Keep .env.* files out of version control (.gitignore) and restrict file permissions so only you can read them.

Need a different .env file at runtime? Pass _env_file=".env.dev" when instantiating Settings or set model_config["env_file"] dynamically based on APP_ENV.

Environment Secrets live in How to load
Local dev .env.local (ignored by git) Developers copy template and generate their own keys
CI/staging/prod AWS Secrets Manager, Vault, or platform key store Pipeline fetches secrets, injects as environment variables at runtime

Never copy production secrets into .env. Use .env for convenience locally and swap to a managed store for shared environments.

Without a prefix, two apps running on the same VM would both read SECRET_KEY. Prefixes (APP_SECRET_KEY, ADMIN_SECRET_KEY) keep configs isolated even when the OS shares a global environment.

Nested config example

env_nested_delimiter shines when you want to keep related settings together.

class FeatureFlags(BaseSettings):
    beta: bool = False
    max_retries: int = 3

class Settings(BaseSettings):
    feature: FeatureFlags = FeatureFlags()

    model_config = {"env_nested_delimiter": "__", "env_prefix": "APP_"}

# .env
# APP_FEATURE__BETA=true
# APP_FEATURE__MAX_RETRIES=5

assert Settings().feature.beta is True

Seeing the nested structure in code makes it easier to reason about large config trees.

Without nesting you would end up with flat names like APP_FEATURE_BETA and APP_FEATURE_MAX_RETRIES, which quickly become unreadable as the list grows. The delimiter keeps keys grouped the same way your Python models are grouped.

FeatureFlags also inherits from BaseSettings so it can parse environment variables via the same delimiter. Using BaseModel here would ignore nested env keys.

Add a quick log in app/main.py so you can confirm which settings loaded:


logger = logging.getLogger("settings")
logger.info(
    "[settings] env=%s reload=%s database=%s",
    settings.env,
    settings.uv_reload,
    settings.database_url,
)

# somewhere near startup
logging.basicConfig(level=logging.INFO)

:::terminal{title="Checking settings with APP_ENV", showFinalPrompt="false"}

[
  { cmd: "APP_ENV=local uv run fastapi dev app/main.py", output: "[settings] env=local reload=True database=sqlite:///./todo.db", delay: 500 },
  { cmd: "APP_ENV=prod APP_SECRET_KEY=sk_live uv run uvicorn app.main:app --workers 4", output: "[settings] env=prod reload=False database=sqlite:///./todo.db", delay: 450 }
]

:::

(Windows PowerShell: $env:APP_ENV="local"; uv run fastapi dev app/main.py · Windows cmd: set APP_ENV=local && uv run ...)

uvicorn options tied to settings

if __name__ == "__main__":
    import uvicorn

    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=8000,
        reload=settings.uv_reload,
        workers=1 if settings.env == "local" else 4,
    )

Use environment values to control reload behavior or worker counts so the same codebase runs in both dev and prod. Production bumps worker counts, while local development keeps auto-reload on.

ℹ️ Worker count depends on CPU cores and memory. Start with workers = number_of_cores and profile under load instead of relying on magic numbers.

Test your configuration

def test_settings_defaults():
    s = Settings(_env_file=None)
    assert s.env == "local"
    assert s.database_url.startswith("sqlite")


def test_settings_from_env(monkeypatch):
    monkeypatch.setenv("APP_ENV", "prod")
    monkeypatch.setenv("APP_SECRET_KEY", "x" * 40)
    s = Settings(_env_file=None)
    assert s.env == "prod"


def test_settings_missing_secret(monkeypatch):
    monkeypatch.delenv("APP_SECRET_KEY", raising=False)
    with pytest.raises(ValidationError):
        Settings(_env_file=None)

Treat settings as code: tests catch regressions whenever defaults change.

_env_file=None tells Pydantic to ignore .env so test values never accidentally pull from a developer’s local file.

Tests use pytest’s built-in monkeypatch fixture to set or remove environment variables.

If you omit _env_file=None, tests may pass on your machine (because .env.local is present) but fail in CI where that file is missing—this single argument prevents that mismatch.

Debug missing settings

If a required value such as secret_key is missing, Pydantic raises a ValidationError before your app starts:

pydantic_core.ValidationError: 1 validation error for Settings
secret_key
  Field required [type=missing]

This fast failure prevents you from deploying services with empty credentials.

Optional expansion: secret stores and key strategy

Skip this section if time is tight. Cover it when you prepare for production.

Secret-store integration example

Fetching a secret from AWS Secrets Manager looks like this:

from functools import lru_cache

@lru_cache
def load_secret(name: str) -> dict:
    client = boto3.client("secretsmanager")
    try:
        response = client.get_secret_value(SecretId=name)
    except client.exceptions.ResourceNotFoundException as exc:
        raise RuntimeError(f"Secret {name} missing") from exc
    return json.loads(response["SecretString"])

if settings.env == "prod" and settings.secret_store_name:
    secret_payload = load_secret(settings.secret_store_name)
    db_password = secret_payload["db_password"]
else:
    db_password = "local-dev-password"

Cache secrets in memory (via lru_cache) so you do not exceed AWS rate limits, and inject them as environment variables before starting FastAPI whenever possible.

lru_cache ensures the secret is fetched once per process; if the secret rotates you can clear the cache by restarting the worker or calling load_secret.cache_clear() during rollout.

AWS setup: install boto3, configure credentials via IAM role or ~/.aws/credentials, and grant the role permission to call secretsmanager:GetSecretValue. For local tests you can stub load_secret or run a local Vault container instead.

from unittest.mock import patch

@patch("app.core.config.load_secret", return_value={"db_password": "test"})
def test_prod_secret(mock_secret):
    # run assertions without touching AWS
    ...

Configuration layers as D2

Developer -> (.env.local)
CI/CD -> (.env.dev)
Prod -> (Secrets Manager)
Secrets Manager -> (Environment Variables)
Environment Variables -> FastAPI Settings

Each layer limits who can read sensitive values. Developers only access local files, while production values stay inside the secret manager. During deployment the CI/CD job fetches secrets, injects them as APP_* environment variables, and then launches FastAPI.

Encryption key management

Plan for key rotation across JWT secret_key, OAuth client secrets, and webhook signing keys. Include a key identifier (KID) header so you can tell which key signed a payload—for example, inspect the token header’s kid before verifying it with the latest key.

During rotation keep both the old and new keys active for a short grace period: sign new tokens with the new key, but verify incoming requests using the kid value to pick the correct key from your key set. After the grace period, retire the old key and remove its entry from the store.

ACTIVE_KEYS = {
    "kid-2024-02": "new-key",
    "kid-2024-01": "old-key",  # remove after grace period
}

def verify_token(token: str):
    header = jwt.get_unverified_header(token)
    key = ACTIVE_KEYS[header["kid"]]
    return jwt.decode(token, key, algorithms=["HS256"])

In production store this map in your secret manager (or JWKS endpoint) rather than hardcoding it—the snippet simply illustrates how the kid header selects the right key.

Having a clean settings/secret stack makes it easier to plug in caches or logging layers later. The next episode moves into caching and performance fundamentals.

Exercises

  • Follow along: create a Settings class and flip between .env.local and .env.dev to watch settings change.
  • Extend: build a wrapper that reads from a secret manager (or a fake function) and injects values as environment variables.
  • Debug: capture ValidationError messages for missing or mistyped values, and confirm uvicorn options change per environment.
  • Definition of done: local and deployment configs are separated, and you have a documented plan for secrets and key management.

Common pitfalls

  • ❌ Committing .env with real secrets—always keep them ignored and use .env.example as a template.
  • ❌ Reusing the same secret_key for local + prod—generate unique keys per environment.
  • ❌ Leaving _env_file enabled in tests—CI will fail because .env is absent.
  • ❌ Running uvicorn --reload in staging/prod—file watching eats CPU and hides issues.

Wrap-up

Modeling per-environment settings in code slashes deployment mistakes. Build the habit of injecting secrets safely now so the team can collaborate without leaking sensitive data.

💬 댓글

이 글에 대한 의견을 남겨주세요