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 |
cp backend/container/env.example backend/container/.envcp frontend/container/env.example frontend/container/.env # optionalcp web/container/env.example web/container/.env # optionalpodman.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):
docker compose --env-file backend/container/.env \ --env-file frontend/container/.env --env-file web/container/.env up --buildLeave 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.
Reference
Section titled “Reference”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.
Backup script variables
Section titled “Backup script variables”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 variables
Section titled “podman.sh variables”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).
Local Development (Without Containers)
Section titled “Local Development (Without Containers)”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:
DEBUG=trueFRONTEND_URL=http://localhost:5173OIDC_REDIRECT_URI=http://localhost:8000/auth/callbackDATABASE_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:
# Backendcd backenduv sync && uv run uvicorn app.main:app --reload --port 8000
# Frontendcd frontendnpm run devThe Vite dev server (port 5173) proxies /api/ and /auth/ to the backend (port 8000).
Production Behind a Reverse Proxy
Section titled “Production Behind a Reverse Proxy”When deploying behind a reverse proxy (Traefik, Caddy, nginx) with HTTPS, set these to the public URLs in backend/container/.env:
OIDC_REDIRECT_URI=https://patrimoine.exemple.fr/auth/callbackFRONTEND_URL=https://patrimoine.exemple.frThe OIDC_REDIRECT_URI must match exactly what is registered in your OIDC provider, including the https:// scheme.