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 runmakes thecopycopies through the ops socket,groundnote backup proberuns the restore drill, andgroundnote backup nightly(run, probe, dead-man ping tobackup.deadman_url) is meant for one OS timer a day (cli.md §5 and §8, examples inexamples/systemd/andexamples/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):
- a
VACUUM INTOsnapshot of the live file (read only) as the baseline; - the newest backup in a temporary file: for
copythe file<directory>/scopes/<scope>.sqlite, forlitestreamlitestream restore -o <tmp> data/scopes/<scope>.sqlite(Litestream reads its config fromLITESTREAM_CONFIGor/etc/litestream.yml); - on both:
PRAGMA integrity_check=ok,meta.scopematches, a search for a word of the newest rule finds it; the baseline has at least the versionsops.sqliteremembers; the backup has at least the append-only rows of the last passed verification and not more than live; - 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_IDandLITESTREAM_SECRET_ACCESS_KEYitself, 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.comfor EU jurisdiction) andregion: auto. - One prefix per scope. The example uses
dir: /data/scopeswithpattern: "*.sqlite"andwatch: 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.sqliteis 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: 1handretention: 168hto shorten restores and keep a week of history (tips, retrieved 2026-11-05). - SQLite settings. Litestream needs WAL mode and recommends
PRAGMA busy_timeout = 5000so the application tolerates Litestream's short checkpoint locks.PRAGMA synchronous = NORMALis safe in WAL mode. Groundnote sets WAL and a busy timeout itself. Do not run manual checkpoints or delete-walfiles; 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.