Topic 16 of 22
Configuration Management
Learn how to manage settings, environment variables, secret files, and separate configuration across dev, test, and production environments — the right way with Pydantic's
BaseSettings.
┌─────────────────────────────────────────────┐
│ Configuration Loading Priority │
│ │
│ 1. Environment Variables ← highest │
│ 2. .env File values │
│ 3. Secrets Files (Docker secrets) │
│ 4. Default values in BaseSettings ← low │
└─────────────────────────────────────────────┘
16.1
Settings
BaseSettings
▼
BaseSettings is a special Pydantic class from the
pydantic-settings package. It works just like BaseModel but has a superpower — it can automatically read values from environment variables, .env files, and secret files.
Think of it like a smart config reader. You define your settings as class fields, and Pydantic automatically looks them up from the environment.
Install separately:
pip install pydantic-settings. This is the modern way starting with Pydantic v2.
python — basic basesettings
# config.py from pydantic_settings import BaseSettings class Settings(BaseSettings): # Field name = environment variable name (case-insensitive) app_name: str = "My FastAPI App" debug: bool = False database_url: str # required — no default max_connections: int = 10 secret_key: str = "change-me-in-production" # Create a singleton instance settings = Settings() # Usage print(settings.app_name) # reads APP_NAME from env, else uses default print(settings.database_url) # reads DATABASE_URL from env (required!)
Environment variable names are case-insensitive by default.
database_url can be set via DATABASE_URL or database_url in the environment.Now let's use it inside FastAPI:
python — using settings in fastapi
# main.py from fastapi import FastAPI from functools import lru_cache from config import Settings app = FastAPI() # ✅ Best practice: use lru_cache so settings is created once @lru_cache def get_settings() -> Settings: return Settings() @app.get("/info") def app_info(settings: Settings = Depends(get_settings)): return { "app_name": settings.app_name, "debug": settings.debug } # Run: DATABASE_URL=postgresql://... uvicorn main:app
Without
lru_cache, a new Settings() instance is created for every request — reading files and environment variables each time. Cache it!
You can also use model_config to customise BaseSettings behaviour:
python — model_config options
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", # read from .env file env_file_encoding="utf-8", case_sensitive=False, # DATABASE_URL = database_url extra="ignore", # ignore unknown env vars ) app_name: str = "FastAPI App" debug: bool = False database_url: str
Environment Variables
▼
Environment variables are key-value pairs set in the operating system or shell. They are the most common way to pass configuration into a running app — especially in containers and CI/CD pipelines.
A typical .env file looks like this:
# .env — local development config
APP_NAME=My FastAPI App
DEBUG=true
DATABASE_URL=postgresql://user:pass@localhost/mydb
MAX_CONNECTIONS=20
SECRET_KEY=super-secret-dev-key-change-in-prod
REDIS_URL=redis://localhost:6379
NEVER commit .env files to Git! Add
.env to .gitignore. Commit a .env.example file instead with dummy values as documentation.
python — reading env vars with validation
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import Field, validator class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") app_name: str = "FastAPI App" debug: bool = False # Validation on env vars — just like normal Pydantic fields! port: int = Field(default=8000, ge=1024, le=65535) database_url: str # Lists from env: ALLOWED_HOSTS=localhost,127.0.0.1 allowed_hosts: list[str] = ["localhost"] # Custom validation @validator("database_url") def validate_db_url(cls, v): if not v.startswith(("postgresql", "sqlite")): raise ValueError("Only PostgreSQL or SQLite supported") return v settings = Settings() # Pydantic handles type conversion automatically: # DEBUG=true → bool True # PORT=3000 → int 3000 # ALLOWED_HOSTS=a,b,c → ['a','b','c'] with list parsing
Setting env vars in different ways:
bash — setting environment variables
# 1. Shell export (temporary for current session) export DATABASE_URL="postgresql://user:pass@localhost/db" uvicorn main:app # 2. Inline (for this command only) DATABASE_URL="postgresql://..." DEBUG=true uvicorn main:app # 3. Docker run docker run -e DATABASE_URL="postgresql://..." -e DEBUG=true myapp # 4. Docker Compose env_file # docker-compose.yml: # services: # api: # env_file: .env
Nested settings use double underscores to represent depth in the environment:
python — nested settings with env_prefix
class DatabaseSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="DB_") host: str = "localhost" port: int = 5432 name: str = "mydb" # With env_prefix="DB_", maps to: # DB_HOST, DB_PORT, DB_NAME class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") app_name: str = "My App" db: DatabaseSettings = DatabaseSettings() # In .env: # DB_HOST=production-server.db # DB_PORT=5432
Secrets Files
▼
Secrets files store sensitive values (API keys, DB passwords) as individual files on disk instead of environment variables. This pattern is native to Docker Swarm and Kubernetes secrets — each secret is mounted as a file at a known path.
For example, Docker Swarm mounts secrets to
/run/secrets/<secret_name>. Pydantic's secrets_dir tells BaseSettings to look there.
python — using secrets_dir
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", secrets_dir="/run/secrets", # Docker Swarm secrets path ) app_name: str = "My App" database_url: str secret_key: str # read from /run/secrets/secret_key api_key: str # read from /run/secrets/api_key # File structure on disk: # /run/secrets/ # ├── secret_key ← file contains: "abc123xyz..." # ├── api_key ← file contains: "sk-openai-abc..." # └── database_url ← file contains: "postgresql://..."
For local development you can simulate this by creating a
secrets/ folder and pointing secrets_dir to it. Never commit that folder to Git either!
bash — local secrets simulation
# Create local secrets directory mkdir -p secrets/ # Write each secret as a file echo -n "my-local-secret-key-12345" > secrets/secret_key echo -n "sk-openai-abc123" > secrets/api_key echo -n "postgresql://user:pass@localhost/db" > secrets/database_url # .gitignore echo "secrets/" >> .gitignore
python — local secrets dir
from pathlib import Path class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", secrets_dir=Path("secrets"), # local folder for dev ) secret_key: str = "fallback-dev-key" api_key: str = ""
The priority order when a value exists in multiple places:
1
Environment Variables
Set via shell, Docker -e, or CI/CD pipeline — always wins
2
.env File
From the env_file on disk — convenient for local dev
3
Secrets Files
From secrets_dir — used in production containers
4
Default Values
Hardcoded in the BaseSettings class definition
16.2
Environment Separation
Real applications run in at least three environments: Development, Test, and Production. Each needs different configuration — different DB URLs, different log levels, different secrets. A clean separation prevents accidents (like deleting production data from a dev script).
Dev (Development)
▼
Development is where you write code. It should be easy to run locally with minimal setup, with debug mode on, verbose logging, and a local database.
# .env.dev — development config
APP_ENV=development
DEBUG=true
LOG_LEVEL=DEBUG
DATABASE_URL=sqlite:///./dev.db
SECRET_KEY=dev-secret-not-secure
RELOAD=true
python — environment-aware settings
import os from pydantic_settings import BaseSettings, SettingsConfigDict def get_env_file() -> str: # Pick the .env file based on APP_ENV environment variable env = os.getenv("APP_ENV", "development") return f".env.{env}" class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=get_env_file(), # .env.development, .env.test, etc. env_file_encoding="utf-8", ) app_env: str = "development" debug: bool = True log_level: str = "DEBUG" database_url: str = "sqlite:///./dev.db" secret_key: str = "dev-secret" reload: bool = False @property def is_dev(self) -> bool: return self.app_env == "development" @property def is_prod(self) -> bool: return self.app_env == "production"
bash — run in development
# Loads .env.development automatically
APP_ENV=development uvicorn main:app --reload
Test
▼
The test environment runs automated tests. It uses an isolated database (usually in-memory or a separate test DB), minimal logging, and overrides that make tests predictable and fast.
# .env.test — test environment
APP_ENV=test
DEBUG=false
LOG_LEVEL=WARNING
DATABASE_URL=sqlite:///./test.db
SECRET_KEY=test-secret-key-static
TESTING=true
python — overriding settings in tests
# tests/conftest.py import pytest from fastapi.testclient import TestClient from main import app, get_settings from config import Settings # ✅ Override settings for tests def override_get_settings(): return Settings( app_env="test", debug=False, database_url="sqlite:///./test.db", secret_key="test-secret-static", ) app.dependency_overrides[get_settings] = override_get_settings @pytest.fixture def client(): with TestClient(app) as c: yield c # All tests now use test settings automatically! def test_app_info(client): response = client.get("/info") assert response.json()["debug"] == False
FastAPI's dependency override system is the cleanest way to swap settings in tests — no monkey-patching, no environment variable manipulation needed.
bash — run tests with test config
# Loads .env.test APP_ENV=test pytest # Or use a pytest.ini / pyproject.toml to set it # [tool.pytest.ini_options] # env = ["APP_ENV=test"]
Production
▼
Production is where real users run your app. It demands strict security, no debug mode, strong secrets, and proper database connections. Config should come from environment variables or secrets managers — never from files baked into the image.
# Production — set via CI/CD or secrets manager, NOT a .env file
APP_ENV=production
DEBUG=false
LOG_LEVEL=INFO
DATABASE_URL=postgresql://user:pass@prod-db.internal/mydb
SECRET_KEY=j7k9$xQv!mP2nR#wL4sZ8yT6uE3hG0dF
ALLOWED_HOSTS=api.myapp.com,myapp.com
python — production settings with validation
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import field_validator, model_validator import os class Settings(BaseSettings): model_config = SettingsConfigDict( # In production: NO .env file — read from OS environment only env_file=None if os.getenv("APP_ENV") == "production" else ".env", ) app_env: str = "development" debug: bool = False secret_key: str database_url: str allowed_hosts: list[str] = ["*"] # Enforce security rules in production @model_validator(mode="after") def validate_production_settings(self): if self.app_env == "production": if self.debug: raise ValueError("DEBUG must be False in production!") if len(self.secret_key) < 32: raise ValueError("SECRET_KEY must be at least 32 chars in prod") if "*" in self.allowed_hosts: raise ValueError("ALLOWED_HOSTS must be explicit in prod") return self
Many teams use AWS Secrets Manager, HashiCorp Vault, or GCP Secret Manager in production. You fetch secrets at startup and inject them into env vars — then BaseSettings reads them normally.
Full working example bringing everything together — one
Settings class that works across all three environments:
python — complete multi-environment setup
# config.py — final production-grade setup import os from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import model_validator APP_ENV = os.getenv("APP_ENV", "development") ENV_FILES = { "development": ".env.dev", "test": ".env.test", "production": None, # no file in prod — env vars only! } class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=ENV_FILES.get(APP_ENV), env_file_encoding="utf-8", case_sensitive=False, ) # Core app_env: str = "development" app_name: str = "FastAPI App" debug: bool = False log_level: str = "INFO" # Database database_url: str = "sqlite:///./dev.db" # Security secret_key: str = "dev-only-secret-key" allowed_hosts: list[str] = ["*"] # Helpers @property def is_dev(self): return self.app_env == "development" @property def is_test(self): return self.app_env == "test" @property def is_prod(self): return self.app_env == "production" @model_validator(mode="after") def enforce_prod_rules(self): if self.is_prod: assert not self.debug, "DEBUG must be off in production" assert len(self.secret_key) >= 32, "Weak secret key!" return self @lru_cache def get_settings() -> Settings: return Settings()
python — main.py using get_settings
from fastapi import FastAPI, Depends from config import Settings, get_settings app = FastAPI() @app.get("/health") def health(s: Settings = Depends(get_settings)): return { "status": "ok", "environment": s.app_env, "debug": s.debug } # Dev: APP_ENV=development uvicorn main:app --reload # Test: APP_ENV=test pytest # Prod: APP_ENV=production uvicorn main:app --workers 4
File Structure
.env.dev
.env.test
.env.example ← git ✓
.gitignore ← lists .env.*
config.py
main.py
Per-Env Rules
DEV debug=true, SQLite, verbose logs
TEST debug=false, test DB, minimal logs
PROD no .env file, strong secrets, strict validation
Topic 16 Complete! You now know how to use
BaseSettings for typed config, load values from environment variables and .env files, use secrets files for Docker/Kubernetes, and properly separate dev / test / production configs. Reply "next" to continue to Topic 17: OpenAPI & Documentation.