Documentation pages

Backup#

Groundnote stores each scope in its own SQLite file (data/scopes/<scope>.sqlite) plus data/ops.sqlite, all in WAL mode, each with exactly one writer (the service). Choose one backup way per instance in groundnote.json:

{
  "backup": {
    "kind": "litestream",
    "target": "https://<account-id>.r2.cloudflarestorage.com/<bucket>",
    "prefix": "groundnote-prod",
    "deadman_url": "https://<your dead-man switch ping URL>"
  }
}

litestream requires target, prefix and deadman_url; copy requires directory and deadman_url. Unknown keys fail the start.

Status. The service never schedules a backup. groundnote backup run makes the copy copies through the ops socket, groundnote backup probe runs the restore drill, and groundnote backup nightly (run, probe, dead-man ping to backup.deadman_url) is meant for one OS timer a day (cli.md §5 and §8, examples in examples/systemd/ and examples/launchd/). The service still does not start or supervise Litestream: run it yourself with examples/litestream.yml; supervision follows in Phase 2 (architecture.md, section 12).

backup.kind What it does Use when
litestream Continuous replication of the WAL to S3-compatible storage, one prefix per scope. You need a low recovery point (seconds) and the bucket operator is trusted.
copy Consistent copies via sqlite3 .backup into a directory. A backup run (restic, borg, snapshots) picks up the directory; recommended default, encrypted by the backup tool.

The container image ships the service, sqlite3 and Litestream. Never run both ways against the same files.

What to back up, and what not#

Back up data/scopes/*.sqlite and data/ops.sqlite. Do not copy the live *.sqlite, *.sqlite-wal or *.sqlite-shm files with a plain file copy (the set is inconsistent while the service runs), and do not back up Litestream's hidden metadata directories (.<file>-litestream) or groundnote.json secrets (there are none; keep the file in version control). Secrets live only in your environment or secret manager.

Backups contain mail and meeting content and are not encrypted by Groundnote. Default recommendation: copy plus restic (client-side encrypted). An export encrypted with age by the operator after download works too; an export or copy that Groundnote itself encrypts with a per-scope age key does not exist yet (planned: slice R5 of redaction.md, architecture.md section 15.2). Use litestream when you need a low recovery point and trust the bucket operator (see Litestream encryption). Background: security/data-at-rest.md.

groundnote export, groundnote backup run|probe|nightly|verify and groundnote restore --to exist (sections below and cli.md §5). Schedule groundnote backup nightly --json once a day from an OS timer (cli.md §8): it copies, runs the restore probe with the nightly export, and pings the dead-man switch only when both succeeded.

Export#

groundnote export <scope> [--out <file>] [--json] downloads GET /v1/export/:scope (approval channel, persons with decide on the scope) as <scope>-export-<UTC stamp>.tar.gz: INDEX.md, one Markdown file per rule, history.jsonl with every row of the allowlisted knowledge and graph tables, and manifest.json with counts and a SHA-256 per file (architecture.md, section 4.4). The archive is written with mode 0600 and never overwrites a file. It contains everything of the scope, rejected rules included, in plaintext: encrypt it before it leaves the host (age -r <recipient>). The service never reads an export back. Credentials: GROUNDNOTE_URL and GROUNDNOTE_ACCESS_JWT (a person's token, cloudflared access token -app <url>), or ~/.config/groundnote/cli.json with {"url": …, "access_jwt": …} and mode 600. Service tokens cannot export; machine backups use backup verify and the copies.

Verify#

groundnote backup verify [--scope <scope>] [--json] asks the running service over the ops socket to check every created scope (or one):

  1. a VACUUM INTO snapshot of the live file (read only) as the baseline;
  2. the newest backup in a temporary file: for copy the file <directory>/scopes/<scope>.sqlite, for litestream litestream restore -o <tmp> data/scopes/<scope>.sqlite (Litestream reads its config from LITESTREAM_CONFIG or /etc/litestream.yml);
  3. on both: PRAGMA integrity_check = ok, meta.scope matches, a search for a word of the newest rule finds it; the baseline has at least the versions ops.sqlite remembers; the backup has at least the append-only rows of the last passed verification and not more than live;
  4. both are exported and the SHA-256 of every rule file compared. Rules changed after the backup's newest write are listed as exceptions, every other difference fails.

The result is stored in backup_state in ops.sqlite. Exit codes: 0 all passed, 3 a check failed (each failed check is printed with its detail), 1 the call failed (for example ops-unreachable, scope-unknown), 2 wrong usage. Temporary files live in the private data directory and are removed afterwards.

Restore to a new file#

groundnote restore --to <path> --scope <scope> [--from litestream|copy] [--json] runs without the service. It never writes into data/scopes/, never overwrites a file (also not -wal/-shm next to it) and writes the result only after the checks of "Verify" step 3 passed, compared with the last passed verification in ops.sqlite. It warns when the file has fewer versions than ops.sqlite remembers: the service would lock it, so restore ops.sqlite from the same time. To put a restored file into service: stop the service, move the file to data/scopes/<scope>.sqlite, remove stale -wal/-shm, start the service. Restoring from an export (--from export) follows in a later phase.

Litestream#

Reference: Litestream configuration, retrieved 2026-11-05; S3-compatible services, retrieved 2026-11-05. The example file is examples/litestream.yml.

  • Credentials only from the environment. Litestream reads LITESTREAM_ACCESS_KEY_ID and LITESTREAM_SECRET_ACCESS_KEY itself, and expands $VAR / ${VAR} in the config file before parsing. Create a bucket-scoped key with object read and write permission.
  • Cloudflare R2. Use type: s3, endpoint: <account-id>.r2.cloudflarestorage.com (<account-id>.eu.r2.cloudflarestorage.com for EU jurisdiction) and region: auto.
  • One prefix per scope. The example uses dir: /data/scopes with pattern: "*.sqlite" and watch: true. Litestream namespaces each replica by the file's relative path, so every scope gets its own prefix (<prefix>/scopes/<scope>.sqlite/), and scopes created after start are picked up within seconds. ops.sqlite is replicated to <prefix>/ops.
  • Snapshots. Litestream takes a full snapshot every 24 h by default and keeps it 24 h. The example sets snapshot.interval: 1h and retention: 168h to shorten restores and keep a week of history (tips, retrieved 2026-11-05).
  • SQLite settings. Litestream needs WAL mode and recommends PRAGMA busy_timeout = 5000 so the application tolerates Litestream's short checkpoint locks. PRAGMA synchronous = NORMAL is safe in WAL mode. Groundnote sets WAL and a busy timeout itself. Do not run manual checkpoints or delete -wal files; Litestream controls checkpointing. Disabling autocheckpoint is only advised for tens of thousands of small writes per second, far above Groundnote's load.
  • Asynchronous. Replication is asynchronous: the last moments before a crash can be lost. On a clean stop Litestream flushes outstanding changes.

Litestream encryption#

Litestream does not encrypt client-side. Since v0.5.1 a replica with an age: block that sets identities or recipients makes it exit at start with age encryption is not currently supported (v0.5.0 accepted the block but silently ignored it) (configuration reference, retrieved 2026-11-05; no newer version with client-side encryption is documented there). The replicated pages are therefore plaintext to the bucket operator. Mitigate with bucket-side encryption (S3 SSE-C/SSE-KMS, or R2 encryption at rest), a bucket-scoped key, and versioning or object lock. Do not put an age: block in the file. If the bucket operator is not trusted, use copy with restic.

Run Litestream as a supervisor of the service (litestream replicate -exec "groundnote serve") or as a second process in the container, with -config pointing to your file.

Restore#

Stop the service, then restore each database to an empty path (restore reference, retrieved 2026-11-05):

litestream restore -config /etc/litestream.yml -o /data/scopes/sales.sqlite \
  s3://<bucket>/<prefix>/scopes/sales.sqlite
litestream restore -config /etc/litestream.yml -o /data/ops.sqlite \
  s3://<bucket>/<prefix>/ops

Add -timestamp <RFC3339> for a point in time, -integrity-check full to check the result and -dry-run to preview. Restoring to an existing file requires -force. Remove stale -wal and -shm files next to the target first. Then start the service.

Restore ops.sqlite and the scope files from the same time. Groundnote remembers the version count per scope in ops.sqlite (backup_state). A scope file with fewer versions than remembered is locked and answers 503 scope-locked instead of silently serving older decisions (deviation D18 in abweichungen.md). The same happens for a failed quick_check, a missing file of a created scope or an unknown schema version.

Restore drill#

Restore into a scratch directory daily (cron or CI), run sqlite3 <file> 'PRAGMA integrity_check;' on every file, and start a throwaway Groundnote against the result to confirm that a known rule is found. A backup that has never been restored is not a backup.

Copy#

groundnote backup run (POST /ops/backup/run, architecture.md 11.1) makes the service write a consistent copy of each database into directory/scopes/<scope>.sqlite and directory/ops.sqlite, safe while the service runs; backup nightly includes it. Your existing backup run then picks that directory up. Restore by stopping the service, placing the copies at data/scopes/<scope>.sqlite and data/ops.sqlite, deleting leftover -wal and -shm files, and starting the service. The same daily drill applies.

Edit on GitHub