Back up, restore, and prove it
Write a backup of the record, prove it restores with a drill, schedule it, and read the health line that tells you when backups have stopped.
This page is for the person who owns the server the record lives on. It has three acts, each a command you run yourself: write a backup, prove the backup restores, and watch the health line that tells you when backups have stopped. A backup nobody has restored is a promise; the drill turns it into a measurement.
What a backup holds
saphan backup writes one directory, <backup dir>/<UTC timestamp>/, while the console keeps
serving.
| piece | how it is taken |
|---|---|
| the store | one consistent snapshot (SQLite VACUUM INTO, or pg_dump on PostgreSQL), never a copy of a live database file |
| each configured CouchDB database | a paged read of every document, attachments inline, one JSON document per line |
| the record's git repositories | a git bundle --all of each repository, plus archives of the record's transaction and document directories |
MANIFEST.json | every file's SHA-256 and size, the counts per piece (rows per table, documents per database, refs per repository), and every piece that does not apply, with the reason |
A piece that does not apply is written down in the manifest, never silently left out. The bundled
git server's own data is not part of the backup today: the manifest lists the forge piece as
not-applicable. Back up the git server's volumes with your host-level backup.
⚠ Limits that are part of every backup — the manifest repeats them as remarks:
- The store is one snapshot. CouchDB is read page by page while it keeps serving; a read during which the database changed is discarded and taken again (three attempts), then the piece fails by name.
- A PostgreSQL store is checksummed but never restored by the drill: the drill reports
not-proven: postgres restore not implementedand never printsPASSfor it. Prove a PostgreSQL dump by hand withpg_restoreinto a disposable database. - Files in the state directory outside the store — the console login, keys — are not in this backup. Keep them in your host-level backup.
1. Choose where backups go
Backups must not live on the disk they protect. Point them at another volume and decide how many to keep:
| key | default | meaning |
|---|---|---|
backup.dir | none | where backups are written; must be an absolute path |
backup.keep | 14 | how many good backups stay before the oldest is removed |
backup.schedule | 23:00 | when the installed schedule runs, local time, HH:MM |
2. Write a backup
The command prints one row per piece and ends with a RESULT: line. RESULT: ok means every piece
was written. If a piece failed, the directory is kept with a .failed suffix, RESULT: error names
the piece, and that directory is never counted as a backup and never causes an older one to be
removed.
3. Prove it restores
Name one backup directory, or the backup directory itself, in which case the newest good backup is used. The drill:
- verifies the manifest's signature, then every file's checksum and size, and stops at the first mismatch;
- restores into a fresh scratch directory — never into your install;
- recounts the rows of every table, the documents of every CouchDB dump and the refs of every repository against the manifest, and runs SQLite's own integrity check on the restored store;
- starts a console server on the scratch copy, on a loopback port with no outbound connection, and asks it one read;
- prints
PASS,UNMEASUREDorFAIL(with the first mismatch by file and piece); - removes the scratch directory, unless you pass
--keep.
The signature. When the host holds a machine device key (created by
saphan machine admit --mint-device-key), saphan backup signs the manifest, and the drill refuses
a manifest that does not verify under that key. The key is never stored in the backup, so someone
who can rewrite the backup directory can rewrite the checksum list but cannot sign it. A host with no
device key writes an unsigned backup: its drill prints UNMEASURED, never PASS.
⚠ What a PASS does not say: the drill starts no CouchDB, so a CouchDB dump is proved by checksum
and document count, not by loading it into a node. Run the drill after every change to what you back
up, and at least once a month.
4. Schedule it
saphan service install registers the backup schedule beside the other services: a systemd timer on
Linux, a launchd job on macOS. saphan service status lists it and saphan service uninstall
removes it. If you change backup.schedule, run saphan service install again.
5. Watch the health line
saphan fleet health prints one line for backups:
The line turns red when the newest good backup is older than twice the schedule period
(48 hours), when no backup exists, or when the newest backup is not usable — a failed run, or a
recorded file that is missing or of another size. It reads the backups themselves, not the
scheduler, so a schedule that was removed or is silently failing turns it red the same way.
saphan fleet health --json carries the same reading under backup.
⚠ The health line checks sizes, not contents, and retention does not check signatures. Only the drill, on a host that holds the device key, proves a backup has not been rewritten.
6. Restoring for real
saphan restore offers the drill and nothing else: restoring into a running install would overwrite
what is running, so no command does it. On the machine you are rebuilding:
- Stop the Saphan services and keep the old state directory aside.
- Copy
store/saphan-studio.dbfrom the backup into<workspace>/.saphan/state/. A PostgreSQL backup isstore/saphan-studio.pgdump; load it withpg_restore. - For each
bridge/repos/<name>.bundle, rungit clone --mirror <bundle> <workspace>/.saphan/bridge/repos/<name>. A repository with no bundle in the manifest had no refs: create it withgit init --bare. - Unpack
bridge/tx.tarandbridge/documents.tarinside<workspace>/.saphan/bridge/. - For each
couchdb/<database>.jsonl, create the database on the new node and post its lines to/<database>/_bulk_docsas{"docs":[…],"new_edits":false}, which keeps each document's revision. ⚠ This step is not exercised by the drill. - Start the services, then run
saphan fleet healthand a normal read.
Run the drill on the same backup first. A restore you have not rehearsed is the one that fails on the day.
When it refuses
| you see | meaning | next |
|---|---|---|
backup.dir "…" is not an absolute path | a relative directory would land wherever the scheduler runs | set a full path |
already exists — two backups within one second | two runs started in the same second | run it again |
RESULT: error with a failed piece | the piece names itself and says why | fix the cause and run saphan backup again |
restore drill FAILED at file … | the file differs from what the manifest recorded | do not trust that backup: use an older one, and find out what touched the file |
no CouchDB credential resolves | the CouchDB node is configured and its login is not | saphan config set channel.credential <entry> |
Per-user vs system-wide state (the --system flag)
Where the binaries go and where the state goes are separate decisions, and the state's is made by the installation's mode, not by the install directory.
The VS Code extension (saphan-code)
A read-only fleet view inside the editor — it needs no server and opens no port.