Chapter 23, Intermediate
Configuration and environment variables
The code is the same in all environments. What changes (the database, the key, the origins) comes from outside it. This keeps secrets out of Git and allows running the same application in development, testing, and production.
Why outside of code
A secret written in the code goes to the repository, and a repository leaks (a new collaborator, a fork, a backup). And configurations like the database URL change from one environment to another: instead of editing the code for each one, the environment provides the values. It's the "12-factor" application rule: configuration in the environment.
The values reside in environment variables, and in development they are usually read from a file .env, which never goes to Git:
APP_CHAVE_SECRETA=troque-por-uma-chave-aleatoria-com-32-bytes-ou-mais
APP_BANCO_URL=sqlite:///./app.db
APP_EXPIRA_EM_MINUTOS=30
APP_ORIGENS_PERMITIDAS=["http://localhost:5173"]
.env
It is good practice to version a .env.example with the names of the variables and dummy values, so that newcomers know what to configure.
`pydantic-settings`: typed configuration
Reading os.getenv("X") always returns text (or None), without validation. The pydantic-settings reads the variables, converts and validates the types, and refuses to start if something mandatory is missing, which is much better than discovering it in production, in the middle of a request:
uv add pydantic-settings
import os
from functools import lru_cache
from pathlib import Path
from pydantic import Field, SecretStr, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
class Configuracoes(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_", extra="ignore")
chave_secreta: SecretStr
algoritmo: str = "HS256"
expira_em_minutos: int = Field(default=30, gt=0)
banco_url: str = "sqlite:///./app.db"
origens_permitidas: list[str] = ["http://localhost:5173"]
try:
Configuracoes(_env_file=None)
except ValidationError as erro:
print(erro.errors()[0]["type"], erro.errors()[0]["loc"])
missing ('chave_secreta',)
Without the variable APP_CHAVE_SECRETA (which has no default), the application does not even build the configurations. The env_prefix avoids collisions with other system variables.
The types are converted
Environment variables are always text. Pydantic converts: "45" becomes int, and complex values (lists) are read as JSON. And the SecretStr hides the value when printing, which prevents leaking the key in a log:
os.environ["APP_CHAVE_SECRETA"] = "uma-chave-bem-comprida-com-mais-de-32-bytes!"
os.environ["APP_EXPIRA_EM_MINUTOS"] = "45"
os.environ["APP_ORIGENS_PERMITIDAS"] = '["https://app.exemplo.com", "http://localhost:5173"]'
config = Configuracoes(_env_file=None)
print(config.expira_em_minutos, type(config.expira_em_minutos).__name__)
print(config.origens_permitidas)
print(config.chave_secreta)
print(len(config.chave_secreta.get_secret_value()))
45 int
['https://app.exemplo.com', 'http://localhost:5173']
**********
44
To get the real value, you ask explicitly (get_secret_value()), and this makes visible, in the code, every place where the secret is used.
Order of priority and the `.env` file
When the same value appears in several places, the winner is, from strongest to weakest: arguments passed to the constructor, environment variables, file .env, default of the model. Therefore, a value defined in the production environment prevails over the .env:
Path("exemplo.env").write_text(
"APP_CHAVE_SECRETA=chave-do-arquivo-com-mais-de-32-bytes-0123456\n"
"APP_EXPIRA_EM_MINUTOS=10\n"
"APP_BANCO_URL=sqlite:///./do-arquivo.db\n",
encoding="utf-8",
)
combinada = Configuracoes(_env_file="exemplo.env")
print(combinada.expira_em_minutos, combinada.banco_url)
print(Configuracoes(_env_file="exemplo.env", expira_em_minutos=5).expira_em_minutos)
45 sqlite:///./do-arquivo.db
5
The expira_em_minutos came from the environment (45), and not from the file (10), because the environment wins. The banco_url, which is not in the environment, came from the file. And the direct argument (5) wins everything.
Deliver to the application as a dependency
Creating the configurations is a task that does not need to be repeated for each request. The lru_cache stores the instance, and a dependency function delivers it to the routes. The advantage appears in the tests: you can swap the dependency for another configuration, without touching environment variables:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
@lru_cache
def obter_configuracoes() -> Configuracoes:
return Configuracoes(_env_file=None)
app = FastAPI()
@app.get("/info")
def info(cfg: Annotated[Configuracoes, Depends(obter_configuracoes)]):
return {"algoritmo": cfg.algoritmo, "expira_em_minutos": cfg.expira_em_minutos}
cliente = TestClient(app)
print(cliente.get("/info").json())
app.dependency_overrides[obter_configuracoes] = lambda: Configuracoes(
_env_file=None, chave_secreta="chave-de-teste-bem-comprida-0123456789", expira_em_minutos=1
)
print(cliente.get("/info").json())
app.dependency_overrides.clear()
{'algoritmo': 'HS256', 'expira_em_minutos': 45}
{'algoritmo': 'HS256', 'expira_em_minutos': 1}
| Where is | What goes there |
|---|---|
| Code | What does not change between environments (field names, rules) |
.env (outside of Git) | The development values |
| Production environment variables | The real secrets, defined in the deployment platform |
.env.example (in Git) | The names of the variables, without secrets |
Exercise 1
A restricted environment to known values
Create ConfigAmbiente (with env_prefix="AMB_") with ambiente: Literal["dev", "prod"] and debug: bool = False. Check that AMB_AMBIENTE=prod and AMB_DEBUG=true work, and that AMB_AMBIENTE=staging is rejected.