M-Bus Gateway
← Tilbage til blog
· Pydantic· FastAPI· Python· konfiguration· environment· settings· backend· SaaS

Pydantic Settings v2 — miljøkonfiguration i FastAPI

Pydantic Settings v2 til konfigurationsstyring i FastAPI: .env filer, miljøvariabler, nested settings, secrets, validering og test-overrides.

Af M-Bus Gateway

Pydantic Settings v2 er standarden for typesikker konfiguration i FastAPI-applikationer. Her er mønstrene der bruges i produktions-SaaS.


Installation og grundlæggende setup

# pydantic-settings installeres separat fra pydantic v2:
# uv add pydantic-settings

# server/src/config.py

from pydantic import Field, SecretStr, PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore",       # Ignorer ukendte env vars
    )

    # Database
    database_url: PostgresDsn = Field(
        default="postgresql+asyncpg://mbus:secret@localhost:5432/mbus"
    )

    # Redis / Celery
    redis_url: RedisDsn = Field(default="redis://localhost:6379/0")
    celery_broker_url: str = Field(default="redis://localhost:6379/1")

    # JWT
    jwt_secret: SecretStr  # Ingen default — SKAL sættes i .env
    jwt_algorithm: str = "HS256"
    jwt_expire_minutes: int = 60
    jwt_refresh_expire_days: int = 30

    # Stripe
    stripe_secret_key: SecretStr = Field(default=SecretStr("sk_test_placeholder"))
    stripe_webhook_secret: SecretStr = Field(default=SecretStr("whsec_placeholder"))
    stripe_price_id_monthly: str = ""

    # Brevo / email
    brevo_api_key: SecretStr = Field(default=SecretStr(""))
    brevo_sender_email: str = "noreply@mbus-gateway.dk"
    brevo_sender_name: str = "M-Bus Gateway"

    # Hetzner Object Storage
    hetzner_access_key: SecretStr = Field(default=SecretStr(""))
    hetzner_secret_key: SecretStr = Field(default=SecretStr(""))
    hetzner_bucket: str = "mbus-production"
    hetzner_endpoint: str = "https://fsn1.your-objectstorage.com"

    # MQTT
    mqtt_host: str = "localhost"
    mqtt_port: int = 1883
    mqtt_tls_port: int = 8883
    mqtt_username: str = ""
    mqtt_password: SecretStr = Field(default=SecretStr(""))

    # App
    debug: bool = False
    allowed_origins: list[str] = ["http://localhost:5173"]
    log_level: str = "INFO"
    environment: str = "production"

    @property
    def is_production(self) -> bool:
        return self.environment == "production"

    @property
    def database_url_str(self) -> str:
        return str(self.database_url)


@lru_cache
def get_settings() -> Settings:
    """Cached settings — indlæses én gang pr. process."""
    return Settings()

Nested settings med model-prefix

# server/src/config.py (udvidet med nested settings)

class DMISettings(BaseSettings):
    """DMI Klimagrid API konfiguration."""
    model_config = SettingsConfigDict(env_prefix="DMI_")

    api_key: SecretStr = Field(default=SecretStr(""))
    base_url: str = "https://dmigw.govcloud.dk/v2/climateData"
    timeout_seconds: int = 30
    retry_attempts: int = 3


class MQTTSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="MQTT_")

    host: str = "localhost"
    port: int = 1883
    tls_port: int = 8883
    ca_cert_path: str = "/etc/mosquitto/ca.crt"
    client_cert_path: str = "/etc/mosquitto/client.crt"
    client_key_path: str = "/etc/mosquitto/client.key"


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_nested_delimiter="__",  # MQTT__HOST=localhost sætter mqtt.host
    )

    # Nested settings (via env_nested_delimiter)
    dmi: DMISettings = DMISettings()
    mqtt: MQTTSettings = MQTTSettings()

    database_url: PostgresDsn = ...
    jwt_secret: SecretStr = ...

.env filer pr. miljø

# .env (lokalt udviklingsmiljø — GIT-IGNORERET)
DATABASE_URL=postgresql+asyncpg://mbus:devpassword@localhost:5432/mbus_dev
REDIS_URL=redis://localhost:6379/0
JWT_SECRET=dev-secret-never-use-in-production-min-32-chars
STRIPE_SECRET_KEY=sk_test_4eC39HqLyjWDarjtT1zdp7dc
DEBUG=true
ENVIRONMENT=development
LOG_LEVEL=DEBUG

# .env.test (test-miljø — kan commites)
DATABASE_URL=postgresql+asyncpg://mbus:test@localhost:5432/mbus_test
JWT_SECRET=test-jwt-secret-exactly-32-chars!
ENVIRONMENT=test
DEBUG=false

# Produktion: Ingen .env fil — sættes via GitHub Secrets / Docker env

Dependency injection i FastAPI

# server/src/dependencies.py

from fastapi import Depends
from functools import lru_cache
from server.src.config import Settings, get_settings


def get_jwt_secret(settings: Settings = Depends(get_settings)) -> str:
    """Returnerer JWT secret som string (ikke SecretStr)."""
    return settings.jwt_secret.get_secret_value()


def get_stripe_key(settings: Settings = Depends(get_settings)) -> str:
    return settings.stripe_secret_key.get_secret_value()


# I router — settings injiceres automatisk:
@router.post("/auth/login")
async def login(
    credentials: LoginRequest,
    settings: Settings = Depends(get_settings),
):
    token = create_jwt(
        user_id=str(user.id),
        secret=settings.jwt_secret.get_secret_value(),
        algorithm=settings.jwt_algorithm,
        expire_minutes=settings.jwt_expire_minutes,
    )
    return {"access_token": token}

SecretStr — beskyt credentials i logs

# SecretStr skjuler værdien i repr() og str():

from pydantic import SecretStr

s = SecretStr("my-super-secret-key")
print(s)                          # **********
print(repr(s))                    # SecretStr('**********')
print(s.get_secret_value())       # my-super-secret-key (kun her)

# I structlog / logging:
import structlog
log = structlog.get_logger()
settings = get_settings()
log.info("Starting server", database=str(settings.database_url))
# Database URL er OK at logge (ingen password i asyncpg URL ved korrekt opsætning)
# JWT secret: aldrig log settings.jwt_secret — SecretStr beskytter automatisk
log.debug("Config loaded", jwt_algo=settings.jwt_algorithm)  # OK

Test-overrides med pytest

# server/tests/conftest.py

import pytest
from unittest.mock import patch
from server.src.config import Settings, get_settings
from server.src.main import app


@pytest.fixture(autouse=True)
def override_settings():
    """Override settings for alle tests — isolerer fra produktion."""
    test_settings = Settings(
        database_url="postgresql+asyncpg://mbus:test@localhost:5432/mbus_test",
        jwt_secret="test-jwt-secret-min-32-characters!!",
        redis_url="redis://localhost:6379/15",  # Separat test-database
        environment="test",
        debug=True,
        stripe_secret_key="sk_test_dummy",
        stripe_webhook_secret="whsec_dummy",
        brevo_api_key="",
    )
    app.dependency_overrides[get_settings] = lambda: test_settings
    yield test_settings
    app.dependency_overrides.clear()
    get_settings.cache_clear()  # Ryd lru_cache


@pytest.fixture
def settings(override_settings: Settings) -> Settings:
    """Giver adgang til test-settings i tests."""
    return override_settings


# Brug i test:
def test_jwt_secret_is_not_empty(settings: Settings):
    assert len(settings.jwt_secret.get_secret_value()) >= 32

def test_environment_is_test(settings: Settings):
    assert settings.environment == "test"
    assert not settings.is_production

Validering med @field_validator

# server/src/config.py

from pydantic import field_validator, model_validator

class Settings(BaseSettings):
    jwt_secret: SecretStr
    jwt_expire_minutes: int = 60
    allowed_origins: list[str] = []

    @field_validator("jwt_secret")
    @classmethod
    def validate_jwt_secret(cls, v: SecretStr) -> SecretStr:
        secret = v.get_secret_value()
        if len(secret) < 32:
            raise ValueError("JWT_SECRET skal være mindst 32 tegn")
        if secret == "dev-secret" or "changeme" in secret.lower():
            import warnings
            warnings.warn("JWT_SECRET er usikkert — brug et random secret i produktion")
        return v

    @field_validator("allowed_origins", mode="before")
    @classmethod
    def parse_origins(cls, v: str | list) -> list[str]:
        """Tillad ALLOWED_ORIGINS som kommasepareret string i .env."""
        if isinstance(v, str):
            return [origin.strip() for origin in v.split(",") if origin.strip()]
        return v

    @model_validator(mode="after")
    def check_production_requirements(self) -> "Settings":
        if self.is_production:
            if not self.stripe_secret_key.get_secret_value().startswith("sk_live"):
                raise ValueError("Brug kun Stripe LIVE nøgle i produktion")
            if not self.brevo_api_key.get_secret_value():
                raise ValueError("BREVO_API_KEY er påkrævet i produktion")
        return self

Konklusion

Pydantic Settings v2 giver typesikker, valideret konfiguration med automatisk indlæsning fra .env og miljøvariabler. SecretStr beskytter credentials mod utilsigtede log-eksponeringer. @lru_cache sikrer at settings kun indlæses én gang. Test-overrides via dependency_overrides isolerer tests fra produktion.

Se FastAPI dependency injection guide eller FastAPI SQLModel multi-tenant guide.