Skip to content

Configure development and production

This chapter makes the same application select SQLite or PostgreSQL entirely from environment variables, while keeping credentials out of logs and API responses.

Application settings

The settings loader accepts development, test, and production. Development defaults to a local SQLite file. Test and production require an explicit DATABASE_URL, which prevents an accidental production process from silently creating a local file.

"""Validated runtime configuration for the Todo example."""

from __future__ import annotations

import os
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Literal, cast
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

Environment = Literal["development", "test", "production"]
_ENVIRONMENTS = {"development", "test", "production"}
_DEVELOPMENT_DATABASE_URL = "sqlite:///todo-dev.sqlite3"
_SENSITIVE_QUERY_KEYS = {
    "access_token",
    "api_key",
    "passwd",
    "password",
    "secret",
    "token",
}


class ConfigurationError(ValueError):
    """Raised when required application configuration is invalid or absent."""


@dataclass(frozen=True)
class Settings:
    """Validated, immutable application settings."""

    environment: Environment
    database_url: str = field(repr=False)

    @property
    def safe_database_url(self) -> str:
        """Return a diagnostic URL with credentials and query secrets removed."""
        parsed = urlsplit(self.database_url)
        netloc = parsed.netloc
        authority_redacted = False
        if "@" in netloc:
            _credentials, host = netloc.rsplit("@", 1)
            netloc = f"***@{host}"
            authority_redacted = True

        query_pairs = parse_qsl(parsed.query, keep_blank_values=True)
        query_redacted = any(
            key.casefold() in _SENSITIVE_QUERY_KEYS for key, _value in query_pairs
        )
        if not authority_redacted and not query_redacted:
            return self.database_url

        query = parsed.query
        if query_redacted:
            query = urlencode(
                [
                    (
                        key,
                        "***" if key.casefold() in _SENSITIVE_QUERY_KEYS else value,
                    )
                    for key, value in query_pairs
                ],
                safe="*",
            )
        safe_url = urlunsplit(parsed._replace(netloc=netloc, query=query))
        scheme_prefix = f"{parsed.scheme}://"
        if (
            not parsed.netloc
            and self.database_url.startswith(scheme_prefix)
            and not safe_url.startswith(scheme_prefix)
        ):
            safe_url = scheme_prefix + safe_url[len(parsed.scheme) + 1 :]
        return safe_url


def load_settings(environ: Mapping[str, str] | None = None) -> Settings:
    """Load and validate settings from a supplied mapping or the process environment."""
    source = os.environ if environ is None else environ
    raw_environment = source.get("APP_ENV", "development")
    normalized_environment = raw_environment.strip().casefold()
    if normalized_environment not in _ENVIRONMENTS:
        raise ConfigurationError(
            f"APP_ENV must be development, test, or production; got {raw_environment!r}"
        )

    database_url = source.get("DATABASE_URL", "").strip()
    if not database_url:
        if normalized_environment == "development":
            database_url = _DEVELOPMENT_DATABASE_URL
        else:
            raise ConfigurationError(
                f"DATABASE_URL is required when APP_ENV={normalized_environment}"
            )

    return Settings(
        environment=cast(Environment, normalized_environment),
        database_url=database_url,
    )

Settings.safe_database_url redacts authority credentials and common sensitive query parameters. Use it for diagnostics; never print database_url directly.

Register one database target

"""Shared Ormdantic database target for the Todo example."""

from importlib import import_module

from ormdantic import Ormdantic

from .config import load_settings

settings = load_settings()
db = Ormdantic(settings.database_url)

# The CLI/Playground imports this module directly, so registration must happen here.
import_module(".models", package=__package__)

Importing models here is intentional. Both the migration CLI and Playground load app.database:db; registration must finish before either tool takes a snapshot.

Playground configuration

[project]
target = "app.database:db"
migrations_dir = "migrations/sqlite"
watch = ["app/**/*.py", "migrations/**/*.toml"]

[environments.development]
url_env = "DATABASE_URL"
env_file = ".env"
safety = "typed"

[environments.production]
url_env = "DATABASE_URL"
safety = "typed"

The development profile requires typed confirmation for writes. The environment named production also receives the Playground's non-weakenable production safety policy. The checked-in migration directory is SQLite; select migrations/postgresql explicitly when applying production artifacts because migration directories are project-wide in the current config format.

Typical production settings are:

APP_ENV=production
DATABASE_URL=postgresql://todo:replace-me@database:5432/todo

Keep the real URL in your secret manager. The .env.example file documents names only; .env is ignored by Docker builds and should not be committed.

See Playground configuration for every supported key and driver URLs for backend-specific parameters.