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,
.envfiles 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
- Make a single settings class the application’s single source of truth.
- Load precedence: “Environment variables >
.env> Defaults”. - Production secrets come from external Secret sources (env vars or file mounts).
- Perform validation and transformation in the settings class (fail immediately at startup).
- 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
LiteralandDsntypes. - Use
repr=Falseto avoid logging secrets. - Use
field_validatorfor 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.
- Process environment variables (e.g.,
ENV=prod) .env(generally do not commit; if you do, commit a template only)- Defaults (initial values in code)
Practical tips
- Provide an
.env.examplein the repo, explicitly marking required fields with comments. - Don’t use
.envin production; inject via managed secrets as environment variables. - Do not give defaults to required fields (make
secret_keyrequired, for example).
4. Multi-Environment Switching (dev/stg/prod)
4.1 Rules
- Switch behavior via the
envvalue. 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
.envwhen 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: boolfields 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, setENV=devand inject a test-only DB andsecret_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
ENVinstructions; inject viadocker run -e KEY=...orcomposeenvironment. - For secrets, use
docker secretor 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
ConfigMapand secrets intoSecret. - 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
- Introduce a single Settings class and prepare
.env.example. Do not provide defaults for required items. - Inject multi-environment values via environment variables; make the app only consume values.
- Add validation and consistency checks. For CORS, DB, external API keys, fail on startup when wrong.
- Move secret management to a Secret Manager or K8s Secret. Establish rotation procedures.
- Introduce Feature Flags and A/B rollout for safe gradual releases.
- Integrate settings injection and schema validation into CI/CD to prevent differential accidents.
References
- Pydantic
- pydantic-settings
- 12-Factor
- FastAPI
- Security/Secrets
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!
