Settings Classes¶
Settings live in backend/src/infrastructure/config/settings.py and are organized as Pydantic BaseSettings classes — each class groups related variables, and a single Settings class composes them all. Defaults come from backend/.env via Starlette's Config() loader.
Settings Architecture¶
# src/infrastructure/config/settings.py
from pydantic_settings import BaseSettings
from starlette.config import Config
config = Config(env_path) # reads backend/.env
class Settings(
EnvironmentSettings,
DatabaseSettings,
CacheSettings,
RateLimiterSettings,
CORSSettings,
CompressionSettings,
APIDocSettings,
AuthSettings,
APISettings,
AppSettings,
AdminSettings,
SQLAdminSettings,
SecuritySettings,
LoggingSettings,
TaskiqSettings,
):
"""Main settings class that combines all setting categories."""
pass
settings = Settings()
def get_settings() -> Settings:
return settings
Anywhere in the app:
from src.infrastructure.config.settings import get_settings
settings = get_settings()
print(settings.APP_NAME)
Built-in Settings Groups¶
The actual classes that ship with the boilerplate, all in src/infrastructure/config/settings.py:
| Class | Covers |
|---|---|
EnvironmentSettings |
ENVIRONMENT (production/staging/development/local) |
DatabaseSettings |
All POSTGRES_* vars + DATABASE_URL computed property |
CacheSettings |
CACHE_* (Redis + Memcached + client-side) |
RateLimiterSettings |
RATE_LIMITER_* (Redis + Memcached + defaults) |
CORSSettings |
CORS_* |
CompressionSettings |
GZIP_* |
APIDocSettings |
ENABLE_DOCS_IN_PRODUCTION, OPENAPI_PREFIX |
AuthSettings |
SECRET_KEY, SESSION_*, CSRF_*, LOGIN_*, OAUTH_* |
APISettings |
API path overrides (API_PREFIX, DOCS_URL, REDOC_URL) |
AppSettings |
APP_NAME, APP_DESCRIPTION, VERSION, DEBUG, contact info |
AdminSettings |
ADMIN_NAME, ADMIN_EMAIL, ADMIN_USERNAME, ADMIN_PASSWORD, DEFAULT_TIER_NAME |
SQLAdminSettings |
ADMIN_ENABLED |
SecuritySettings |
PRODUCTION_SECURITY_VALIDATION_ENABLED, PRODUCTION_SECURITY_STRICT_MODE |
LoggingSettings |
All LOG_* |
TaskiqSettings |
TASKIQ_* (Redis + RabbitMQ + worker tuning) |
Anatomy of a Settings Group¶
A typical class:
class DatabaseSettings(BaseSettings):
"""Database-related settings."""
POSTGRES_USER: str = config("POSTGRES_USER", default="postgres")
POSTGRES_PASSWORD: str = config("POSTGRES_PASSWORD", default="postgres")
POSTGRES_SERVER: str = config("POSTGRES_SERVER", default="localhost")
POSTGRES_PORT: int = config("POSTGRES_PORT", default=5432)
POSTGRES_DB: str = config("POSTGRES_DB", default="postgres")
POSTGRES_ASYNC_PREFIX: str = config("POSTGRES_ASYNC_PREFIX", default="postgresql+asyncpg://")
CREATE_TABLES_ON_STARTUP: bool = config("CREATE_TABLES_ON_STARTUP", default=True, cast=bool)
POSTGRES_POOL_SIZE: int = config("POSTGRES_POOL_SIZE", default=20, cast=int)
@property
def DATABASE_URL(self) -> str:
"""Construct the full database URL.
Falls back to assembling from POSTGRES_* if DATABASE_URL is not set.
"""
direct_url = config("DATABASE_URL", default=None)
if direct_url:
return direct_url
return (
f"{self.POSTGRES_ASYNC_PREFIX}{self.POSTGRES_USER}:"
f"{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:"
f"{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
Key points:
- Each field uses
config("VAR_NAME", default=..., cast=...). TheConfig()instance is initialized withbackend/.envso values are loaded at import time. - For typed conversion, pass
cast=int,cast=bool, etc. - Use
@propertyfor derived values (likeDATABASE_URL) — no need for@computed_fieldsince callers always go throughget_settings().
Adding Custom Settings¶
Basic Custom Group¶
# backend/src/infrastructure/config/settings.py
class StorageSettings(BaseSettings):
"""File-storage settings."""
STORAGE_BACKEND: str = config("STORAGE_BACKEND", default="local") # "local" or "s3"
LOCAL_STORAGE_PATH: str = config("LOCAL_STORAGE_PATH", default="./uploads")
AWS_ACCESS_KEY_ID: str = config("AWS_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY: str = config("AWS_SECRET_ACCESS_KEY", default="")
AWS_BUCKET_NAME: str = config("AWS_BUCKET_NAME", default="")
AWS_REGION: str = config("AWS_REGION", default="us-east-1")
MAX_UPLOAD_SIZE_BYTES: int = config("MAX_UPLOAD_SIZE_BYTES", default=10_485_760, cast=int)
class Settings(
EnvironmentSettings,
DatabaseSettings,
# ...existing groups...
StorageSettings, # add yours
):
pass
Then add the matching variables to backend/.env.example so they're discoverable.
Computed / Derived Values¶
class StorageSettings(BaseSettings):
STORAGE_BACKEND: str = config("STORAGE_BACKEND", default="local")
AWS_ACCESS_KEY_ID: str = config("AWS_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY: str = config("AWS_SECRET_ACCESS_KEY", default="")
AWS_BUCKET_NAME: str = config("AWS_BUCKET_NAME", default="")
@property
def s3_configured(self) -> bool:
return bool(self.AWS_ACCESS_KEY_ID and self.AWS_SECRET_ACCESS_KEY and self.AWS_BUCKET_NAME)
@property
def storage_enabled(self) -> bool:
if self.STORAGE_BACKEND == "local":
return True
if self.STORAGE_BACKEND == "s3":
return self.s3_configured
return False
Validation¶
For richer validation, switch a field's value to use Pydantic validators:
from pydantic import field_validator, model_validator
class StorageSettings(BaseSettings):
STORAGE_BACKEND: str = config("STORAGE_BACKEND", default="local")
MAX_UPLOAD_SIZE_BYTES: int = config("MAX_UPLOAD_SIZE_BYTES", default=10_485_760, cast=int)
@field_validator("MAX_UPLOAD_SIZE_BYTES")
@classmethod
def _check_upload_size(cls, v: int) -> int:
if v < 1024:
raise ValueError("MAX_UPLOAD_SIZE_BYTES must be at least 1KB")
if v > 100 * 1024 * 1024:
raise ValueError("MAX_UPLOAD_SIZE_BYTES cannot exceed 100MB")
return v
@model_validator(mode="after")
def _check_backend(self) -> "StorageSettings":
if self.STORAGE_BACKEND not in ("local", "s3"):
raise ValueError(f"Unknown STORAGE_BACKEND: {self.STORAGE_BACKEND}")
return self
Validators run when Settings() is instantiated at startup, so misconfiguration fails fast.
Enums for Constrained Values¶
For options with a fixed set of valid values, define a StrEnum in src/infrastructure/config/enums.py and use it as the default:
# enums.py
from enum import StrEnum
class StorageBackend(StrEnum):
LOCAL = "local"
S3 = "s3"
# settings.py
from .enums import StorageBackend
class StorageSettings(BaseSettings):
STORAGE_BACKEND: str = config("STORAGE_BACKEND", default=StorageBackend.LOCAL.value)
The boilerplate already does this for CacheBackend, LogFormat, LogLevel, SessionBackend, TaskiqBrokerType, and EnvironmentOption.
Removing Built-in Groups¶
If you don't use a feature, drop the corresponding class from the Settings MRO:
class Settings(
EnvironmentSettings,
DatabaseSettings,
CORSSettings,
AuthSettings,
APISettings,
AppSettings,
LoggingSettings,
# CacheSettings — removed
# RateLimiterSettings — removed
# TaskiqSettings — removed
):
pass
You'll also want to:
- Remove the now-orphan code that depends on those settings (e.g. cache decorator, taskiq broker, rate limiter middleware)
- Drop the corresponding env vars from
.env.example - Disable startup of those subsystems in
infrastructure/app_factory.py
Testing Settings¶
The test suite uses fixtures that override settings. The general pattern:
import pytest
from src.infrastructure.config.settings import Settings
@pytest.fixture
def test_settings(monkeypatch):
monkeypatch.setenv("ENVIRONMENT", "local")
monkeypatch.setenv("CACHE_ENABLED", "false")
monkeypatch.setenv("RATE_LIMITER_ENABLED", "false")
return Settings()
For one-off overrides without env vars, instantiate the relevant settings class directly with kwargs:
def test_storage_validation():
with pytest.raises(ValueError, match="cannot exceed 100MB"):
StorageSettings(MAX_UPLOAD_SIZE_BYTES=200_000_000)
Best Practices¶
Organization¶
- Group settings by subsystem (cache, auth, taskiq), not by environment
- Keep validation alongside the field it validates
- Add a one-line docstring per class so its purpose is obvious
- Mirror group names in
.env.examplesection headers
Security¶
- Validate
SECRET_KEYlength / strength whenENVIRONMENT=production(the boilerplate already does this via the production security validator) - Never set a real default for credentials — leave them blank and let the validator complain
- Use
@propertyto derive connection strings rather than embedding them in env vars
Performance¶
- The
Settingsinstance is created once at import time and shared viaget_settings()— don't instantiate it per-request - Keep validators cheap; they run at startup but they also run if anyone re-instantiates
Settings
Testing¶
- Use
monkeypatch.setenv(...)to vary env vars per test - Don't reach for the global
settingsin tests when you can pass an instance directly
See Also¶
- Environment Variables — Full variable reference
- Docker Setup — How variables flow into Compose
- Environment-Specific — Recommended values per environment