# Backup & restore
Source: https://docs.privacytracker.privacykey.org/backup-and-restore

How to back up your privacytracker data, restore it, and migrate between install paths.

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

| Surface | Path |
|---|---|
| Desktop app (macOS) | `~/Library/Application Support/privacytracker/privacy.db` |
| Docker | `/app/data/privacy.db` in the `privacytracker-data` volume, or `./data/privacy.db` on the host with the bind-mount file |
| From source | `./data/privacy.db` in the repo working directory |

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)

```bash
# macOS desktop app — quit the app first for a clean copy
cp ~/Library/Application\ Support/privacytracker/privacy.db ~/backups/privacy-$(date +%F).db

# Docker: stop the container, copy the data directory out of its volume, start it again
docker compose stop web
docker compose cp web:/app/data ~/backups/privacytracker-$(date +%F)
docker compose start web

# From source
cp data/privacy.db ../backups/privacy-$(date +%F).db
```

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](https://docs.privacytracker.privacykey.org/images/settings-backup.png)

*Backup and restore settings*

From the API:

```bash
curl -o backup-$(date +%F).json http://localhost:3000/api/backup/export
```

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:

```bash
# 1. Preview (read-only)
curl -X POST http://localhost:3000/api/backup/preview \
  -H "Content-Type: application/json" \
  -H "Origin: http://localhost:3000" \
  --data-binary @backup-2026-05-09.json

# 2. Restore (after reviewing the preview)
curl -X POST http://localhost:3000/api/backup/restore \
  -H "Content-Type: application/json" \
  -H "Origin: http://localhost:3000" \
  --data-binary @backup-2026-05-09.json
```

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:

```
409 { "error": "...", "code": "untrusted_backup", "signaturePresent": true }
```

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:

```bash
curl -X POST "http://localhost:3000/api/backup/restore?allowUntrusted=1" \
  -H "Content-Type: application/json" \
  -H "Origin: http://localhost:3000" \
  --data-binary @backup-from-old-machine.json
```

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.

> **Warning**
>
> 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**:

| Setting | Default | Notes |
|---|---|---|
| **Create snapshots automatically** | off | nothing is written until you enable this |
| **Snapshot interval** | Daily (24h) | Every 6 hours / Every 12 hours / Daily / Weekly |
| **Snapshots to keep** | 10 | how many recent snapshots to keep; older ones are auto-pruned. Clamped to 1–100 |
| **Create snapshot now** | — | one-click snapshot for ad-hoc backups |

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](#restoring-a-bundle-from-another-install), which today means finishing the restore through the API rather than the UI.

### Docker → desktop app

**Step 1: Export from Docker**

```bash
curl -o privacytracker-export.json http://localhost:3000/api/backup/export
```

**Step 2: Stop the Docker stack**

```bash
docker compose down
```

**Step 3: Install the desktop app**

Download the latest `.dmg` from [Releases](https://github.com/privacykey/privacytracker/releases/latest), drag to `/Applications`, launch.

**Step 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:

```bash
curl -X POST "http://localhost:3000/api/backup/restore?allowUntrusted=1" \
  -H "Content-Type: application/json" \
  -H "Origin: http://localhost:3000" \
  --data-binary @privacytracker-export.json
```

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](https://docs.privacytracker.privacykey.org/installation)); 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):

**Step 1: Quarantine the broken DB**

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

```bash
mv data/privacy.db data/privacy.db.broken
mv data/privacy.db-wal data/privacy.db.broken-wal 2>/dev/null
mv data/privacy.db-shm data/privacy.db.broken-shm 2>/dev/null
```

**Step 2: Try sqlite3 .recover**

```bash
sqlite3 data/privacy.db.broken ".recover" | sqlite3 data/privacy.db
```

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

**Step 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.

**Step 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`:

```bash
# Spin up a temporary container pointed at a fresh data dir
docker run --rm -p 3001:3000 -v $(pwd)/test-data:/app/data privacytracker:latest &

# Import the bundle
curl -X POST "http://localhost:3001/api/backup/restore?allowUntrusted=1" \
  -H "Content-Type: application/json" \
  -H "Origin: http://localhost:3001" \
  --data-binary @backup-2026-05-09.json

# Verify counts match expectations, then tear down
curl http://localhost:3001/api/apps | jq '.apps | length'
docker stop $!
rm -rf test-data
```

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.
