Skip to content

Back up and restore a Platform site

A site's database has two halves. derived is a view of git that nerthus derive rebuilds; authored is the site's own facts (pending changes, grants, rulings, audit, the instance registry), and nothing else in the world holds them. So every night a site backs up both stores: a dump of authored and a git bundle of every corpus clone, age-encrypted, in R2. This page is how to set that up, take one, and get one back.

authored/2026/09/09/authored-20260909T031500Z.sql.age    the dump
corpora/2026/09/09/lore-20260909T031500Z.bundle.age      one mirror per corpus clone

Set a site up

Where the backups go is configuration (the keys):

[backup]
endpoint = "https://<account>.r2.cloudflarestorage.com"
bucket = "nerthus-backups"
prefix = "dev"                 # one bucket may hold several sites
retain_days = 30               # 0, the default, deletes nothing
r2_budget_bytes = 6442450944   # 6 GiB, the default; 0 means no budget

The credential lives in the instance's sops store and nowhere else. Pipe it in, because ps shows every argument of every process:

printf '%s' "$R2_KEY_ID"     | nerthus secrets set R2_ACCESS_KEY_ID --note "the backup bucket"
printf '%s' "$R2_SECRET_KEY" | nerthus secrets set R2_SECRET_ACCESS_KEY --note "the backup bucket"

Take one

nerthus db backup                  # dump, encrypt, upload; prints the key and the throughput
nerthus db backup --retain 30      # …and delete anything older than 30 days afterwards
nerthus db backup --to ./out.age   # write it locally and upload nothing
nerthus db backup --dry-run        # dump, encrypt, measure against the budget, upload nothing
nerthus corpus bundle              # one encrypted mirror per corpus clone this instance holds
nerthus db backups                 # what is in the bucket, oldest first

The dump is encrypted to this instance's age recipient. A backup another instance took cannot be opened here, which is why nerthus init never replaces an identity: the key that decrypts every backup the site has taken is the one in its configuration directory.

The R2 budget

R2's free tier is 10 GB and the account is billed past it, so a site declares what it may keep: backup.r2_budget_bytes. Before every upload the job lists the bucket and adds the encrypted size it is about to write:

budget: 3612774400 of 6442450944 bytes after this backup (3611725824 in 41 objects now, 1048576 to add)
  • It is a listing, not a running total. Corpus mirrors, another site's prefix and a manual upload spend the same tier, and only the store knows about all of them, so the whole bucket is measured.
  • It is the ciphertext's size, checked with the encrypted file in hand and before the upload.
  • Passing it refuses the backup and uploads nothing. The refusal names the three numbers and logs backup.budget.exceeded at ERROR. --dry-run prints the same line.

Get one back

nerthus db hash                                   # the canonical SHA-256 of `authored`, before
nerthus db restore authored/2026/09/09/…age --yes # THIS DROPS SCHEMA authored FIRST
nerthus db hash                                   # …and after: the same number

restore asks before it runs when somebody is there to answer, and --yes is what a job passes. It drops the schema first, because a dump replayed over a live schema is neither the old state nor the new: every CREATE fails and what is left is whatever the site had. nerthus db hash is the canonical dump of authored, without the migration bookkeeping table, so "the same" is a measurement.

nerthus corpus restore-bundle lore.bundle.age ./lore --expect <commit>

A bundle restores with no network at all, and --expect refuses unless some ref of the clone is at the commit named.

Moderation data stays on the site

Moderation data is never backed up off-site. Every table marked as moderation has its rows left out of every dump; its definition stays, so a restore produces the schema the migrations describe. --include-moderation exists for a local investigation and cannot be uploaded: the flag without --to is refused.

What CI proves

Job What it measures
test:python the round trip through a local file: seed, hash, back up, drop the schema, restore, the same hash and row counts, and one changed row must move the hash
test:backup the same round trip through MinIO, which checks the request signature the package writes; the job fails if that case skipped
reference:check a bundle of every commit reference.lock pins, restored and asserted to come back at that commit

Limits

One upload carries at most 5 GiB; a larger object is refused by name. A failed upload is not retried: the next night's run is the retry. Backups are encrypted to the site's instance only; the operator's recovery identity is not yet a second recipient, so a rebuilt host cannot open an old backup without the original identity.