Skip to content

Environment Variables

Patrimoine is configured through environment variables, split into one env file per container. Each container has a commented template next to its Containerfile; copy the ones you need and adjust the values:

Template Copy to Container Needed
backend/container/env.example backend/container/.env backend (API) Required
frontend/container/env.example frontend/container/.env frontend (nginx + web app) Only to change FRONTEND_HOST_PORT, BACKEND_URL, FRONTEND_BACKEND_RESOLVER, FRONTEND_CONTAINER_PORT, FRONTEND_TRUSTED_PROXIES or the request limits
web/container/env.example web/container/.env web (landing page + manuals) Only to change SITE_PORT
data/container/env.example data/container/.env PostgreSQL container started by podman.sh Only to change POSTGRES_IMAGE
Terminal window
cp backend/container/env.example backend/container/.env
cp frontend/container/env.example frontend/container/.env # optional
cp web/container/env.example web/container/.env # optional

podman.sh reads these files automatically. For docker compose / podman compose, the backend service reads backend/container/.env itself, while the host-side substitutions (FRONTEND_HOST_PORT, SITE_PORT, BACKEND_PORT) come from --env-file flags, one per file you created (Compose 2.24 or later; when a variable appears in several files, the last --env-file wins):

Terminal window
docker compose --env-file backend/container/.env \
--env-file frontend/container/.env --env-file web/container/.env up --build

Leave out the flag for any template you did not copy: FRONTEND_HOST_PORT then defaults to 8080 and SITE_PORT to 8082. See Podman Compose Deployment for details.

BACKEND_PORT is set once, in backend/container/.env: the backend listens on it, and compose and podman.sh build the frontend’s default BACKEND_URL from it.


Variables marked “Yes” under Required have a built-in default that is wrong for a real deployment, or none, and must be set for the app (or that optional subsystem — OIDC, SMTP) to work correctly. Variables marked “No” already work with the Default value shown if left unset. The File column names the env file each variable belongs to (see the table above): backend, frontend, web, data or compose override (set by docker-compose.e2e.yml only).

Variable File Description Default Required
FRONTEND_HOST_PORT frontend Host port for the frontend (nginx). Read on the host by podman.sh and docker-compose.yml, not by the container. 8080 No
BACKEND_PORT backend Host-internal port for the backend (FastAPI/uvicorn). Rarely needs changing — only relevant if 8000 conflicts with another host service or you’re running multiple instances. Set it in backend/container/.env; compose and podman.sh build the frontend’s default BACKEND_URL from it. With compose, pass --env-file backend/container/.env so the frontend’s default BACKEND_URL follows it; if you set BACKEND_URL yourself, keep its port equal to BACKEND_PORT. 8000 No
BACKEND_URL frontend Where the frontend’s nginx proxies /api/ and /auth/ to, as an origin only: http(s)://host[:port], with no path. Set it to host the backend apart from the frontend, on another machine or network. An https:// URL is reached with SNI and its certificate is verified against the image’s CA bundle. The backend must accept the frontend’s public Host header and treat the frontend as its trusted proxy. With compose, the frontend service still waits for the local backend service; to run only the frontend against a remote backend, override its depends_on. Empty means the default: http://backend:BACKEND_PORT (compose) or the pod’s own backend (podman.sh). (empty) No
FRONTEND_BACKEND_RESOLVER frontend DNS server nginx uses to resolve the backend’s name, as an IP address, once nginx is running and again each time the record expires. By default it is the nameserver the container itself was given, falling back to Docker’s 127.0.0.11. Set it only when BACKEND_URL names a remote host that resolver does not resolve, for example under podman.sh. (container nameserver) No
FRONTEND_CONTAINER_PORT frontend Port nginx listens on inside the frontend container, the one FRONTEND_HOST_PORT is published to. Change it only if 8080 conflicts with something sharing the container’s network (for example host networking). Must be 1024 or higher, because the container runs unprivileged. 8080 No
FRONTEND_API_RATE_LIMIT frontend Per-IP request rate nginx allows on /api/, in nginx limit_req_zone syntax (10r/s is ten requests per second). A request over the rate plus a burst of twice the rate gets HTTP 503. Raise it only when many clients legitimately share one address, for example automated browser tests; the backend applies its own, separate limit as well. 10r/s No
FRONTEND_AUTH_RATE_LIMIT frontend Same as FRONTEND_API_RATE_LIMIT, for /auth/. 5r/s No
FRONTEND_TRUSTED_PROXIES frontend Comma-separated IP addresses or CIDRs of the reverse proxies in front of nginx, for example Traefik or Caddy as the frontend sees them. When set, nginx takes the client address from X-Forwarded-For, and only for connections that come from these addresses, so a visitor cannot forge it. The per-IP request limits of nginx, the backend’s rate limiter and its audit log then see the real client instead of the proxy. Leave it empty when nothing sits in front of nginx. With docker compose / podman compose the shipped docker-compose.yml does not forward this variable to the container: add FRONTEND_TRUSTED_PROXIES: ${FRONTEND_TRUSTED_PROXIES:-} under the frontend service’s environment (or set it there directly). podman.sh reads it from frontend/container/.env automatically. (empty) No
SITE_PORT web Host port for the web container’s nginx — the single Starlight site serving the landing page at /{locale}/ and the manuals under /{locale}/docs/. Only needs setting if 8082 is taken. The reverse proxy’s site block (Caddyfile.example’s {$SITE_PORT}) must forward to this same port; that block’s hostname (Caddyfile.example’s SITE_DOMAIN, set in the reverse proxy’s own environment, like DOMAIN) is the site’s public host, i.e. the host of SITE_URL. 8082 No
DOCS_URL backend Public URL of the documentation site, for example https://patrimoine.app. Published to the web and Android apps as docs_url on GET /auth/config so they can link to it; empty means no docs site is deployed and the apps show no link to it. Must be an absolute http:// or https:// URL with a host: any other value is rejected when the settings load, so the backend does not start. Surrounding spaces are trimmed; the value is otherwise used as given. The web container no longer reads this variable — it serves one vhost, and the manuals live on that same site — so set it to the same URL as SITE_URL. (empty) No
DATABASE_URL backend Database connection URL. SQLite (default): four slashes (////) indicate an absolute path inside the container; three slashes (///) indicate a relative path (local dev outside a container). Set to a postgresql+asyncpg:// URL to use PostgreSQL instead — see PostgreSQL Deployment. sqlite+aiosqlite:///data/patrimoine.db (app default, relative — override to the four-slash absolute form for container deployments) Yes
DB_POOL_SIZE backend PostgreSQL connection pool size. Ignored for SQLite. Must be at least 1. 5 No
DB_MAX_OVERFLOW backend Maximum PostgreSQL connections beyond DB_POOL_SIZE allowed under load. Ignored for SQLite. Must be 0 or greater. 10 No
DEBUG backend Debug mode. Set to false in production. Shows error traces, also serves the API docs (see EXPOSE_API_DOCS) and silences the startup warning about a localhost FRONTEND_URL. The cookie Secure flag is protocol-aware (HTTPS via X-Forwarded-Proto), not DEBUG-based. false No
EXPOSE_API_DOCS backend Serve the interactive API docs (/docs, /redoc) and the schema (/openapi.json) from the backend. Off by default so a production deployment does not publish its full API surface; DEBUG=true also turns them on. The reverse proxy does not forward these paths either way. false No
MAX_REQUEST_BODY_BYTES backend Largest request body, in bytes, the backend accepts on any route other than imports. A bigger body is refused with HTTP 413 before it is read. Must be at least 1. 2097152 (2 MiB) No
MAX_IMPORT_BODY_BYTES backend Same limit for the import and backup-restore routes, which carry file uploads (each file is also capped at 10 MB). Keep it a little above 10 MB to leave room for the upload’s framing. Must be at least 1. 12582912 (12 MiB) No
APP_NAME backend Display name used in the browser tab title, the TOTP issuer name (shown in authenticator apps), and the /system endpoint. Patrimoine No
JWT_SECRET backend HMAC secret for signing local JWTs. Generate with python -c "import secrets; print(secrets.token_urlsafe(48))". A value shorter than 32 characters is rejected by every process that loads settings (API, arq worker, alembic migrations), not only at API startup. Rotating it ends all local sessions and strands TOTP users; see the rotation runbook. (empty) Yes (local auth)
JWT_ALGORITHM backend HMAC signing algorithm for local-auth JWTs: HS256, HS384 or HS512. Any other value stops the API from starting. HS256 No
JWT_ACCEPT_LEGACY_KEY backend Keep accepting local-auth tokens signed with the raw JWT_SECRET, issued before the signing key was derived from it. Set to false once those sessions have expired (after REFRESH_TOKEN_EXPIRE_DAYS, at most MAX_SESSION_DAYS). See the rotation runbook. true No
ACCESS_TOKEN_EXPIRE_MINUTES backend Local-auth access token lifetime, in minutes. 30 No
REFRESH_TOKEN_EXPIRE_DAYS backend Local-auth refresh token lifetime, in days. 7 No
MAX_SESSION_DAYS backend Absolute local-auth session lifetime, in days. Refresh-token rotation never extends a session beyond this many days after the original sign-in; the user must then sign in again. 30 No
LOGIN_MAX_ATTEMPTS backend Failed local-auth login attempts (wrong password or wrong TOTP code) from one client IP against one account before that IP is locked out of the account. The lock doubles with each further failure, up to LOGIN_LOCKOUT_MAX_MINUTES. Counted per account and IP, so someone who only knows an email address cannot lock the owner out. Supplements the IP-based /auth/* rate limit. The per-IP counters are kept in memory and reset when the backend restarts. 5 No
LOGIN_LOCKOUT_MINUTES backend How long a locked IP, or a locked account, stays locked on the first lock; doubles with each further failure. 15 No
LOGIN_LOCKOUT_MAX_MINUTES backend Upper bound, in minutes, for the doubling lockout duration. 240 No
LOGIN_ACCOUNT_MAX_ATTEMPTS backend Failed attempts across all IPs before the whole account is locked for everyone (guessing spread over many IPs). Raised to LOGIN_MAX_ATTEMPTS if set lower. Stored on the account, so it survives restarts. 25 No
REGISTRATION_ENABLED backend Enable user registration. Set to true only during initial setup, then false. false No
MAX_TOKENS_PER_USER backend Maximum number of personal-access API tokens a user may hold at once. Mobile login mints one on every sign-in; hitting the cap auto-revokes the oldest token so login keeps working rather than failing. 10 No
ADMIN_EMAILS backend Comma-separated emails promoted to admin (matched case-insensitively) when the backend starts and on each authenticated request. OIDC accounts only: a local-auth account is never matched, because a self-registered email is not proof of ownership. On a local-auth instance the first account registered becomes admin, and an admin can promote others from the Administration → Users page — see Troubleshooting. One-way only — removing an entry does not demote an existing admin. Must be a comma-separated list of syntactically valid emails; the API refuses to start otherwise, naming the offending entry. An account whose provider reports its email as unverified (email_verified: false) is not matched by email; use ADMIN_SUBS if your provider cannot guarantee verified emails. (empty) No
ADMIN_SUBS backend Comma-separated OIDC sub claims promoted to admin, matched exactly. Same rules as ADMIN_EMAILS: OIDC accounts only, checked at startup and on each authenticated request, one-way. Local accounts are never matched, so a local:<email> value has no effect. (empty) No
TRUSTED_PROXY backend Trust X-Real-IP and X-Forwarded-Proto headers from the reverse proxy. X-Real-IP is used for rate limiting; X-Forwarded-Proto for HTTPS detection (cookie Secure/SameSite attributes). Set to false if the backend is directly exposed without nginx. When false, the backend also starts setting X-Content-Type-Options, X-Frame-Options and Referrer-Policy itself on every response, since nginx is no longer there to do it (see Reverse Proxy: Security Headers). true No
TRUSTED_PROXIES backend Comma-separated IP addresses or CIDRs of the reverse proxies allowed to set X-Real-IP and X-Forwarded-Proto. When set, those headers are honoured only for connections that come from these addresses; anyone else reaching the backend is treated as a direct client, so they cannot forge their IP for the rate limiter or the audit log. Leave it empty while only the frontend’s nginx can reach the backend (the default, which trusts peers on private addresses when TRUSTED_PROXY is on: loopback, RFC 1918, link-local, 100.64.0.0/10 and IPv6 ULA); set it when the frontend and backend run on different hosts or the backend port is otherwise reachable. In a pod where nginx shares the backend’s loopback, leave it empty. Also set it if your proxy reaches the backend from a public address. (empty) No
OIDC_ENABLED backend Explicit switch for OIDC. Both this and OIDC_CLIENT_ID must be set for OIDC to be enabled — previously OIDC was implicitly on whenever OIDC_CLIENT_ID was non-empty, which made it easy to accidentally go live while staging credentials, or hard to read “is OIDC on” at a glance. false No
OIDC_ISSUER backend OIDC discovery endpoint. Used exactly as given — it is compared byte-for-byte against the token’s iss claim, so it must match your provider’s issuer URL precisely (including whether it ends with a trailing slash). Must be an absolute http(s) URL with a host; the API refuses to start otherwise. The backend trims a trailing slash only when building the discovery and JWKS URLs, not when comparing iss. The default is a placeholder — inert unless OIDC_ENABLED=true and OIDC_CLIENT_ID are also set; replace it with your real provider’s issuer URL. http://localhost:9000/application/o/patrimoine Yes (OIDC)
OIDC_CLIENT_ID backend OIDC client ID from your identity provider. Required alongside OIDC_ENABLED=true to turn OIDC on. (empty) No
OIDC_CLIENT_SECRET backend OIDC client secret from your identity provider. (empty) Yes (OIDC)
OIDC_REDIRECT_URI backend Redirect URI after OIDC authentication. Must match exactly what is registered in your OIDC provider (including scheme). Must be an absolute http(s) URL with a host; the API refuses to start otherwise. Defaults to FRONTEND_URL plus /auth/callback, so set it only to override that. FRONTEND_URL + /auth/callback Yes (OIDC)
MOBILE_OIDC_CLIENT_ID backend OIDC client ID for the Android app’s own PKCE flow. Falls back to OIDC_CLIENT_ID when empty. That client must have patrimoine://callback registered as an allowed redirect URI, and must be configured as a public/PKCE client (no client secret) — the Android app never sends one, so a client configured to require authentication (confidential client) fails with “Client authentication failed” at the token exchange. If OIDC_CLIENT_ID’s client is confidential (the web backend holds its secret), register a separate public client for mobile rather than relying on the fallback. (empty) No
MOBILE_OIDC_ISSUER backend OIDC issuer for the mobile client’s own provider. Falls back to OIDC_ISSUER when empty. Only needed when the identity provider mints a distinct issuer per provider/application (e.g. Authentik’s default “Each provider has a different issuer” mode) and the mobile client is a separate provider from OIDC_CLIENT_ID’s — in that case OIDC_ISSUER alone won’t resolve to the mobile client’s issuer, and both the discovery URL the app fetches (/auth/config → mobile_oidc_issuer) and the backend’s own JWKS/iss validation in POST /auth/mobile/exchange need this set to the mobile provider’s actual issuer URL. (empty) No
FRONTEND_URL backend Must match the real public URL visitors use to reach the app. Two uses: (1) the sole allowed CORS origin — a mismatch blocks every frontend request; (2) the base URL for every absolute link the backend builds (password reset, email change confirmation, admin invites, scheduled reports/bill reminders, post-OIDC-login redirect) — a mismatch sends users to a broken link. Set to your real deployment URL (e.g. https://patrimoine.exemple.fr) once behind a reverse proxy or on a different port/domain than the default. Must be an absolute http(s) URL with a host; the API refuses to start otherwise. Used exactly as given — no trailing slash is added or removed. Outside debug mode the backend logs a warning at startup while it is still the localhost default. http://localhost:8080 Yes (production)
SITE_URL backend Public URL of the marketing/website (for example https://patrimoine.app), distinct from FRONTEND_URL above (this app’s own web client). Published to the web and Android apps as site_url on GET /auth/config, mirroring DOCS_URL/docs_url, so they can link to it; empty means no separate marketing site exists and the apps show no link to it. Must be an absolute http:// or https:// URL with a host: any other value is rejected when the settings load, so the backend does not start. It is also the server_name of the web container’s single nginx vhost (landing page plus manuals) — docker-compose.yml and podman.sh fall back to a localhost placeholder when it is unset, which still serves because that single vhost is nginx’s default server. (empty) No
HELP_URL backend Public URL of a help/support destination (for example a support-desk or FAQ URL). Published to the web and Android apps as help_url on GET /auth/config so they can link to it; empty means no help destination is configured and the apps show no link to it. Must be an absolute http:// or https:// URL with a host: any other value is rejected when the settings load, so the backend does not start. (empty) No
E2E_TEST_MODE compose override Auth bypass for Playwright tests, set by docker-compose.e2e.yml. Requires DEBUG=true: otherwise the backend refuses to start. Never set to true in production. false No
E2E_BYPASS_TOKEN compose override Bypass token for E2E tests. If E2E_TEST_MODE=true and this is empty, a random token is generated at startup. (empty) No
REDIS_URL backend Redis URL for the arq task queue. If empty, the backend uses an asyncio fallback for background tasks. (empty) No
METRICS_TOKEN backend Bearer token that turns on GET /metrics, the Prometheus metrics endpoint. Empty disables the endpoint, which then answers 404; a missing or wrong token gets 401. A non-empty value shorter than 32 characters is rejected when the settings load. Generate one with python -c "import secrets; print(secrets.token_urlsafe(48))". Prometheus scrapes the backend directly on the container network: the frontend does not proxy /metrics. Keep it secret: it is never logged. (empty) No
AUDIT_LOG_RETENTION_DAYS backend Number of days to retain audit logs. Entries older than this are deleted by the cleanup job. 90 No
WEBHOOK_DELIVERY_RETENTION_DAYS backend Number of days to keep webhook delivery history. Deliveries older than this are deleted by the daily cleanup job. Must be at least 1. 30 No
FRANKFURTER_API_URL backend Base URL of the Frankfurter-compatible exchange-rate API used for automatic ECB rate fetches. Override to route through a proxy or self-hosted mirror. Must be an absolute http(s) URL. https://api.frankfurter.dev/v1 No
YAHOO_SEARCH_URL backend Yahoo Finance ticker-search endpoint used by the holding form’s symbol lookup. Override to route through a proxy or mirror. Must be an absolute http(s) URL. https://query2.finance.yahoo.com/v1/finance/search No
YAHOO_CHART_URL backend Yahoo Finance chart endpoint used for quotes and historical prices. Must contain the {symbol} placeholder exactly once, and no other placeholder. https://query1.finance.yahoo.com/v8/finance/chart/{symbol} No
AUTO_BACKUP backend Enable automatic SQLite backup on startup + every 24h (SQLite only). Opt-in — set to true to enable; set back to false if it causes database lock contention. false No
SMTP_HOST backend SMTP server hostname. Empty = email feature disabled — GET /auth/config’s email_enabled reflects this, and the frontend hides “Forgot password?” until it’s set. (empty) No
SMTP_PORT backend SMTP server port. Must be between 1 and 65535; the API refuses to start otherwise. 587 No
SMTP_USER backend SMTP username for authentication. (empty) No
SMTP_PASSWORD backend SMTP password. (empty) No
SMTP_FROM backend “From” address for outgoing emails (password reset, email change confirmation, admin invites, bill reminders, scheduled reports, notifications). (empty) No
POSTGRES_IMAGE data Container image podman.sh uses for the PostgreSQL container it starts when DATABASE_URL points at a loopback PostgreSQL host. The database name, user and password are parsed from DATABASE_URL, not set here. docker.io/library/postgres:18-alpine No

E2E_TEST_MODE and E2E_BYPASS_TOKEN are test-only. They are deliberately absent from every env template and are set by docker-compose.e2e.yml; never put them in a production env file.

WEBDAV_URL, WEBDAV_USER, WEBDAV_PASSWORD and S3_BUCKET are not read by any container and do not belong in any of the env files above. They are read only by the host script backup-db.sh, from the environment of the shell or cron job that runs it. DATABASE_PATH is read the same way. See Backup and Restore for how to set them.

Variable Description Default Required
WEBDAV_URL WebDAV URL for offsite backup sync. All three WebDAV variables are required together. (empty) No
WEBDAV_USER WebDAV username for authentication. (empty) No
WEBDAV_PASSWORD WebDAV password. Passed via a temp .netrc file (not visible in ps). (empty) No
S3_BUCKET S3 bucket name for offsite backup sync. Requires the aws CLI installed on the host. (empty) No
DATABASE_PATH Path of the SQLite file backup-db.sh backs up. ./data/patrimoine.db No

./backup-db.sh [backup_dir] takes the backup directory as its first argument (default ./data/backups).

podman.sh also reads these from its environment or the per-container files: POD_NAME (pod name prefix, default patrimoine; the website pod is <POD_NAME>-docs), DATA_DIR (host data directory mounted at /data, default ./data), REDIS_IMAGE (image for the managed Redis, default docker.io/library/redis:7-alpine, used only when REDIS_URL is a loopback redis:// URL) and WORKER_SETTLE_SECONDS (seconds between the three liveness checks of the managed arq worker, default 1).


For local development with uv run uvicorn and npm run dev, copy backend/container/env.example to backend/.env (the backend reads .env from its working directory) and override these variables:

Terminal window
DEBUG=true
FRONTEND_URL=http://localhost:5173
OIDC_REDIRECT_URI=http://localhost:8000/auth/callback
DATABASE_URL=sqlite+aiosqlite:///data/patrimoine.db
Variable Dev value Production value Why different
DEBUG true false Show error traces in dev
FRONTEND_URL http://localhost:5173 http://localhost:8080 Vite dev server runs on 5173
OIDC_REDIRECT_URI http://localhost:8000/auth/callback http://localhost:8080/auth/callback Backend handles OIDC callback directly in dev (no nginx)
DATABASE_URL sqlite+aiosqlite:///data/patrimoine.db sqlite+aiosqlite:////data/patrimoine.db Three slashes = relative path on host; four slashes = absolute path in container

Start the backend and frontend separately:

Terminal window
# Backend
cd backend
uv sync && uv run uvicorn app.main:app --reload --port 8000
# Frontend
cd frontend
npm run dev

The Vite dev server (port 5173) proxies /api/ and /auth/ to the backend (port 8000).


When deploying behind a reverse proxy (Traefik, Caddy, nginx) with HTTPS, set these to the public URLs in backend/container/.env:

Terminal window
OIDC_REDIRECT_URI=https://patrimoine.exemple.fr/auth/callback
FRONTEND_URL=https://patrimoine.exemple.fr

The OIDC_REDIRECT_URI must match exactly what is registered in your OIDC provider, including the https:// scheme.