Chapter 58, Backend
Professional configuration
Configuration is everything that changes between your computer, CI, and production, without changing the code. Mistakes here are the most common cause of leaked secrets and "it works on my machine".
Code for this chapter: backend/cap58_configuracao.py
The rule: configuration comes from the environment
The principle (of twelve-factor apps) is simple: the same code runs anywhere, and what changes is read from environment variables. No if ambiente == "prod" scattered throughout the code, and no passwords in versioned files.
| Environment | Where the values come from | Note |
|---|---|---|
| Development | A file .env local | Outside of Git (.gitignore) |
| Tests and CI | Variables defined in the job | Disposable values |
| Production | Secret manager or orchestrator variables | Never in image or repository |
Typed configuration with pydantic-settings
Read os.environ["PORTA"] returns text, and forgetting to convert is a bug waiting to happen. The pydantic-settings reads the variables, converts to the declared type and validates, all at startup. If something is wrong, the program doesn't even start, which is exactly what you want:
uv add pydantic-settings
import os
from pathlib import Path
from typing import Literal
from pydantic import SecretStr, ValidationError, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Configuracao(BaseSettings):
model_config = SettingsConfigDict(env_prefix="LOJA_", extra="ignore")
ambiente: Literal["dev", "test", "prod"] = "dev"
porta: int = 8000
chave_api: SecretStr
The field chave_api has no default value, so it is mandatory. Without the variable LOJA_CHAVE_API, the configuration refuses to start and says exactly what is missing:
try:
Configuracao(_env_file=None)
except ValidationError as erro:
print([(e["loc"], e["type"]) for e in erro.errors()])
[(('chave_api',), 'missing')]
By defining the variables, the conversion happens automatically. The port came in as text and became int. And the SecretStr hides the value when printing, which protects the key from leaking in logs and error messages:
os.environ["LOJA_CHAVE_API"] = "segredo-123"
os.environ["LOJA_PORTA"] = "9000"
config = Configuracao(_env_file=None)
print(config)
print(config.porta + 1, type(config.porta).__name__)
print(config.chave_api.get_secret_value()[:7])
ambiente='dev' porta=9000 chave_api=SecretStr('**********')
9001 int
segredo
An impossible value to convert is rejected with a clear error, instead of exploding later with a ValueError without context:
os.environ["LOJA_PORTA"] = "abc"
try:
Configuracao(_env_file=None)
except ValidationError as erro:
print(erro.errors()[0]["type"])
os.environ["LOJA_PORTA"] = "9000"
int_parsing
Who wins: the order of precedence
Values can come from various places, and the order decides. From highest to lowest priority: arguments passed to the constructor, then environment variables, then the file .env, then the code defaults. This allows production to overwrite the local file without editing anything:
Path(".env.demo").write_text("LOJA_PORTA=7000\nLOJA_AMBIENTE=test\n", encoding="utf-8")
print(Configuracao(_env_file=".env.demo").porta)
del os.environ["LOJA_PORTA"]
print(Configuracao(_env_file=".env.demo").porta)
print(Configuracao(_env_file=".env.demo", porta=1234).porta)
9000
7000
1234
In the first line, the environment variable (9000) won over the file (7000). After removing it, the file took precedence. And the explicit argument won over both.
Business rules over configuration
Validations that cross fields go into a model_validator. The classic example: production requires a strong key, and the program refuses to start with a weak one:
class ConfiguracaoSegura(Configuracao):
@model_validator(mode="after")
def producao_exige_chave_forte(self):
if self.ambiente == "prod" and len(self.chave_api.get_secret_value()) < 16:
raise ValueError("em produção a chave precisa ter pelo menos 16 caracteres")
return self
os.environ["LOJA_AMBIENTE"] = "prod"
try:
ConfiguracaoSegura(_env_file=None)
except ValidationError as erro:
print(erro.errors()[0]["msg"])
del os.environ["LOJA_AMBIENTE"]
Value error, em produção a chave precisa ter pelo menos 16 caracteres
The final project (chapter 62) uses the same idea: API_AMBIENTE=prod with an SQLite database is rejected at startup.
Secrets
| Practice | Why |
|---|---|
.env in the .gitignore, and a .env.example versioned without real values | Documents which variables exist, without leaking |
SecretStr for passwords and keys | Do not appear in print, in repr or in logs |
| Secrets injected at runtime, never copied to the Docker image | An image is distributed, and the layers store what was copied |
One file per secret (secrets_dir="/run/secrets"), in Docker and Kubernetes | Do not become visible in the process variable list |
| Rotation possible without redeploy | A leaked key needs to be able to be changed quickly |
A secret that entered Git is leaked
Deleting the file in the next commit does not solve: the history keeps everything. The correct response to a committed secret is to revoke it and generate another. Cleaning the history is secondary.
Exercise 1
A boolean debug field
Add debug: bool = False to a subclass of Configuracao. Show that LOJA_DEBUG=1 becomes True and that LOJA_DEBUG=talvez is rejected.