Configuration, secrets, and environments
Relay needs a database URL, a Redis URL, two API keys, three Langfuse values, a model name, and a daily token budget. On a laptop those point at localhost and a personal key; in production they point at managed services and secrets nobody has seen in plain text. The code should not know which it is. It should read one object, typed and validated, and refuse to start if a value is missing or malformed. pydantic-settings gives you that object, and the rest of this lesson is about where its values come from and how to keep the secret ones secret.
One Settings class
A BaseSettings subclass reads each field from an environment variable of the same name, falling back to a .env file, then to the field's default. Types are validated the same way as any Pydantic model, so a typo in a port number or a missing key is a clear error at construction time.
from functools import lru_cache
from typing import Literal
from pydantic import Field, PostgresDsn, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
env: Literal["dev", "staging", "prod"] = "dev"
database_url: PostgresDsn
redis_url: str = "redis://localhost:6379/0"
openai_api_key: SecretStr
langfuse_public_key: str
langfuse_secret_key: SecretStr
langfuse_base_url: str = "https://cloud.langfuse.com"
answer_model: str = "gpt-6-astra"
daily_token_budget: int = Field(default=200_000, ge=0)
@lru_cache
def get_settings() -> Settings:
return Settings()
Three details carry weight. SecretStr hides the value in repr, logs, and tracebacks; you call .get_secret_value() at the one place that needs the string. extra="ignore" lets the environment contain variables that are not yours, which every real environment does. And get_settings is cached so the whole process shares one instance, which is also what lets tests replace it.
Commit a .env.example with every key and no values, keep .env in .gitignore, and the next person on the project can see what they need to supply.
Using it in FastAPI
Inject the settings as a dependency rather than importing a module-level instance. Handlers stay testable and the construction happens once, in the cached function.
from typing import Annotated
from fastapi import Depends, FastAPI
from relay.settings import Settings, get_settings
app = FastAPI()
SettingsDep = Annotated[Settings, Depends(get_settings)]
@app.get("/health")
async def health(settings: SettingsDep) -> dict[str, str]:
return {"env": settings.env, "model": settings.answer_model}
In tests, app.dependency_overrides[get_settings] = lambda: Settings(env="dev", database_url=..., ...) swaps the whole object, and get_settings.cache_clear() resets it between test modules that need different values.
Construct the settings in the lifespan too, before the engine and the Redis client, so a broken environment fails the process at startup. A missing OpenAI key that surfaces on the first customer question is the expensive way to learn about it.
Environments and where secrets live
Values fall into three kinds, and each has a home per environment.
| Kind | Examples | Laptop | Staging and production |
|---|---|---|---|
| Plain config | Model name, budget, base URLs | .env |
Environment variables set on the platform |
| Secrets | API keys, database password | .env, never committed |
A secrets manager that injects environment variables at start |
| Environment identity | ENV=prod |
.env |
Set once per deployment target |
Two rules follow. Secrets are never baked into a container image or a repository; they reach the process as environment variables at start, from whatever secret store the platform provides. And the env field is the only switch that changes behaviour: permissive CORS in dev, strict everywhere else; a validator that refuses obviously wrong combinations in production.
from pydantic import model_validator
class Settings(BaseSettings):
# fields as above
@model_validator(mode="after")
def prod_is_not_local(self) -> "Settings":
if self.env == "prod" and "localhost" in str(self.database_url):
raise ValueError("prod cannot point at a localhost database")
return self
The Langfuse SDK reads its own variables, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, and LANGFUSE_BASE_URL. Keep those names in Settings so one .env serves both, and the values you validate are the ones the SDK will use.
Engineering note. A startup failure is the cheapest failure you can have. Construct
Settingsbefore anything else in the lifespan, log the non-secret fields once, and let a missing value crash the process with a readable error. A container that restarts three times with "field required: openai_api_key" is a two-minute fix; a running container with a wrong key is an outage.
Where it goes wrong
- A leftover
.envinside the image. Real environment variables win over the file, so a value you meant to leave unset in production gets quietly supplied by the file you forgot to exclude. Add.envto.dockerignore. Settings()at import time in several modules. Each creates its own instance, tests cannot override them, and one of them runs before the environment is loaded. Use the cached function everywhere.- Renaming the Langfuse variables.
RELAY_LANGFUSE_KEYlooks tidy and the SDK will never see it. - Coercion surprises.
DEBUG=falsebecomes the booleanFalse, which is fine; a list field expects a JSON array in the variable, not comma-separated text. - Printing the settings object. Fine with
SecretStr, a leak without it. Make every credential aSecretStrbefore the firstprint(settings).
Try it
Add the Settings class to Relay, replace every os.environ read with the dependency, and construct it in the lifespan. Then remove OPENAI_API_KEY from .env, start the app, and read the error. Put the key back, add the production validator, set ENV=prod with a localhost database URL, and watch it refuse to start.
Next, Docker for Python services packages that validated application into an image that runs the same way everywhere.
Continue with the complete track
Keep your progress and unlock the surrounding lessons, exercises, and complete learning path.
Unlock the complete track