Configuration is feature-shaped (how do I set up AI?); this page is threat-model-shaped (what should I lock down before this is reachable from anything other than my own laptop?). Security & trust explains what the protections are and what threats they address; this page is the operational checklist for applying them on your install. Work through it in order. Each step assumes the previous ones are done.
privacytracker is single-user by design. There are no user accounts. Anyone who can reach the UI has full access to its data. Hardening here is about controlling who can reach it, not about partitioning between users on the same install.

Decide your network exposure

Pick the most restrictive box you can live with — every step below is calibrated to one of these.
Default for the desktop app. No external network exposure; only processes on your machine can reach it. CSRF is enforced.The desktop app binds 127.0.0.1 on a port of its own, never 3000, and takes no admin token. See Set the admin token.http://localhost:3000 is the default for a server you run yourself, from Docker or a source checkout. That server counts as loopback-only when you tell it so with PRIVACYTRACKER_BIND_HOST=127.0.0.1; left unset, its bind reads as unknown and the admin token becomes mandatory. See Configuration.Hardening checklist: none beyond the defaults. You’re done.
Hosted on a NAS / Pi / always-on machine reachable from other devices on your home or office network. Multiple household members can use the same instance. Not exposed to the internet.Hardening checklist: TLS via reverse proxy, admin token set, dev endpoints blocked, off-host backups. The cookbook recipe at Self-host on a home NAS for the household walks through this end-to-end.
Reachable from anywhere. This is not a supported deployment model — privacytracker has no user accounts and no DDoS protection, and its internal rate limiter is defence-in-depth rather than an edge control: without PRIVACYTRACKER_TRUST_PROXY every caller shares one bucket per route, so it can’t tell a flood from a busy household. If you absolutely must, do all of Tier 2 plus: a rate-limiting reverse proxy (Cloudflare / a paid provider), IP allowlists, and an off-host TLS termination. Better: use Tailscale, WireGuard, or another mesh-VPN to keep the actual surface on a trusted network and only expose VPN authentication to the public internet.

TLS via reverse proxy

privacytracker ships with no built-in TLS — it expects a reverse proxy to terminate. The proxy is also the natural place for edge rate limiting, IP allowlists, and structured access logs. The main repo ships working Compose stacks for two of the three proxies below — deploy/caddy/ (Caddyfile, compose.yaml, .env.example) and deploy/traefik/ (compose.yaml, .env.example). If you cloned the repo, start from those rather than the snippets here; the snippets are the minimum that makes the same-origin check work, not a complete deployment.
The shortest working config for a LAN deploy:
Caddy auto-issues Let’s Encrypt certificates if privacytracker.lan resolves on the public internet, or self-signed certs for a LAN-only deploy. The Host forwarding line is non-negotiable — privacytracker enforces a same-origin CSRF check, so a proxy that strips or rewrites the Host header will cause every mutation to fail with 403 {"error":"Cross-origin mutation rejected"}.
After deploying, verify the CSRF check still passes with the new origin:
If the second call also fails, your proxy is rewriting Host. See Troubleshooting → Reverse-proxy CSRF rejection.

Set the admin token

Generate a token with at least 32 bytes of entropy and inject it into the runtime environment.
For Docker:
The desktop app takes no admin token, and there is no way to give it one. It starts its backend with a fixed environment that does not include AUDITOR_ADMIN_TOKEN, so setting the variable in a launchd plist, your shell profile or the app’s own environment has no effect. The desktop app binds 127.0.0.1 only, which keeps it out of the network-exposed posture that makes the token mandatory. See Tauri desktop shell for the variables the shell does hand the server.
Confirm it’s taking effect:
Error bodies here are human-readable prose, not stable machine codes — assert on the status, not the string. The token is verified with crypto.timingSafeEqual so token comparison runs in constant time. Failed attempts append to audit_log with the requester IP — see Review the audit log below. Setting the token also turns on the browser-session path: the Settings panel exchanges it once via POST /api/auth/admin-token/login for an 8-hour HttpOnly pt_admin_token cookie, so household members don’t paste a secret into every request and no script running in the page can read it. POST /api/auth/admin-token/logout clears the session. See Security → Browser sessions use a cookie. Once the instance is network-exposed, the token also gates a handful of sensitive reads — /api/backup/, /api/export, /api/deployment/, /api/diagnostics/, /api/ai/debug-log, /api/import/, /api/desktop/diagnostics. Any integration you point at those needs the header or the cookie too.
Rotating the token requires a process restart. The new value is read once on boot. Don’t share the token in chat / Slack / email — treat it as a secret on the level of a database password.

Block dev endpoints from the proxy

privacytracker ships with /api/dev/* routes that are only safe in development: All five ship in production builds — nothing in the source gates them to development. These are gated by the same CSRF and admin-token checks, but for a Tier 2/3 deploy you should also block them at the proxy so they aren’t reachable at all. Caddy:
Verify:

Review the audit log

The audit_log table records destructive-route attempts and authentication failures. Review it on a regular cadence appropriate to your exposure tier. There is no UI for this table — nothing in the app reads it. SQL is the only way in:
Things to react to:
  • Repeated admin_token.login.invalid from the same IP — someone’s brute-forcing. Rotate the token, then ban the IP at the proxy. admin_token.login.rate_limited and admin_token.login.global_throttled mean the built-in limiter already pushed back.
  • Unfamiliar IPs hitting destructive routes — even if the token check passed (i.e., they have the token), an unfamiliar source means the token leaked. Rotate.
  • reset.success you didn’t trigger — your data was wiped. Restore from backup; investigate how the actor got the token.
Set PRIVACYTRACKER_TRUST_PROXY if you want real client IPs in actor_ip. Without it, X-Forwarded-For is attacker-controlled and deliberately ignored, so every row records the literal local — which makes “same IP” un-observable and the first bullet unusable. Don’t treat the table as tamper-proof. Individual rows are only ever appended, never deleted or rewritten, but two routes wipe the whole table: POST /api/admin/start-over truncates it (audit_log is in START_OVER_TABLES_TO_TRUNCATE, lib/reset-tables.ts), and POST /api/backup/restore clears it and re-inserts whatever the bundle carried. Both are admin-token routes, so the actor you’d be investigating here — someone who has the token — can erase the trail behind them. POST /api/reset preserves it on purpose. If you need the trail to survive that, replicate it off-host on a schedule alongside your backups. If it grows large, drop and recreate manually with the app stopped.

Off-host backups

A NAS’s own RAID is not a backup. For Tier 2/3, schedule a daily replica of the data directory to a different physical machine.
Or use the in-app Settings → Backup → Automatic local snapshots to keep rolling JSON bundles under data/backups/, then sync those off-host. They’re off by default — enable the toggle and pick an interval. The bundle carries an integer format version, and a bundle from an older format restores cleanly into a newer release. See Backup & restore for the full options. Periodically verify a backup by restoring it into a throwaway instance — a backup you’ve never tested isn’t a backup, it’s a wish. Note the throwaway will reject the bundle as untrusted (different install, different signing key) unless you pass allowUntrusted=1; see Restoring a bundle from another install.

Disable inbound auto-update on Tier 3

The Tauri auto-updater fetches signed patches from GitHub releases on a recurring schedule, verified against the bundled minisign public key. For a Tier 3 (public-internet) deploy you may want to stop automatic updates and pull-and-verify each release by hand instead. Tradeoff: you lose silent security patches; you gain deterministic timing of every code change.
There is currently no supported switch for this. The updater plugin is registered unconditionally in src-tauri/src/main.rs and tauri.conf.json sets plugins.updater.active: true — there is no environment variable and no in-app toggle that turns it off.
If you need deterministic update timing today, the options are:
  • Run the Docker build instead of the desktop app on Tier 3 hosts. Image updates are explicit (docker compose pull && up), which is the posture this tier wants anyway.
  • Block the update endpoint at the host firewall — the updater reads https://github.com/privacykey/privacytracker/releases/latest/download/latest.json. A failed check now backs off (15 minutes, doubling, capped at a day) rather than retrying tightly, so a blackholed endpoint is not a busy loop.
Tracking a first-class opt-out: privacytracker issues.

Network restrictions

If your host firewall supports it, restrict outbound network from the privacytracker process to only the destinations it actually needs: The last two are different things and people get them the wrong way round. github.com is the desktop updater’s signed-patch endpoint. api.github.com is lib/update-check.ts, which runs on Docker, Node, Homebrew, and desktop alike: enabled by default, first probe 25 seconds after boot, then a 6-hour ticker that reaches the network at most once a day thanks to the cache. Block it believing it’s desktop-only and you silently disable the update banner on a server install. To switch the check off properly rather than blackholing the host, set its app_settings flag — there’s no UI toggle for it today:
Anything else is unnecessary and a yellow flag if it appears in your egress logs. A simple iptables / nftables rule scoped to the privacytracker container’s UID is enough; for serious lockdown, run the container in its own network namespace.

Pre-launch checklist

Before exposing your install to anyone other than yourself:
1

TLS terminates at a reverse proxy

Verified by browser padlock + curl -v showing the cert.
2

Same-origin check passes through the proxy

curl -X POST .../api/sync/trigger -H "Origin: https://your-host" returns 200, never 403. A sync already in flight is still a 200 — the body just carries "skipped": true.
3

Admin token is set and required

curl -X POST .../api/reset -H "Origin: ..." (no token) returns 401 {"error":"Admin token required"}.
4

Dev endpoints return 404

curl -X POST .../api/dev/seed-sample-data -H "Origin: ..." returns 404.
5

Off-host backup ran at least once

ls /backups/privacytracker-*/privacy.db on the backup host shows a recent file. Restored it into a throwaway instance and counts match.
6

Audit log is empty (or only your own legitimate events)

sqlite3 data/privacy.db "SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 20;" shows nothing surprising.
If any step fails, fix it before you tell anyone the URL.

What you can’t lock down

privacytracker is a self-hosted single-user app. There are guardrails it doesn’t have, by design:
  • No user accounts. Anyone with browser access has full access. Treat the URL itself as a credential.
  • No per-caller rate limiting without a trusted proxy. privacytracker does limit its own routes (Rate limiting) — 30/min on /api/scrape, 3 per 10 min on /api/backup/restore, 5/min on token login, and so on. But unless PRIVACYTRACKER_TRUST_PROXY is set, X-Forwarded-For is untrusted and every caller shares one bucket per route, so a bad actor inside the trust boundary consumes the same budget as the household. Rate-limit at the proxy if Tier 3.
  • No anti-CSRF beyond same-origin. No token-cookie pairs, no SameSite=strict-only flows. The same-origin check + the admin token are the entire CSRF surface.
  • No append-only enforcement on audit_log at the SQL level. Individual rows are never rewritten — the only write is an insert — but two admin-token routes clear the whole table (POST /api/admin/start-over truncates it, POST /api/backup/restore replaces it), and a process with shell access to data/privacy.db can rewrite it directly. So the actor you are investigating after a token leak can erase the record of their own access. The mitigation is to replicate the log off-host, and not to give untrusted actors shell access.
These are conscious tradeoffs for the local-first, single-user model. If you need the missing pieces, you probably want a different tool (or a privacytracker instance per user).