privacytracker stores everything in a single SQLite file, which makes backup boring in a good way: copy the file, you have a backup. There are also higher-level tools for versioned exports, scheduled snapshots, and migrating between install paths.

Where the database lives

It’s a single SQLite file plus -wal and -shm siblings while the app is running. Recent writes can sit in the -wal file until SQLite checkpoints them, so a copy of privacy.db alone, taken while the app runs, can miss them. Stop the app first, or use the backup bundle below.

Quick file copy (simplest)

Restore is the inverse — replace the file with the app stopped, then start it back up. Schema migrations run on boot, so an older backup restored into a newer release upgrades automatically. On Docker, restore a backup bundle through Settings → Backup instead of copying files into the volume: files copied in with docker compose cp belong to root, and the server runs as the container’s audit user.

Backup bundle (versioned, app-aware)

GET /api/backup/export produces a single JSON file with every app, label, snapshot, annotation, notification, focus state, and feature-flag override. The envelope carries an integer version field — currently 1 — which is the bundle format version, not the app version. A bundle restores into any release that understands that format or a later one; a bundle from a newer format is refused with a message telling you to upgrade, rather than being misparsed. Tables missing on an older schema are skipped, so a newer bundle still restores what an older install can hold. From the UI: Settings → Backup → Export bundle.
Backup and restore settings

Backup and restore settings

From the API:
The bundle is plain JSON — you can gzip it for storage and restore from the gzipped form, the importer transparently decompresses.

Restore with preview

In the UI, restore is a two-step flow: POST /api/backup/preview shows what will be replaced, then you type RESTORE to confirm before anything is written. From the UI: Settings → Backup → Import bundle → choose the file → confirm in the preview dialog. From the API:
The typed RESTORE string is a browser-side guardrail only — the route reads no confirm parameter, so a curl restores immediately. Take a file copy of data/privacy.db first as a safety net; restore wipes the existing database and reloads from the bundle. Restore is rate-limited to 3 attempts per 10 minutes and requires the admin token when one is configured.

Restoring a bundle from another install

Every exported bundle carries an HMAC-SHA256 signature computed with a per-install key at <data dir>/backup-signing.key. That key is generated on first use and never leaves the machine, so a bundle exported by a different install — a different container, a different Mac, a fresh data directory — cannot verify. Restoring one is rejected:
This is deliberate: it makes “reset, then restore my own backup” a trusted operation and forces cross-install restores to be a decision rather than an accident. To proceed, opt in explicitly:
The header x-allow-untrusted-backup: 1 does the same thing. Both accept 1 or true. Rows from an untrusted bundle are still sanitised on the way in, but the signature no longer tells you where the file came from — only restore a bundle you can vouch for yourself.
The desktop and web UIs do not send this flag today. A cross-install restore has to go through the API until they do.

Server-local rolling snapshots

privacytracker can keep its own rolling snapshots in the data directory under data/backups/ — useful if you don’t want to wire up an external backup tool but still want a few historical copies on hand. They are off by default. Turn them on and configure them in Settings → Backup → Automatic local snapshots: The server checks on startup and on each 30-minute tick, writing a snapshot when the interval is due. Snapshots use the same versioned JSON bundle format. List them via GET /api/backup/snapshots; download a specific one via GET /api/backup/snapshots/<filename>.

Migrating between install paths

The same versioned bundle works as the migration vehicle, with one caveat: the destination is a different install, so the bundle’s signature won’t verify there. Every migration below needs the allowUntrusted opt-in described in Restoring a bundle from another install, which today means finishing the restore through the API rather than the UI.

Docker → desktop app

1

Export from Docker

2

Stop the Docker stack

3

Install the desktop app

Download the latest .dmg from Releases, drag to /Applications, launch.
4

Import the bundle

Launch the desktop app once so it creates its data directory, then restore through the API with the untrusted opt-in — the bundle was signed by the Docker install, so the UI path rejects it:
The desktop app serves on 127.0.0.1 at a port of its own, not 3000. The Host row under Settings → Admin → Deployment Diagnostics shows it; use it in both the URL and the Origin header. From the first release on the Rust backend the app keeps that port from one launch to the next, where earlier releases pick a new one at every launch.

Desktop app → Docker

Same flow in reverse. Export from the desktop app’s Settings → Backup, drop the JSON next to docker-compose.yml, start the stack, then restore with ?allowUntrusted=1 against http://localhost:3000.

Source checkout → Docker (or vice versa)

A source checkout keeps its database at ./data/privacy.db. Docker does too with the bind-mount file (see Installation → Docker); otherwise it keeps it in the privacytracker-data volume. So you can either:
  • Copy the file between the two when both use ./data, or
  • Use the bundle export/import flow (recommended: it works with the volume, and it also handles schema upgrades).

What’s not in a backup

A bundle restore replaces app data and settings. It does not restore:
  • Auto-update binaries — those are managed by Tauri’s updater on the desktop app or brew upgrade on Homebrew.
  • Environment variables — AUDITOR_ADMIN_TOKEN, etc., are external to the database. Re-set them in your runtime config.
  • iPhone import helper output — those are stand-alone .txt / .csv files; back them up separately if you want to keep them.
  • The backup signing key — backup-signing.key sits next to privacy.db in the data directory, deliberately outside the database so a reset or restore can’t invalidate your existing bundles. A plain file copy of the whole data/ directory carries it along; a bundle export doesn’t.

Disaster recovery

If data/privacy.db is corrupted (rare; SQLite is durable, but not invincible to underlying disk failure):
1

Quarantine the broken DB

Don’t run the app against a corrupted file — it can make things worse.
2

Try sqlite3 .recover

SQLite’s recovery tool is good — it salvages everything still readable into a fresh file.
3

Or restore the most recent backup

Drop your last good .db (or import a bundle) and start the app. Schema migrations run on boot, so older backups upgrade automatically.
4

If both fail

Start fresh with Settings → Reset DB (or POST /api/reset with the admin token). You’ll re-import apps via the onboarding wizard.

Verifying a backup

A good habit is to periodically verify your backups by restoring one into a throwaway instance. The throwaway has a fresh data dir and therefore a fresh signing key, so the bundle reads as untrusted there — pass allowUntrusted=1:
If the counts match what you expect from the source instance, your backup is good. To verify the signature as well as the contents, copy backup-signing.key from the source install’s data directory into test-data/ before starting the container. The restore then succeeds without the flag — which proves the bundle really was produced by that install and hasn’t been altered since.