FastAPI Mastery
Topic 16 of 22 — Configuration Management
72.7% complete
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
HIGHEST
2
.env File
From the env_file on disk — convenient for local dev
HIGH
3
Secrets Files
From secrets_dir — used in production containers
MED
4
Default Values
Hardcoded in the BaseSettings class definition
LOWEST
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.