Production
This page is about moving an instance to production: how to put it behind a reverse proxy the right way, what the single-instance invariant means, and how to back it up. Notarium deploys as a single container — production configuration comes down to the three things below.
Reverse proxy and forwarded headers
You put a reverse proxy (nginx, Caddy, Traefik) in front of the app — it terminates TLS and proxies to Notarium's port. The key requirement: the proxy must forward the headers describing the external address.
The reverse proxy must send X-Forwarded-Host (or leave Host untouched) and X-Forwarded-Proto: https. Otherwise cookie-authenticated mutations from the interface are rejected as cross-origin — the symptom is "I can see it but can't save." Forwarding X-Forwarded-Proto is also needed so the session cookie gets the Secure flag.
Here's why: the Origin check on mutations compares the request's source against the address the browser sees — and that address arrives in a forwarded header. Agent calls over a Bearer PAT are exempt from the check (they carry no cookie — so there's no CSRF surface). The proxy, for its part, must overwrite the forwarded headers with its own values rather than pass the client's straight through.
If you turn on OAuth authorization for agents, set PUBLIC_BASE_URL when running behind the proxy (for example, https://notes.example.com) — a stable external address for the OAuth metadata. Without it, the address is derived from the forwarded headers. See Configuration.
Trusting the client IP
An axis separate from the address is the client's real IP. Two limits are counted against it: login attempts and the admission of new OAuth clients. Behind a proxy every request arrives from the same address, so without explicit configuration those limits would be counted per proxy — that is, against everyone at once.
The knob is TRUST_PROXY: a comma-separated list of the IPs/CIDRs of the immediate proxies.
# .env — substitute the address of your own proxy container or host
TRUST_PROXY=172.18.0.0/16
The safe default is to leave the variable unset: then X-Forwarded-For has no bearing on the limits at all, and no header can forge someone else's IP. Set it only when you know your proxy's address for certain, and keep the list narrow.
Boolean values, hop counts, named ranges, and all-address ranges (/0) are rejected at startup. Trusting every address would mean any client could assign itself an IP by header and walk around the login limit.
This setting has no effect on forwarding X-Forwarded-Host and X-Forwarded-Proto — those are independent axes, and the contract from the previous section stands unchanged.
The single-instance invariant
Notarium is built for a single process. Two pieces of authentication state live in the process's memory:
- login rate-limit — the attempt counters;
- the SSE socket registry — the mechanism through which revoking access instantly tears down live connections.
Behind a load balancer with several instances and no shared storage, these mechanisms break down: an attacker multiplies the limit across instances, and revoking access on one instance won't close an SSE connection held open on another.
Both pieces of state live in the process's memory, so behind a load balancer with several instances and no shared storage these mechanisms don't work. Moving the metadata database to Postgres gives you shared state, but that alone isn't enough for horizontal scaling. Keep a single instance.
Backups
The canonical backup is a built-in command of the image, not a copy of the files made from outside: notarium backup assembles a verified ZIP and streams it out while the service keeps running.
docker compose exec -T notarium backup > notarium-$(date -u +%Y%m%dT%H%M%SZ).zip
docker compose exec -T notarium backup verify < notarium-20260731.zip
Verification belongs in the regular job, not in a one-off gesture: it changes nothing and catches corruption before the day you need the archive. For the job itself, a plain redirect isn't enough — take the safe publishing sequence (temporary file → flush to disk → atomic hard link) from the runbook: Backup and restore. That page also covers restoring into a clean volume and where the command stops applying.
meta.dbcp /data/meta.db, or copying the volume while the service runs, is not a backup: the metadata database runs in WAL mode, committed rows may still be sitting in meta.db-wal, and files copied one by one aren't a snapshot of a single point in time.
What goes into the backup, and why:
| What | Role |
|---|---|
/data/spaces | Your Markdown files — the source of truth. |
/data/meta.db | The metadata database (history, users, access) — unrecoverable from files. |
/data/jobs | Artifacts and uploads from import/export jobs. |
/data/engine | The engine's derived indexes. Not in the archive: they're rebuilt from files. |
If the metadata database has been moved to Postgres, or the notes live outside the data root, the built-in command exits with an error instead of producing a partial archive — back up the database and the mounted directories with your provider's standard tooling. For exactly what the metadata database holds, see the Database page.