Skip to content

    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:

    .env
    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"]
    
    .gitignore
    .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:

    Terminal
    uv add pydantic-settings
    
    intermediario/cap23_configuracao.pylinhas 10 a 31
    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"])
    
    Output
    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:

    intermediario/cap23_configuracao.pylinhas 36 a 44
    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()))
    
    Output
    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:

    intermediario/cap23_configuracao.pylinhas 49 a 57
    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)
    
    Output
    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:

    intermediario/cap23_configuracao.pylinhas 62 a 88
    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()
    
    Output
    {'algoritmo': 'HS256', 'expira_em_minutos': 45}
    {'algoritmo': 'HS256', 'expira_em_minutos': 1}
    
    Where isWhat goes there
    CodeWhat does not change between environments (field names, rules)
    .env (outside of Git)The development values
    Production environment variablesThe 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.