Skip to content

Architecture Overview

Patrimoine runs as a three-container Podman pod (or Docker Compose stack): backend (FastAPI), frontend (nginx serving the SPA and proxying /api/v1/ and /auth/) and docs (nginx serving this documentation site). Data is persisted in a SQLite database file on a bind-mounted volume.

Component Technology
Backend FastAPI (Python 3.13) + uvicorn + SQLAlchemy 2.0 async
Frontend React 19 + Vite 8 + Tailwind CSS v4 + @dnd-kit + react-intl
Android Kotlin 2.4 + Jetpack Compose + Hilt + Room + Ktor + AppAuth
Command-line client Python 3.13 + argparse + httpx, in cli/
Database SQLite (WAL mode) or PostgreSQL (asyncpg, optional)
Queue arq + Redis (optional, asyncio fallback)
Auth OIDC (e.g. Authentik) or local email/password, mobile PKCE
Container Podman (rootless) + podman compose
CI/CD Forgejo Actions (lint, tests, audit, build images)
Frontend server nginx (SPA + reverse proxy)
Docs site Astro + Starlight (static, served by nginx)
flowchart TD accTitle: Deployment architecture accDescr: The browser reaches Traefik over HTTPS, which forwards to the frontend container running nginx. The frontend serves the single-page app and proxies API and auth requests to the FastAPI backend, which stores its SQLite database on a mounted data volume. Browser["Browser"] -- HTTPS --> Traefik["Traefik (TLS termination)"] subgraph Pod["Podman Pod / Compose"] Traefik --> Frontend subgraph Frontend["frontend (docker.io/library/nginx:1.31-alpine)<br/>port 8080 (public)"] SPA["/usr/share/nginx/html/ (SPA)"] ProxyAPI["/api/v1/ -- proxy"] ProxyAuth["/auth/ -- proxy"] end ProxyAPI --> Backend ProxyAuth --> Backend subgraph Backend["backend (docker.io/library/python:3.13-alpine)<br/>port 8000 (internal)<br/>FastAPI + uvicorn"] DBPath["/data/patrimoine.db"] end DBPath --> Volume subgraph Volume["Volume: ./data"] SQLite["patrimoine.db (SQLite WAL)"] end end
Service Base Image Stack Port Role
backend docker.io/library/python:3.13-alpine FastAPI + uvicorn 8000 (internal) REST API, auth, background tasks, SQLite access
frontend docker.io/library/node:24-alpine → docker.io/library/nginx:1.31-alpine React 19 SPA + nginx reverse proxy 8080 (public) Serves static files; proxies /api/v1/ and /auth/ to backend
docs docker.io/library/node:24-alpine → docker.io/library/nginx:1.31-alpine Astro + Starlight static site 8080 in the container, SITE_PORT (default 8082) on the host Serves the landing page, the three manuals and the API reference
Client Stack Role
Android app Kotlin 2.4 + Jetpack Compose + Hilt + Room + Ktor + AppAuth Native mobile client; authenticates via /auth/login/token (local) or OAuth2 PKCE + /auth/mobile/exchange (OIDC); uses ptr_ API tokens
Command-line client Python 3.13 + argparse + httpx Terminal client for scripts and cron jobs; talks to /api/v1/ with a ptr_ API token; lists and adds snapshots and transactions and exports data
Service Condition Role
worker REDIS_URL set in backend/container/.env arq background worker for the scheduled jobs (daily backup, recurring snapshots, planned-transaction sweep, FX and price refresh, audit-log and webhook-delivery cleanup, delta cache, scheduled reports, alert check); session and token purges and the webhook retry loop stay in the API
redis REDIS_URL points at the pod loopback with ./podman.sh, or an external Redis Queue for the arq worker; also read by /metrics for the worker’s job counts
postgres docker-compose.postgres.yml, or ./podman.sh when DATABASE_URL is a loopback postgresql+asyncpg:// URL PostgreSQL alternative to SQLite (asyncpg driver)
  1. Browser → nginx (port 8080) — The browser sends all requests to the frontend container on port 8080. TLS is terminated by an external reverse proxy (Traefik/Caddy) in production.
  2. Static files — nginx serves SPA assets from /usr/share/nginx/html/ via try_files $uri $uri/ /index.html (SPA fallback).
  3. API proxy — Requests matching /api/v1/ or /auth/ are proxied to BACKEND_URL (http://backend:8000 under Compose, http://127.0.0.1:<BACKEND_PORT> in the podman.sh pod) with X-Real-IP, X-Forwarded-For and X-Forwarded-Proto headers. Rate limiting is per zone: api defaults to 10 r/s (burst 20) and auth to 5 r/s (burst 10), both tunable with FRONTEND_API_RATE_LIMIT / FRONTEND_AUTH_RATE_LIMIT. /health is proxied unlogged and unlimited.
  4. Backend → SQLite — The backend reads and writes to /data/patrimoine.db using async SQLAlchemy (aiosqlite driver). WAL mode and foreign key enforcement are set per connection via an event listener.
  5. Volume persistence — The ./data host directory is bind-mounted to /data in the backend container, persisting the SQLite database across container restarts.
Service Check Interval Timeout Retries Start Period
backend GET /health (Python urllib.request.urlopen) 30s 5s 3 15s
frontend wget --spider http://localhost:8080/ 30s 3s 3 5s

The backend /health endpoint is a dependency-free liveness check (status only, nothing else is exposed publicly) — DB connectivity is verified separately by GET /health/db. The frontend healthcheck confirms nginx is serving.

GET /metrics serves Prometheus metrics. It answers 404 until METRICS_TOKEN (at least 32 characters) is set, requires Authorization: Bearer <token>, and is not proxied by nginx: scrape it on the container network.

All three containers apply the following security measures via docker-compose.yml:

Measure Configuration
Non-root user Backend: desordre (UID 10001, GID 10001); Frontend: nginx user
Read-only filesystem read_only: true on every service
Capability drop cap_drop: [ALL] — all Linux capabilities removed
No new privileges security_opt: [no-new-privileges:true]
tmpfs mounts /tmp on every container; frontend and docs also mount /var/cache/nginx, /var/run and /etc/nginx/conf.d (the entrypoint writes the rendered nginx config there)
Memory limit Backend: 512m; Frontend: 256m; Docs: 128m
CPU limit Backend: 1.0; Frontend: 0.5; Docs: 0.25
Request body size nginx client_max_body_size 10m

nginx adds the following headers to every response:

X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; script-src 'self'; ...
Strict-Transport-Security: max-age=31536000; includeSubDomains