Disaster recovery
What is backed up, the three legs, the recovery chain, the runbook for a dead host, the recovery card, failure modes, RPO and RTO, and verification.
The design's headline property: a printed kit plus any one backup target recovers everything, with no vendor, no internet, and no exocomp code required. This page is the runbook and the chain behind it.
What is backed up
One staging tree, assembled to tmpfs, sent as identical bytes to all three legs:
backup-<ts>/
├── MANIFEST.json manifest/schema/exocomp/keyring versions, tool versions,
│ timestamp, sha256 + size of every file
├── MANIFEST.mac HMAC-SHA256 of MANIFEST.json under
│ Derive("backup-manifest-mac", <target>) — written per leg
├── snapshot.db ← VACUUM INTO (consistent, WAL-checkpointed, sub-second)
├── keyring.version the keyring generation this snapshot was written under
└── keyring.age the N age-wrapped DEK copies — see below
MANIFEST.mac is not listed inside the manifest it authenticates, and its key is
per target, so a manifest lifted from one target does not verify against another.
A restore checks the tag before it trusts a single sha256: a tag that does not
match is reported as tampering, a digest that does not match as corruption.
Application-consistent backups (quiesce hooks)
The snapshot above is consistent for exocomp's own SQLite (VACUUM INTO). For a
live application's data (a running Postgres, a busy Vaultwarden SQLite), a raw
copy can be torn. Drop a .exocomp/backup-quiesce.yml in the data dir to wrap the
backup in quiesce hooks — its pre_run steps quiesce (e.g. pg_dump, stop a
compose stack, sqlite3 .backup), its post_run steps un-quiesce:
pre_run:
- name: dump-postgres
run: pg_dump -Fc app > /var/lib/exocomp/dumps/app.dump
post_run:
- name: done
run: 'true'
It uses the same schema as .exocomp/hooks.yml (a gate: is refused — a backup
has no health gate). A failed pre_run aborts the backup before anything is
staged — never a torn snapshot — and the failure surfaces to be alerted on; the
post_run un-quiesce always runs once quiesce succeeded. A missing file (or one
with no steps) runs the backup exactly as before. Both the backup run CLI and
the daemon's scheduled backups honor it. Hooks run sandboxed as the run user
(no sudo, no egress — see run lifecycle).
A snapshot with no tag from a target that never authenticated one still restores, with
a loud legacy backup: integrity unverified warning. A git target is the exception: it
is age-encrypted only to public recipients, so the DEK-keyed tag is the only thing
binding a snapshot to your keyring. A git target that has authenticated once leaves an
AUTHENTICATED.mac marker at its root; a later untagged snapshot there is a downgrade —
the forgery an attacker with git write and the public recipients would attempt — and the
restore refuses it (ErrManifestUnauthenticated) unless you pass
--allow-unauthenticated for a snapshot you are certain is yours. Restic targets need no
marker: their repository password is derived from the DEK, so an attacker without it can
neither read nor write the repo.
The engine (internal/backup) ships exactly this tree. config.toml, blobs/
(job artifacts), and repos/*.bundle (project git bundles) join the tree when those
features land — the design remains as above; the implementation is authoritative for
what exists today.
OpenTofu state ciphertext rides inside exocomp.db. ansible-vault and sops files ride
inside the git bundles as ciphertext — restic treats them as opaque blobs, so there is no
extra integration. Provider mirrors, vendored Galaxy collections, and the volume toolchain
ride in the same snapshot, which is what makes an offline restore possible.
Three legs, independently
| Leg | Mechanism |
|---|---|
| NAS | restic to a local repository on the SMB/NFS mount, after asserting mountpoint, canary file, and free space |
| S3-compatible | restic to an s3: repository. Credentials resolve from sealed secret rows s3/<target>/access-key-id and s3/<target>/secret-access-key (optionally .../region, .../session-token) when both required rows are sealed; otherwise the leg falls back to the process environment and logs a warning — it never fails silently on a missing sealed credential |
| private GitHub repo | tar+zstd of the staging tree, age-encrypted, pushed as a single orphan commit, force-pushed to a rolling ref window, chunked into 90 MB .age.partNN parts with a sha256 parts manifest (internal/backup/chunk.go) that carries its own .mac — it is the one file a chunked snapshot exposes outside the age envelope. The leg refuses loudly before pushing if the bare repository's objects directory already exceeds 4 GB |
One leg failing does not abort the others; each gets its own backup_run row and its own
alert. A target that fails twice consecutively raises a critical notification. The
dead-man's-switch ping fires only on full success, because the failure mode that
actually kills you is silent non-execution.
A backup_run row ends in succeeded, failed, or cancelled; a leg the daemon's
shutdown stopped before its drain grace ran out records cancelled, not failed. The
row is closed whatever happens to the leg, so a row still reading running means the
process writing it was killed outright (SIGKILL), not that a backup is in progress.
Sealing S3 credentials
A restic_s3 target's credentials are ordinary sealed secrets, named
s3/<target>/<field> (<target> is the name given to backup add-target):
printf '%s' "$ACCESS_KEY" | exocomp secrets add s3/offsite/access-key-id --kind s3-credential
printf '%s' "$SECRET_KEY" | exocomp secrets add s3/offsite/secret-access-key --kind s3-credential
region and session-token fields are optional, sealed the same way. Both
access-key-id and secret-access-key must be sealed for the target to use them: a
target with only one, or neither, falls back to the process environment
(AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) and the run logs a warning — it never fails
silently on a missing sealed credential, and it never mixes one sealed field with one
environment field.
The one detail that makes recovery work
keyring.age is written to every target root, outside the restic repository, with
keyring.version beside it.
It is ciphertext — N age-wrapped copies of the DEK — so leaving it in the clear is safe. It must not be inside the encrypted backup, because the restic password is derived from the DEK that the keyring wraps. Putting it inside would be a perfect circular dependency and the single most likely way to build an unrecoverable system.
printed recovery key → fetch keyring.age from ANY target → unwrap DEK
→ HKDF → restic password → restore → running control plane
The generation beside it is what makes a rollback visible: an old keyring.age still
unwraps the DEK — that is what it is for — so nothing else distinguishes the current
keyring from one that predates a revoked recipient. exocomp restore refuses a keyring
older than the generation the snapshot's manifest records, at the target root and inside
the snapshot alike. Recipient changes and host-key rotation bump the generation
(exocomp identity migrate), which is also when the printed card goes stale.
Every arrow is executable with off-the-shelf tools. The second circularity — needing a storage credential to fetch anything at all — is closed by putting a read-only storage credential per off-host leg on the printed kit itself (Decision 0003).
The image the deployment ran
RECOVERY.md at each target root carries image-ref: — the ref the daemon read from the
unit's EXOCOMP_IMAGE and recorded. keyring.age restores state, but nothing in it
restores the image: a local tag lives in the store of the host that can fail. Capture the
image beside the backup, and load it on the new host:
podman save -o exocomp-image.tar <image-ref> # on the host that still runs it
# on the new host: load the image, then the Quadlet units and `exocomp init`:
podman load -i exocomp-image.tar # or `podman pull <image-ref>` with a registry
--image-load runs podman load -i (or docker load -i for the Docker tier) before
install or rollback; --image-ref names the image the unit should run. Set the unit's
EXOCOMP_IMAGE to that ref so the daemon records it and the next RECOVERY.md reflects
reality (containers). A custom image also records its source base digests
in the recovery set (Decision 0016): a registry tag may
be used to install, but recovery must not need one (invariant 7). exocomp doctor prints
the stored ref, or says it was never recorded.
Runbook: the control host is gone
You have a printed recovery kit and any one backup target.
sequenceDiagram
actor Op as Operator
participant NH as New host (bare)
participant T as Any backup target
participant C as exocompd
Op->>NH: recovery.sh --from --identity
Note over NH: loads podman + the image
NO exocompd running yet
NH->>Op: prompt: backup target URI?
Op->>NH: target URI from the kit
NH->>T: fetch keyring.age (credential supplied by the operator; on the kit)
NH->>Op: prompt: paste or scan the recovery age key
Op->>NH: AGE-SECRET-KEY-1…
NH->>NH: age -d keyring.age → DEK
NH->>NH: HKDF(DEK, "restic-repo:") → RESTIC_PASSWORD
NH->>T: restic restore latest --target
NH->>NH: install the Quadlet unit, enable it
C->>C: boot, try to unwrap the DEK via the host key… ABSENT
C->>Op: "no host identity — rewrap now?"
Op->>C: yes
C->>C: mint a new host identity, wrap the DEK, TPM-seal, add a keyring row
C->>C: revoke the old host recipient, rebuild schedules from the DB
C->>Op: N runbooks, M schedules, K workspaces, last job
Then verify rather than assume:
exocomp doctor # tool versions, data dir mode, keyring recipients
exocomp tui # last known activity — does it match your memory?
Deriving without the daemon
The card's rule is one command, and it runs with or without a live envelope:
exocomp kit derive restic/nas # the repository password
exocomp kit derive ansible-vault/homelab # the vault password for a project
exocomp kit derive --list # every purpose:id the store knows of
# Offline: no daemon, no host key — the recovery key and a keyring.age
# fetched from any target root are the only inputs.
exocomp kit derive restic/nas --identity recovery.key --keyring ./keyring.age
exocomp kit verify --page kit.txt re-renders the kit as it stands now and compares it
with a printed page: a page that no longer hashes to the digest it prints was edited, and
a page that no longer matches the current kit needs reprinting. The card asks for this
quarterly.
exocomp identity migrate --add-recipient label=age1… | --drop-recipient label | --rotate-host re-wraps the keyring and bumps its generation. It refuses while
exocomp serve is running: the daemon holds the keyring it started with, and the next
backup would write that one back to every target root. Sealed secrets and derived
subkeys survive untouched — only who can unwrap the DEK changes — and the command ends
with the reprint nag.
exocomp ssh-serve --listen <addr> --authorized-keys <file> renders the same TUI over SSH
(public-key auth only, no passwords) for a host with no local terminal at hand — a recovery
host reached only over the network, for instance.
The recovery card
The card is the manual path: the same chain with no exocomp binary anywhere in it, using
only age, openssl, and restic.
The card's literal command text is a build artifact, not documentation. It is rendered
by the kit renderer, and the exact rendered text is executed in CI on a clean image
against a fixture backup, with the derived restic password diffed against exocompd's own
derivation. This rule exists because an earlier card did not run as written — the
pipeline fed an openssl kdf invocation that does not read stdin, and the -kdfopt
spellings were mixed. A DR card whose commands do not run is worse than no card.
Treat the printed card as authoritative over this page. The chain it automates is:
age -d -i <recovery key file> keyring.age→ the 32-byte DEK.HKDF-SHA256over the DEK — versioned domain-separation salt,infostringrestic-repo:<target-id>— 32 bytes, base64-encoded →RESTIC_PASSWORD. Both the salt and the target id are printed on the card, so the derivation is reproducible by hand.restic -r <target URI> restore latest --target <data volume>, using the read-only storage credential you supply (printed on the card).- Start the service, then
exocomp doctor.
The card also carries the QR-coded recovery key, the target URIs, the fingerprints of every
active recipient (so the operator can tell which YubiKey), the healthchecks ping URL, the
ansible-pull trust anchor fingerprint where pull mode is enabled, and — where the optional
Litestream sidecar is configured — the replica URL, so a rebuild can find the fast-RPO
copy of exocomp.db (see below). The replica URL is a locator, not a secret; the replica's
own storage credentials stay in the sealed secret plane, never on the card.
The kit is displayed once and never stored in the database. The bootstrap wizard requires
typing I HAVE STORED THIS before it will continue, and exocomp doctor warns if the
database has no recovery-kind keyring row.
Failure modes
| What died | Recovery |
|---|---|
| Host disk | The runbook above |
| Host key, kit intact | The runbook above — the recovery key unwraps the DEK, a new host key is minted and the old one revoked |
| Recovery kit lost, host alive | exocomp recovery-kit re-renders the kit structure from what is already stored; it does not mint a new recovery identity, wrap the DEK to it, or revoke the old row |
| Recovery kit lost and host key lost | Unrecoverable, by design; the same property any real vault has |
| DB corrupt, backups fine | exocomp restore --from <kind:uri> --dest <fresh data dir> restores the newest snapshot — for a restic kind, an older one by id with --snapshot <id> (full or unambiguous prefix; a git target keeps exactly one snapshot, so it has no selector). It refuses a destination that already holds a keyring. There is no in-place restore and no startup PRAGMA integrity_check gate |
| One backup target unreachable | Others proceed and each gets its own backup_run row; there is no per-target alerting or last-success-age view in doctor |
exocompd will not build or run at all |
The card's manual path uses only age, openssl, and restic. The bundles are plain git bundles and exocomp.db is plain SQLite |
RPO and RTO
| Configuration | RPO | RTO (dead host → serving) |
|---|---|---|
| Default: DB-only fast path every 15 min, full tree nightly | ≤ 15 min | ~5 min |
| With the optional Litestream sidecar → S3 | ~1 s | ~5 min |
The 15-minute fast path is a VACUUM INTO plus a restic backup of just exocomp.db — a
few hundred KB after dedup, cheap enough to run constantly. Litestream stays optional
because losing 15 minutes of job history is not a data-loss event worth a second process
for most operators.
Fast-RPO restore (Litestream)
Litestream is the fast-RPO complement, not a replacement: restic stays the authoritative, encrypted DR chain. The two recover different things, and the precedence is deliberate.
| Litestream | restic | |
|---|---|---|
| Recovers | exocomp.db only |
keyring, sealed secrets, database, git bundles, tofu mirrors — everything |
| RPO | ~seconds (continuous WAL shipping) | ≤ 15 min (fast path) / nightly (full tree) |
| Encryption | none — plaintext WAL frames; the target must be LAN-local or an already-encrypted store | age-wrapped keyring + restic repository password derived from the DEK |
| Authenticity | none in the transport; proven after the fact by the decrypt round-trip below | per-target manifest MAC + keyring-generation check |
| Role | fast-forward the database on an already-recovered host | the root of trust and the whole recovery |
When to use which. For a full disaster — dead host, lost host key — recover with restic (the runbook above): it is the only chain that carries the keyring and secrets, and Litestream cannot stand in for it. Then, if seconds matter and the Litestream sidecar was replicating, fast-forward the database to its freshest state:
exocomp restore --from-litestream \
--dest <data dir the restic restore rebuilt> \
--identity recovery.key \
--replica <replica url> # omitted: read from the litestream.replica_url setting
The order is load-bearing. Litestream recovers the database only, so --from-litestream
requires a data dir that already holds keyring.age — the keyring is what opens the database's
sealed rows. Onto an empty dir it refuses and points you back at the restic restore. Because a
replica ships unauthenticated, unencrypted bytes, the restored database is not trusted until a
decrypt round-trip proves it: the command unwraps the DEK from the keyring beside it and opens
one sealed row, and only then promotes the recovered file over the live one. A database that
fails the round-trip is discarded, and the live one is left untouched.
The no-encryption caveat. Litestream does not encrypt. A LAN-local or file:// replica is
accepted as-is; a plaintext public remote (s3://…, sftp://host…, gs://… on a routable
host) is refused unless you pass --i-know-the-replica-is-encrypted, which asserts the store
itself is encrypted. This mirrors the config side: never point Litestream at a plaintext remote.
A restore never resumes schedules. Like the restic restore, --from-litestream leaves the
system paused — it writes the database and stops. Starting exocomp serve is a deliberate act,
so a half-understood recovery does not immediately fire jobs.
Deterministic unit tests cover the source-selection precedence, the DB-only-vs-full distinction,
the plaintext-remote refusal, and the decrypt round-trip gate without any litestream binary;
the shell round-trip is an e2e that skips cleanly where the binary is absent, the same posture as
the tofu and ansible gates.
Verification — the part people skip
restic check --read-data-subset=1/12weekly per target, rotating through the whole repository quarterly so bandwidth stays sane.- A monthly
dr-drill: restore the newest snapshot into a throwaway container, boot against it, assert row counts, assert a canary secret round-trips, assert that a play and a plan run with network egress disabled, then destroy it and post the result. Every restored snapshot contains interrupted jobs and stale locks by construction, so boot-time reconciliation is asserted here too. - The drill runs from a scratch container holding only the kit contents — no host state, no shell history — which is what makes it a real test of the credential chain.
- A scheduled upgrade rehearsal mirrors it: restore last night's snapshot into a scratch instance, run the new image's migrations against it, assert, discard.
Making the drill a scheduled job in the same engine costs nothing new, and it means a silently broken envelope fails a routine job instead of being discovered during a real disaster.
backup drill: the restic/digest/integrity_check slice, today
exocomp backup drill [--target <name>] and exocomp schedule add --kind drill implement
the restore-and-verify core of the dr-drill above (internal/backup.RestoreDrill): restore
the newest snapshot into a throwaway directory, verify every manifest digest, open the
restored database, and run PRAGMA integrity_check — then discard the directory whatever
the outcome. Booting a scratch container, asserting row counts, and running a play/plan with
network egress disabled are not yet wired to it; the section above stays the target shape.
The restic password is derived straight from the running envelope (the same HKDF a backup
writes with), so a scheduled drill needs no offline recovery identity and can run unattended
on a cadence. A git target is the exception — it is only ever encrypted to public recipients
— so it refuses live drilling and points at a manual exocomp restore --identity instead.
A drilled snapshot that does not check out (OK: false) is a drill finding: the CLI
reports it and exits nonzero, and a scheduled fire settles the job failed, which the ordinary
job-failure paging already covers. A target the drill could not even reach — unresolved, no
backup yet — is a plain error, the same finding-vs-infra-error split RestoreDrill documents.