Why configuration management matters
A production-ready API needs different behavior depending on where it runs: local development, automated tests, staging, and production. Configuration management is the practice of keeping those differences explicit, validated, and safe. In FastAPI projects, the most common approach is: (1) store configuration in environment variables, (2) load and validate them through a settings class, and (3) keep secrets out of source control.
This chapter focuses on separating environments and managing settings such as database URLs, secrets, debug flags, and CORS. The goal is to make it easy to switch environments without changing code.
Environment separation: what changes across dev/test/prod
Typical differences between environments include:
- Database URL: local Postgres vs. managed cloud database; test database vs. production database.
- Secrets: JWT signing keys, API keys, encryption keys (never hard-coded).
- Debug behavior: verbose logging and error details in development; minimal details in production.
- CORS: permissive local origins vs. strict production origins.
- Feature flags: enabling/disabling experimental features.
Instead of branching logic everywhere, centralize these values in a single settings object that the rest of the app reads.
Recommended project structure for configuration
A simple, maintainable structure:
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
app/ config.py main.py ... Where app/config.py defines a settings class and app/main.py loads it once and wires it into the application.
Step-by-step: create a validated settings class
1) Define settings with Pydantic Settings
Use pydantic-settings (Pydantic v2) to load environment variables and validate them. This gives you type safety, defaults, and clear errors when required values are missing.
from typing import List, Optional from pydantic import AnyUrl, Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=('.env',), env_file_encoding='utf-8', extra='ignore', ) # --- Core environment flags --- environment: str = Field(default='development', description='development|testing|production') debug: bool = False # --- Database --- database_url: AnyUrl = Field(..., description='Database connection URL') # --- Secrets --- secret_key: SecretStr = Field(..., min_length=32, description='App secret used for signing/encryption') # --- CORS --- cors_allow_origins: List[str] = Field(default_factory=list) cors_allow_credentials: bool = True cors_allow_methods: List[str] = Field(default_factory=lambda: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) cors_allow_headers: List[str] = Field(default_factory=lambda: ['Authorization', 'Content-Type']) settings = Settings()What this gives you:
database_urlandsecret_keyare required (Field(...)). If missing, the app fails fast on startup with a clear validation error.SecretStrprevents accidental printing of secret values (it masks them in logs and repr).env_fileallows local development to use a.envfile, while production can rely on real environment variables.
2) Add safe defaults and environment-specific constraints
Some values should have safe defaults (like debug=False), while others should be mandatory in production (like strict CORS). You can enforce this with custom validation.
from pydantic import model_validator class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=('.env',), env_file_encoding='utf-8', extra='ignore') environment: str = 'development' debug: bool = False database_url: AnyUrl = Field(...) secret_key: SecretStr = Field(..., min_length=32) cors_allow_origins: List[str] = Field(default_factory=list) @model_validator(mode='after') def validate_environment_rules(self): env = self.environment.lower() if env not in {'development', 'testing', 'production'}: raise ValueError('environment must be development, testing, or production') # In production, require explicit CORS origins (avoid '*') if env == 'production': if not self.cors_allow_origins: raise ValueError('cors_allow_origins must be set in production') if '*' in self.cors_allow_origins: raise ValueError("Do not use '*' in cors_allow_origins in production") # Debug should not be enabled in production if env == 'production' and self.debug: raise ValueError('debug must be False in production') return selfThis keeps your application from starting with insecure settings in production-like environments.
3) Support common environment variable naming
Many teams prefer uppercase environment variables (e.g., DATABASE_URL). Pydantic can map them automatically. You can also add a prefix to avoid collisions.
class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=('.env',), env_prefix='APP_', extra='ignore', ) database_url: AnyUrl = Field(...) secret_key: SecretStr = Field(..., min_length=32) debug: bool = FalseWith env_prefix='APP_', the environment variables become APP_DATABASE_URL, APP_SECRET_KEY, etc.
Step-by-step: wiring settings into FastAPI
1) Load settings once
Load settings at import time (or in a startup hook) so misconfiguration fails early. A common pattern is to expose a get_settings() function that returns a cached instance.
from functools import lru_cache from .config import Settings @lru_cache def get_settings() -> Settings: return Settings()2) Apply CORS settings from configuration
Use the settings values to configure middleware. Keep the middleware configuration centralized so it changes per environment without code edits.
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .settings_loader import get_settings def create_app() -> FastAPI: settings = get_settings() app = FastAPI(debug=settings.debug) if settings.cors_allow_origins: app.add_middleware( CORSMiddleware, allow_origins=settings.cors_allow_origins, allow_credentials=settings.cors_allow_credentials, allow_methods=settings.cors_allow_methods, allow_headers=settings.cors_allow_headers, ) return app app = create_app()In development you might set cors_allow_origins to ["http://localhost:5173"]. In production, you set it to your real frontend origin(s).
Database URLs, secrets, and debug flags: practical patterns
Database URL patterns
Keep a single database_url setting and switch it per environment. Examples:
- Development:
postgresql+psycopg://postgres:postgres@localhost:5432/app_dev - Testing:
postgresql+psycopg://postgres:postgres@localhost:5432/app_test - Production:
postgresql+psycopg://user:pass@db.example.com:5432/app(provided by your platform)
Do not embed production credentials in code or committed files. The production URL should come from the deployment environment.
Secrets: never commit them
Use environment variables for secrets. For local development, store them in a .env file that is ignored by Git.
# .gitignore .env .env.*Create a committed template file instead, so teammates know what to set:
# .env.example (commit this) APP_ENVIRONMENT=development APP_DEBUG=true APP_DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/app_dev APP_SECRET_KEY=change-me-to-a-long-random-string APP_CORS_ALLOW_ORIGINS=["http://localhost:5173"]Then each developer creates their own .env locally by copying .env.example and replacing values.
Debug flags: safe defaults
Default debug to False and only enable it explicitly in development. When debug=True, you may expose stack traces and internal details; your settings validation should prevent this in production.
CORS settings: strict in production, convenient in development
CORS controls which browser-based frontends can call your API. A good approach:
- Development: allow your local frontend origin(s).
- Production: allow only the real domain(s) that host your frontend.
- Avoid
*in production, especially when credentials (cookies/authorization headers) are involved.
Because CORS values are configuration, you can deploy the same code to multiple environments and only change APP_CORS_ALLOW_ORIGINS.
Validation of required settings and fail-fast behavior
Failing fast means the application refuses to start if required configuration is missing or unsafe. This is preferable to starting with partial configuration and failing later under load.
Example: if APP_SECRET_KEY is missing, Pydantic raises a validation error at startup. If APP_ENVIRONMENT=production and APP_DEBUG=true, your custom validator raises an error and prevents an insecure deployment.
Practical step-by-step: switch between local development and production-like configuration
1) Local development with a .env file
Create .env (not committed):
APP_ENVIRONMENT=development APP_DEBUG=true APP_DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/app_dev APP_SECRET_KEY=dev-only-super-long-random-string-change-me APP_CORS_ALLOW_ORIGINS=["http://localhost:5173"]Run your app normally. The settings class loads .env and configures FastAPI accordingly.
2) Production-like run using only environment variables
Simulate production by not using .env and exporting environment variables (or setting them in your process manager/container):
export APP_ENVIRONMENT=production export APP_DEBUG=false export APP_DATABASE_URL='postgresql+psycopg://user:pass@db.internal:5432/app' export APP_SECRET_KEY='a-very-long-random-production-secret-key-value' export APP_CORS_ALLOW_ORIGINS='["https://api.example.com","https://app.example.com"]' uvicorn app.main:appBecause the settings enforce production rules, the app will refuse to start if you forget to set CORS origins, accidentally enable debug, or omit required secrets.
3) Quick verification checklist
| Check | Development | Production-like |
|---|---|---|
| Secrets stored | .env (ignored) | Environment variables / secret manager |
| Debug | Optional true | Must be false |
| CORS | Local origins | Explicit domains, no * |
| Database URL | Local DB | Managed DB URL |