---
title: "Production"
description: "Reverse proxy and forwarded headers, trusting the client IP, the single-instance invariant, regular backups with the built-in command."
---

# 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](/docs/self-hosting/install/) — 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.

> [!warning] The most common deployment mistake
> 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](/docs/self-hosting/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.

```bash
# .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.

> [!warning] Don't list "everyone" here
> 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.

> [!note] Multiple instances behind a load balancer
> 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](/docs/self-hosting/database/) 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.

```bash
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](/docs/self-hosting/backup/). That page also covers restoring into a clean volume and where the command stops applying.

> [!danger] Don't copy a live `meta.db`
> `cp /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](/docs/self-hosting/database/) page.
