green snake
Photo by Pixabay on Pexels.com

No-Drama Configuration & Secret Management: A Practical FastAPI × pydantic-settings Guide — Environment Variables, .env, Multi-Env Switching, Type Safety, Validation, Secret Operations, and Feature Flags


Summary (Big Picture First)

  • The goal is to standardize configuration and secret handling in FastAPI and safely automate multi-environment switching across development, staging, and production.
  • The recommended architecture centers on pydantic-settings v2, with environment variables as the highest priority, .env files as local helpers, and Secret Managers or Docker/Kubernetes Secrets in production.
  • Keep quality high with type safety and validation, split settings into structures per dependency, use Feature Flags for gradual behavior changes, and apply the same discipline to tests and CI/CD.
  • As a finishing touch, we provide samples for logging, CORS, DB connections, external API keys, and dig into operations such as rotation and revocation during incidents.

Who Will Benefit (Concrete Personas)

  • Learner A (Senior undergrad / solo dev): Wants to reliably understand where to place .env, load order, and the basics of secret management in production.
  • Small Team B (3-person agency): Frequent outages due to staging vs. production differences. Wants a unified Settings model and validation to reduce incidents.
  • SaaS Team C (startup): Wants to introduce Feature Flags and rollouts to experiment safely. Needs a strategy for key rotation and runtime substitution.

Accessibility Review

  • Structure: Front-loaded summary → Core design → Implementation → Multi-env → Secret mgmt → Operations & tests → Pitfalls → Recap (inverted pyramid).
  • Language: Define jargon briefly on first use. Keep code comments minimal and present code in monospaced blocks for readability.
  • Care: Short paragraphs, bullets to reduce eye travel, reiterate audience and benefits in each section.
  • Overall level: AA-equivalent.

1. Core Policy (Through the Lens of the 12-Factor App)

In the 12-Factor App, configuration should be injected via environment variables. Keeping secrets out of the repository is paramount.
pydantic-settings implements this principle with type safety.

Pillars of the policy

  1. Make a single settings class the application’s single source of truth.
  2. Load precedence: “Environment variables > .env > Defaults”.
  3. Production secrets come from external Secret sources (env vars or file mounts).
  4. Perform validation and transformation in the settings class (fail immediately at startup).
  5. Multi-environment switching via Env name and value diffs, with minimal conditional logic.

2. Start with the Smallest Possible Settings Class

2.1 Dependencies

pydantic>=2.6
pydantic-settings>=2.3
python-dotenv  # (optional) .env helper

2.2 Minimal Sample

# app/core/settings.py
from pydantic import Field, PostgresDsn, AnyUrl, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal, Optional

EnvName = Literal["dev", "stg", "prod"]

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",               # local helper
        env_file_encoding="utf-8",
        env_prefix="",                 # optional prefix
        extra="ignore"                 # ignore unexpected keys (can be stricter)
    )

    # Common
    app_name: str = "My FastAPI"
    env: EnvName = "dev"
    host: str = "0.0.0.0"
    port: int = 8000
    log_level: Literal["debug", "info", "warning", "error"] = "info"

    # DB
    database_url: PostgresDsn | str = "postgresql+psycopg://user:pass@localhost:5432/appdb"

    # CORS
    cors_origins: list[str] = ["http://localhost:3000"]

    # External API
    external_api_base: AnyUrl | None = None
    external_api_key: Optional[str] = Field(default=None, repr=False)

    # Security
    secret_key: str = Field(..., repr=False, description="Used for JWT, etc.")
    access_token_expires_minutes: int = 15

    # Feature Flag
    enable_new_search: bool = False

    @field_validator("cors_origins", mode="before")
    @classmethod
    def parse_cors_origins(cls, v):
        if isinstance(v, str):
            # Turn "https://a,https://b" from env vars into a list
            return [s.strip() for s in v.split(",") if s.strip()]
        return v

def get_settings() -> Settings:
    return Settings()

2.3 Usage (in the app)

# app/main.py
from fastapi import FastAPI
from app.core.settings import get_settings

settings = get_settings()
app = FastAPI(title=settings.app_name)

@app.get("/meta")
def meta():
    return {
        "app": settings.app_name,
        "env": settings.env,
        "log_level": settings.log_level,
        "flags": {"new_search": settings.enable_new_search}
    }

Key points

  • Use type annotations to constrain input; reject invalid values early with Literal and Dsn types.
  • Use repr=False to avoid logging secrets.
  • Use field_validator for pragmatic preprocessing like string-to-list conversions for env var inputs.

3. Load Order & Precedence (Startup Guarantees)

By principle, environment variables take top priority. .env is a helper for local development.

  1. Process environment variables (e.g., ENV=prod)
  2. .env (generally do not commit; if you do, commit a template only)
  3. Defaults (initial values in code)

Practical tips

  • Provide an .env.example in the repo, explicitly marking required fields with comments.
  • Don’t use .env in production; inject via managed secrets as environment variables.
  • Do not give defaults to required fields (make secret_key required, for example).

4. Multi-Environment Switching (dev/stg/prod)

4.1 Rules

  • Switch behavior via the env value. Design so that values control behavior and if statements don’t proliferate.
  • Replace things like CORS, logging, and external API endpoints via config values; keep application code unchanged.

4.2 Example: Injecting Environment-Specific Diffs

# dev (.env for local)
ENV=dev
SECRET_KEY=dev-secret
DATABASE_URL=postgresql+psycopg://dev:dev@localhost:5432/app_dev
CORS_ORIGINS=http://localhost:3000

# stg (injected by CI/CD)
ENV=stg
SECRET_KEY=stg-secret
DATABASE_URL=postgresql+psycopg://stg:stg@stg-db:5432/app_stg
CORS_ORIGINS=https://stg.example.com

# prod (injected by a Secret Manager or K8s)
ENV=prod
SECRET_KEY=prod-secret
DATABASE_URL=postgresql+psycopg://prod:prod@prod-db:5432/app
CORS_ORIGINS=https://app.example.com
LOG_LEVEL=warning
ENABLE_NEW_SEARCH=true

4.3 A Value-Only Switching Design

# app/core/bootstrap.py
from app.core.settings import get_settings
from app.core.logging import setup_logging

def bootstrap():
    s = get_settings()
    setup_logging(s.log_level)
    # Pass s.cors_origins as-is to CORS middleware, etc.

The more immutable your app’s startup code is, the less likely environment differences will cause incidents.


5. Splitting into Sub-Configs (Clarity & Reuse)

As settings grow, you’ll want to split by concern. For example, organize them in nested structures like this:

# app/core/settings.py (excerpt, with splits)
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict

class DbConfig(BaseModel):
    url: str
    pool_size: int = 5
    echo: bool = False

class SecurityConfig(BaseModel):
    secret_key: str
    access_token_expires_minutes: int = 15

class CorsConfig(BaseModel):
    origins: list[str] = []
    allow_credentials: bool = True

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    env: EnvName = "dev"
    log_level: Literal["debug", "info", "warning", "error"] = "info"

    db: DbConfig = DbConfig(url="sqlite:///./app.db")   # keep defaults small
    security: SecurityConfig = SecurityConfig(secret_key="PLEASE-SET")
    cors: CorsConfig = CorsConfig(origins=["http://localhost:3000"])

To override nested fields with environment variables, use double underscores (pydantic’s nesting convention), e.g., DB__URL.

# Example
DB__URL=postgresql+psycopg://user:pass@host:5432/appdb
CORS__ORIGINS=https://app.example.com,https://stg.example.com

6. Validation & Consistency Across Dependencies

Configuration should fail at startup when broken. Check consistency across conditions.

# app/core/settings.py (consistency check example)
from pydantic import model_validator

class Settings(BaseSettings):
    # ... (omitted)
    env: EnvName = "dev"
    external_api_base: AnyUrl | None = None
    external_api_key: str | None = None

    @model_validator(mode="after")
    def check_external_api(self):
        if (self.external_api_base is None) ^ (self.external_api_key is None):
            raise ValueError("Set external_api_base and external_api_key together.")
        if self.env == "prod" and self.log_level == "debug":
            raise ValueError("log_level=debug is not allowed in prod.")
        return self

This makes invalid combinations fail immediately, not during deployment.


7. Handling Secrets (Operating Safely)

7.1 Principles

  • Do not hardcode secrets in code; avoid putting them in .env when possible.
  • In production, use a Secret Manager or Kubernetes/Docker Secrets and inject as environment variables.
  • Do not log secrets (repr=False, masking).
  • Plan for rotation (periodic key updates) and revocation (immediate cut-off upon leakage).

7.2 File-Mounted Secrets

Sometimes cloud/Kubernetes Secrets are mounted as files. With a small helper, pydantic-settings can read files easily.

# app/core/secret_loader.py
from pathlib import Path

def from_file(path: str) -> str:
    p = Path(path)
    if not p.exists():
        raise FileNotFoundError(path)
    return p.read_text(encoding="utf-8").strip()
# app/core/settings.py (usage example)
secret_key: str = Field(default_factory=lambda: from_file("/run/secrets/secret_key"))

Assume runtime injection in production while using .env locally as a helper—this is a robust pattern.


8. Feature Flags (Safe Behavioral Switching)

Instead of dropping a new feature into production all at once, turn it on gradually with flags.

8.1 Design

  • Add enable_xxx: bool fields to the settings class.
  • Branch in routes or service layers, but localize the branching points.
  • For experiments with percentages or cohorts, combine environment variables with user attributes.

8.2 Example

# app/services/search.py
from app.core.settings import get_settings

def search(query: str):
    s = get_settings()
    if s.enable_new_search:
        return new_engine(query)
    return legacy_engine(query)

Operate flags with the assumption that they will be removed; decide a lifetime to prevent rot.


9. Common Setting Samples: Logging, CORS, DB

9.1 Logging

# app/core/logging.py
import json, logging, sys
from typing import Any

class JsonFormatter(logging.Formatter):
    def format(self, record):
        payload: dict[str, Any] = {
            "t": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
            "lvl": record.levelname,
            "name": record.name,
            "msg": record.getMessage(),
        }
        return json.dumps(payload, ensure_ascii=False)

def setup_logging(level: str = "info"):
    h = logging.StreamHandler(sys.stdout)
    h.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers[:] = [h]
    root.setLevel(level.upper())

9.2 CORS

# app/main.py (CORS)
from fastapi.middleware.cors import CORSMiddleware
from app.core.settings import get_settings
s = get_settings()
app.add_middleware(
    CORSMiddleware,
    allow_origins=s.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

9.3 DB Connection (SQLAlchemy)

# app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.core.settings import get_settings
s = get_settings()

engine = create_engine(s.database_url, pool_pre_ping=True, echo=False)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

10. Injecting Settings in Tests & CI

10.1 Using a Dedicated .env.test

  • When running pytest, set ENV=dev and inject a test-only DB and secret_key.
  • Instead of BaseSettings(env_file=".env"), point to a different file only during test startup.
# tests/conftest.py
import os
os.environ["ENV"] = "dev"
os.environ["SECRET_KEY"] = "test-secret"
os.environ["DATABASE_URL"] = "sqlite:///./test.db"

Overriding via environment variables is simple and keeps process-wide consistency.

10.2 Injection in CI/CD

  • Store keys in CI secret stores (GitHub Actions / GitLab CI) and inject as job environment variables.
  • Since pydantic validation runs before merging/deploying, you’ll detect missing settings on the spot.

11. Operating with Docker/Kubernetes

11.1 Docker

  • Don’t put unknown values in ENV instructions; inject via docker run -e KEY=... or compose environment.
  • For secrets, use docker secret or external Secret sources (inject as env vars or read from a file).

docker-compose.yml example:

services:
  app:
    image: my-fastapi:latest
    environment:
      ENV: "stg"
      LOG_LEVEL: "info"
      DATABASE_URL: "${DATABASE_URL}"
      SECRET_KEY: "${SECRET_KEY}"
    ports: ["8000:8000"]

11.2 Kubernetes

  • Split non-secret config into ConfigMap and secrets into Secret.
  • Inject into Pods via environment variables or volumes.
  • During rotation, combine with Rolling Update and stabilize with readiness probes.

12. Failure Behavior & Safety Nets

  • Fail fast on validation errors at startup. It’s safer than running ambiguously.
  • For critical settings, fail closed (disable functionality when values are missing).
  • If an external API key is missing, error out instead of switching to a dummy implementation to prevent accidents.

13. Incident Response (Revocation & Rotation)

  • On key leakage, revoke immediately. Regenerate via the issuer’s console or API and update environment variables.
  • It helps if the app supports config reloads. You can reload on SIGHUP or on a periodic schedule (but reloading secrets at runtime requires careful operational design).

Simple reload hook example:

# app/core/runtime.py
from app.core.settings import Settings
_settings_cache: Settings | None = None

def current_settings() -> Settings:
    global _settings_cache
    if _settings_cache is None:
        _settings_cache = Settings()
    return _settings_cache

def reload_settings():
    global _settings_cache
    _settings_cache = Settings()

14. Patterns That Help in the Real World

14.1 A/B Testing (Percentage Flags)

# app/core/flags.py
import hashlib
from app.core.settings import get_settings

def rollout(user_id: str, percentage: int) -> bool:
    # Stable assignment via hashing
    v = int(hashlib.sha256(user_id.encode()).hexdigest()[:8], 16) % 100
    return v < percentage

def new_ui_enabled(user_id: str) -> bool:
    s = get_settings()
    if not s.enable_new_search:
        return False
    return rollout(user_id, 20)  # ship to 20%

14.2 Switching Destinations (Region/Tenant)

# app/core/endpoint_resolver.py
from app.core.settings import get_settings

def api_base_for_tenant(tenant: str) -> str:
    s = get_settings()
    if s.env == "prod":
        return f"https://{tenant}.api.example.com"
    return f"https://stg-{tenant}.api.example.com"

14.3 Time-Bound Auto-Disable

# app/core/flag_until.py
from datetime import datetime, timezone

def enabled_until(dt_iso: str) -> bool:
    # "2025-12-31T23:59:59Z"
    limit = datetime.fromisoformat(dt_iso.replace("Z","+00:00"))
    return datetime.now(timezone.utc) < limit

15. Common Pitfalls & Remedies

Symptom Cause Remedy
Only prod breaks Hidden .env dependency / missing env var replacement Env vars take precedence, omit defaults for required values
Secrets appear in logs Printed or included in exceptions repr=False, masking, structured logs with field control
CORS fails String not converted to list String→list validator, log final values
Too many flags, confusion Flags accumulate with no expiry Expiry dates and flag removal tasks, naming conventions
Weak validation collapses later Insufficient validation Use field_validator / model_validator to fail at startup

16. Adoption Roadmap

  1. Introduce a single Settings class and prepare .env.example. Do not provide defaults for required items.
  2. Inject multi-environment values via environment variables; make the app only consume values.
  3. Add validation and consistency checks. For CORS, DB, external API keys, fail on startup when wrong.
  4. Move secret management to a Secret Manager or K8s Secret. Establish rotation procedures.
  5. Introduce Feature Flags and A/B rollout for safe gradual releases.
  6. Integrate settings injection and schema validation into CI/CD to prevent differential accidents.

References


Recap

  • For configuration, the fundamentals are type safety, env-var precedence, and .env only as a local helper.
  • Use pydantic-settings for structured config, and stabilize operations by failing at startup via validation and consistency checks.
  • Inject secrets from external Secret sources, and manage their lifecycle with rotation and revocation.
  • Switch environments by values, aiming to keep code immutable. Feature Flags enable gradual rollouts so you can safely trial high-risk changes.
  • Starting today, unify your Settings, review required fields, and prepare .env.example. With a solid foundation, FastAPI development and operations become surprisingly smooth. I’m cheering you on!

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)