Backups
Every Joinery site can back itself up: on a schedule, encrypted, uploaded to an S3-compatible bucket, with retention enforced. No agent, no SSH, no management node. server_manager is a fleet layer on top of this, not a prerequisite for it — a site running server_manager backs itself up through exactly the same path as a standalone install, because no site's recovery may depend on another machine being alive.
Configured at Admin → System → Backups (/admin/admin_backups).
Two parties, two profiles
A site can be backed up by more than one party. It backs itself up, and a management node managing it may take its own copies. Those are not two ways of doing one thing — they are two parties' backups, on two schedules, onto two shelves, answerable to two people. They open with the same key, because that key belongs to the machine and its administrator, not to whoever asked for the run.
A profile (includes/BackupProfile.php) is the unit that keeps them apart:
site | manager | |
|---|---|---|
| Configured by | the site's admin, on its Backups page | the management node |
| Triggered by | the Backup scheduled task | the management node's FleetBackupRun |
| Executed by | BackupRunner on the machine | BackupRunner on the machine |
| Recovery key | the site's own backup_recovery_public_key | the site's own backup_recovery_public_key |
| Bucket credentials | stored on the machine | none: every write is a link the management node's broker signs |
| Prunes backup storage | the site | the management node |
| Depends on | nothing | the management node being alive at the scheduled moment |
Where a manager run writes: the broker
A Managed node holds no key to the bucket its management node keeps for it.
Its backup job carries, where a credential would go, a broker run: the
management node's broker address, a run opened for this job when the node's
agent claimed it, and that run's token. The run's engine hands that to
S3Signer in place of a credential (BackupBroker, an S3LinkSource): every
upload request becomes one call to the broker for a signed link, then the
request itself on that link, so a stream, a multipart upload and a retry work
exactly as they do against a site's own target.
- begin says where the run writes: the storage space, its base key
(
{path_prefix}/{slug}/manager/), the bucket and the target's name. It is asked before the run decides which chain it extends, because a chain stays in one space (bkh_remote_space_id); a run the broker takes in another space starts a new chain there. - sign answers one link for one write inside the base key: a put, or a
multipart create, a batch of ten part links, a complete. Nothing outside the
base key, no read and no delete is ever signed for a node. *Nothing is
written twice: a key a finished run completed is answered
exists, with the size and sha256 recorded for it, instead of a link. In a space whose target locks, a put's and a multipart create's link signs the object lock headers and the answer names them; the node sends them as given (§ Object lock). - finish names every object the run wrote, with the bytes and sha256 the
node sent, and the chain. The broker records them (
svo_sha256), cancels whatever else the run signed, and writes the run's ledger file (below). Only then is the run committed on the node; a run the broker did not record is a failed run, undone like any other, and its retry signs the same names again. A finish whose reply was lost is asked again and answered with what was recorded. A node that never heard back (it died after the broker recorded the run) finds its next run number already taken: that run fails saying so, and marks the chain closed, and the run after it starts a new chain (name_taken). A name already holding the same bytes counts as stored. - abort says the run failed: what it signed is cancelled.
Reads need no broker call: for a restore, a verify or a download the management node signs one link per object into the job, so no credential of any kind travels for a read either.
The recovery key is the one thing a management node does not supply. It says where a backup goes and signs the writes that put it there; what opens the archive is read on the machine, from that machine's own verified setting. A manager run that arrives carrying key material is refused, not ignored, and a machine with no verified key of its own refuses to back up at all rather than sealing to a key it was handed.
The reason is that sealing cannot fail visibly. Encrypting to a public key succeeds whether or not anybody holds the private half, so a substituted key produces archives that report themselves encrypted, upload normally and show green on every dashboard — while only whoever substituted the key can read them, and only a restore attempt would ever reveal it. A key that arrives over a wire is therefore a key nobody on the receiving machine can verify, whatever sent it. The cost is accepted deliberately: opening a machine's backups needs that machine's recovery key, and no single key opens a fleet.
Neither profile owns the site's backups. They are peers. A site admin who wants copies of their own as well as the management node's just sets their profile up; a management node keeps taking its own whatever the site does. Two backups a night of one machine is a supported configuration, not a misconfiguration to be detected.
The asymmetry in the last row is the safety argument: the site profile depends on nothing. A management node that is down, retired or hostile costs a site nothing it was relying on.
Everything a run touches that could collide with another run is derived from the
profile: the working directory (backups/ for the site, backups/manager/ for a
management node's), and therefore the lock, the tar snapshot, the chain manifest,
the envelope scratch and the local sweep. Sharing a snapshot alone would corrupt
both chains — each run advances it, so each profile would treat the other's work
as already archived.
Two locks are held. The per-profile lock is correctness: two runs of one profile
share a snapshot and a manifest. The machine-wide lock is courtesy and I/O: a run
that finds the other profile working reports itself skipped and waits for its
next tick.
What a backup is
Two shapes, chosen by How backups are taken.
Incremental (default). A chain: one full, then runs that carry only what
changed. Measured on a real site, the first run's file archive was 193 MB and
the next was 37 kB. The site is archived as two parts, each with its own
incremental history: data, the site directory less public_html/
(uploads, config, static files), and code, public_html/.
{path_prefix}/{slug}/{profile}/chain-{YYYYMMDD_HHMMSS}/
manifest-0000.json the restore contract — order, levels, hashes, sealed keys
data-0000.tar.gz.enc the site less public_html: the full
code-0000.tar.gz.enc public_html, rooted at public_html: the full
db-0000.sql.gz.enc
meta-0000.tar.gz.enc shape.json + virtualhost + a note of the run
data-0001.tar.gz.enc an incremental
code-0001.tar.gz.enc a full again when an upgrade swapped the code
db-0001.sql.gz.enc
manifest-0001.json run 1's manifest: every run up to and including it
...The manifest (version 3) records a level on every artifact of a kind that
increments — data, code, and a physical database backup, pgdata — so one
kind can start over inside a chain while the others carry on; a run's own
level is 0 exactly when every kind in it is. A restore takes each kind from
its newest level 0 at or before the run, forward to the run
(BackupChain::restore_plan(); restore_chain.sh applies the same rule).
Every run writes its own manifest, manifest-{seq}.json, naming every run
of the chain up to and including it, so nothing in a chain is ever written
twice. Readers take the highest number (BackupChain::newest_manifest_name());
a prune deletes every manifest of a chain with the chain. On the machine the
chain's working copy is manifest.json, and staging lands the chosen manifest
under that name, so everything that reads a local chain reads one file.
A version 2 manifest is the same layout with one manifest.json that every
run rewrote; a version 1 manifest is a chain written before the data/code
split: one files-NNNN.tar.gz.enc a run, the whole site directory, and one
level per run that is the files archive's. Every reader reads versions 1 to
3, and refuses a version it does not know, by name, rather than restoring part
of a chain. A runner never extends a chain of another version: the next run
starts a new chain (layout_split).
Full every time. One self-contained archive per run:
{path_prefix}/{slug}/{profile}/{project}-{YYYYMMDD_HHMMSS}.tar.gz.enc the archive
{path_prefix}/{slug}/{profile}/{project}-{YYYYMMDD_HHMMSS}.tar.gz.enc.keys.json its envelopeA database-only backup is the same shape with {database}-{YYYYMMDD_HHMMSS}.sql.gz.enc
as the archive. All three families carry the same stamp, which is what a
management node's retention sorts backup storage by.
Nothing an engine produces lands on this disk. The data and code archives, the
standalone archive and the database dump are each one pipeline —
tar | openssl or pg_dump | gzip | openssl — whose stdout the runner hands
to S3Signer::put_stream(), which uploads it as it flows. What a run holds on
disk is the metadata artifact (kilobytes), the chain manifest, the snapshot,
and, for a standalone archive, the compressed database dump for the length of
the tar (it is a member of that archive, staged beside the output and removed
with the staging directory). A standalone archive is built from the live
tree — a second -C into the site root with the members renamed under
project_files/ — so no copy of the site is ever made. On the smallest nodes
this is the difference between a site that can be backed up and one that
cannot: peak disk during a run is the site itself.
The engine's verdict arrives after its bytes. tar reports a file that changed
while it was being read as exit 1 (accepted — the normal case on a live tree)
and a real failure as 2 or more; pg_dump reports its own. So each engine in
stream mode (--archive -) writes its statuses to a report file
(--report FILE) once the stream has been fully produced, and the runner
completes the upload only when the process exited 0, the report says tar 0
or 1 (or pg_dump 0), openssl 0, and at least 64 bytes went up — an openssl
envelope around an empty stream is 32 bytes, and a backup of nothing is never
recorded as a backup. Anything else aborts the upload: nothing partial or
empty is ever in backup storage, and a chain run that fails this way is discarded
under the ordinary rule (snapshots and manifest put back as they stood, the
run's other objects deleted where the credential can delete).
Everything is AES-256-CBC (PBKDF2, random salt). slug defaults to the project
directory name — the same value a management node would use for this site — so a
standalone site that later joins a fleet keeps one location instead of starting
a second pile beside the first. profile separates the parties, so a listing can
always say whose backup an object is and each party's retention addresses only
its own backup storage.
What a backup carries besides files and database
A backup has to be able to rebuild a site on hardware that is not the hardware it
came from, so it records the facts about that hardware which a restore has to
settle. shape.json, written by reconcile_site.sh --print-shape into the
archive (full mode) or the meta artifact (chain mode):
| Field | What it is for |
|---|---|
deployment_environment | docker or baremetal, read from the site's own config — never probed for at runtime |
domain, web_root, base_dir, site_template | where the site thought it was |
php_version, postgres_version | what it was running under, so an incompatibility is legible later |
vhost_captured, vhost_role | whether a virtualhost travelled, and whether it was a container's internal one or a bare-metal site's public one |
An archive with no shape.json restores normally: the source shape reads as
unknown and the restore reconciles against the target regardless.
Offloaded files in backup storage
A site that offloads its uploaded files and inbound mail to a cloud file store
(docs/cloud_storage.md) serves them from that bucket, and its archives carry
none of them: the runner hands the files engine an exclude list naming every
offloaded row's local paths, a blob's original and variants, a message's
.eml. Each offloaded file is on the backup storage once instead — encrypted, content-addressed, kept for as
long as any retained run's index names it — so a restore point stays whole
whatever the chain does, and a chain's incrementals never carry a day of
photos backup storage already holds.
{prefix}/{slug}/{profile}/
chain-{id}/
objects-0003.json.gz the run's index — plain gzipped JSON, like the manifest
{slug}-{stamp}.objects.json.gz a standalone full's index, same stamp as its archive
objects/
{epoch}/
envelope.json the epoch's sealed data key
envelope-{fpr}.json the same key re-sealed to a later recovery key
{fbb_stored_name}.enc one object per offloaded blob
mailbox/{id}.eml.enc one object per offloaded messageAn object's name is the blob's stored name, which never carries a slash, or
one namespace segment and a file for a consumer that is not a blob (offloaded
mail is mailbox/{message id}.eml), so the two can never collide.
BackupObjects::location_of() is the one reading of this layout every listing
uses. A restore asks every storage profile for the row a name belongs to
(StorageProfile::backupRow()) and puts the object back at that row's
placement. Whether the file store still serves an object is asked of the store
the row records.
The index is a manifest artifact of kind objects and names every
offloaded blob the run knew: name, epoch, the encrypted object's
object_bytes and object_sha256, and stored — whether it was in backup storage
when the index was written. No plaintext size, hash or MIME type: the private
store offloads private blobs too, and a plain file the management node reads
must not carry a fingerprint of a private file's content. Everything a restore
needs about the plaintext is in the blob row, which is restored first. Objects
are not ledgered; their integrity chain is manifest → index → object, each
verified against the one above before it is opened.
Epochs are the key model. One data key with one envelope at
objects/{epoch}/envelope.json — the ordinary envelope, sealed to the same two
recipients as every chain — encrypts every object stored while the epoch is
current, in the same aes-256-cbc-pbkdf2 form as an archive, so the
Opening a backup with no Joinery anywhere procedure opens an object
unchanged. A new epoch starts when there is none, when the recovery recipient
changed, when the site key cannot open the current envelope (degrade, never
fail every run), or when backup storage is proven to have lost an object
stored under the current epoch: a fresh encryption is never the ciphertext
the backup sets naming the lost key recorded, so it is never written there. On recovery-key rotation the older epochs are re-sealed,
not re-encrypted: each envelope the site key opens is rebuilt for the current
recipients and uploaded beside it as envelope-{fpr}.json, named by the
first 16 hex of the new recovery key's fingerprint — nothing in backup storage
is written twice. The new key opens everything through those, and the old key
still opens what it always did through envelope.json. An epoch whose envelope
re-sealed to the current key is already there is left alone. An epoch the
site key cannot open stays sealed to the retired key alone; the run writes
those down (objects/retired-epochs.json), names them in its message (so a
management node's job result and this site's history say so), and Recovery
Readiness says so on the recovery-key card — "N objects (X GB) open only
with a retired recovery key" — with no automatic re-copy. Keep that key.
Enabled profiles, and the hold. A profile stores offloaded files when it
is enabled (BackupProfile::enabled()): the site profile when a target is
enabled, the recovery key proven and the backup type includes files; the
manager profile when this machine has joined a management node and a manager
run carrying the object store has been here (the objects/enabled marker).
An offloaded file's local bytes stay on this server until every enabled
profile holds it — the site profile by the store the offload tick makes on
the way out (docs/cloud_storage.md § An offloaded file is in backup
storage before its local copy goes), the manager profile by its held.json,
which the management node's runs write. Only then are the original and its
variants released. With no profile enabled nothing is held, and the file
store alone serves the file.
The run. Before the archive: the epoch; what backup storage holds (the site
profile lists its objects/ prefix; the manager profile reads the newest
manager index through the presigned objects_index_url in its request, union
held.json). held.json names the destination it describes — the target
for the site profile, the management node's space for the manager profile —
and a run reads it only on that destination: a profile that moved storage, or
a file that names no destination, starts from the new destination's own
index, and the run stores every object that index lacks.
Objects backup storage lost. An object a backup set records as stored and
backup storage does not hold is not held, and the run stores it again from
the site's copy — its local original, else the file bucket — under the current
epoch, a new one when the lost object was under the current one. The site
profile proves it itself: an object its previous index names that its own
listing lacks. The manager profile is told: the management node's backup
storage check lists every location an index it read marks stored that the
listing lacks, and the next run's request carries them as objects_missing
({space, locations: [epoch/name]}), taken only from a listing that completed
for that space; the node applies it only when its run writes to the same space.
The job carries it as two parameters the agent declares, objects_missing_space
and the list objects_missing, and only to an agent at 1.83.0 or newer (an older
agent refuses a run that names them, so it is not told and the check keeps
naming the objects until it is updated). One run names as many as fit in a job,
at most 2,000; the rest are still missing at the next check and go with a later
run.
The run's index names the object where it now is, so the newest restore point
is whole again. Earlier restore points still name the lost key and cannot
bring the object back; the check keeps naming them until retention removes
their backup set. An object the
site no longer has a copy of cannot be stored again: the run says so, its index
keeps naming the lost location, and the check keeps reporting it.
Then the exclude list; then the store — every cloud blob not held,
one at a time under the offload engine's per-row lock, local originals first,
then catch-up: a blob with no local bytes is fetched from the file bucket,
encrypted and uploaded, both temporaries deleted. One budget covers the whole
step, 2 GB or 20 minutes (BackupRunner::OBJECT_STORE_BUDGET_*, constants);
what it leaves is indexed stored: false and taken next run, and the Backups
page says "N files (X GB) still to copy from the file store" until it reaches
zero. Then the engines with the exclude file, the index, the commit, and —
after the run is offsite — held.json and the release. A run whose request
did not ask for objects (a management node not running the object store, a
database-only backup) stores nothing and holds nothing.
The manager run request carries these fields from a management node
running the object store: objects: true, objects_index_url (a presigned
GET for the newest manager index, or absent when there is none),
epoch_envelope_urls ({epoch id: presigned GET} for each epoch's newest
envelope in the listing, what the re-seal reads) and, when its backup storage
check found any, objects_missing (above). A node whose management node sends none of
them behaves as if the object store did not exist.
Node disk. Every step is bounded:
| Step | Extra disk held | For how long |
|---|---|---|
| Offload tick, site's backup storage store | one object's ciphertext | the one upload |
| Waiting for the manager run | uploads since the last successful manager run | until that run |
| Run store step | one object's ciphertext | per object |
| Catch-up | one object's plaintext + ciphertext | per object |
| Tar | as before, less every cloud blob | as before |
objects/epoch.json
(the current epoch and its envelope), objects/held.json (what this
profile's backup storage held as of its last run, and which destination that
was — a cache, rewritten whole by every run, read by the release rule and,
for the manager profile, unioned with the index), objects/retired-epochs.json,
objects/enabled (manager profile) and objects/tmp/.Retention. Objects are a third family beside chains and standalone
fulls, pruned by whoever prunes that backup storage: the management node's
FleetBackupRetention::prune() deletes an object when no retained run's index
names it and it is older than the newest retained run's start, and stops
without deleting when any index it needs cannot be read; the site's own
enforce_object_retention() deletes, when a chain or standalone full is
pruned, every object its indexes name that no retained index names. An empty
epoch's envelope goes with its last object.
What each backup storage protects against. The object store protects against a
deleted bucket, a revoked key and an accidental rm in the file store. It
does not protect against losing the account backup storage is on: when the site's
backup target and the file store share an access key, the Backups page and
the cloud-storage page both say "Backup storage and the file store share one
account; losing it loses both. A management node's copy would survive it." The
same-key test is the whole rule.
On the pages. The Backups page's Offloaded files box: how many files
live in the file store; per enabled backup, "Offloaded files in backup storage
(this site's backup): N objects, X GB; last indexed at the run of …"; what is
still to copy from the file store; "M files (X GB) waiting for the management
node's backup before their local copy is released" (every cloud row whose
original is still on disk — one stat per row on page load, no column, no
cache; BackupObjectsStatus); the same-account line; and the daily file-store
check with Bring them back (§ The file store is checked too). The
cloud-storage page's Status block has the waiting count and size. The
node's Backups tab in the reads the backup storage listing:
"Offloaded files in backup storage: N objects, X GB by this management node; last
indexed at the run of …", with Bring them back. And because a count on a
page is read by nobody, BackupObjectsNotice stands on every admin page when
the waiting bytes pass 2 GB or anything waits for a backup that has not
succeeded in 7 days: "N files (X GB) are waiting for the management node's
backup, which last succeeded D days ago. They stay on this server until it
does." It clears itself when nothing waits.
What a restore reconciles
Both restore paths — the archive and the chain — end in
reconcile_site.sh, which makes the restored site agree with the machine it
landed on. It reports each value it changed and refuses rather than papering
over a mismatch it cannot fix.
The identity, in both places it lives:
| Setting | Set to |
|---|---|
webDir (config and stg_settings) | the domain the restore was given |
deployment_environment | the target's shape |
baseDir, site_template | the target's paths |
| database credentials | left as the target's — never taken from the backup |
Some files in a backup are the machine's, not the backup's, and the
target's own copies survive every restore. Both restore scripts read the one
list, MACHINE_CONFIG_FILES in install_tools/_restore_guard.sh:
config/Globalvars_site.phpholds this machine's database password and itssecret_box_key. Restoring the source's copy is what turns a clean-looking rebuild intoSQLSTATE[08006]on every page — and it bites a same-shape rebuild exactly as hard as a cross-shape one.config/backup_site_keyidentifies one machine as a recipient of its own backups. Two machines sharing it means one machine's key opens the other's archives.backup_envelope.phpmints a fresh one on first use, so absent is the correct state.- The files that decide what the machine trusts, or that its host writes:
release_verify_keys,release_statement_keys,release_trust,transparency_log_keys,release_log_required,release_log_optional,tree_owner,agent_signing_key,release_statement_key,disk_allowanceandcode_check. A backup's copy would make its maker's signature count as ours, hand the code tree to the web user, or opt the machine out of the public log. Where the machine has none of its own, the backup's copy is used, which is how a management node rebuilt from its own backup gets its signing key back. On a host that runs only signed code it never is: the host converger writes what belongs there.
Restoring onto a host that runs only signed code
A host whose restrictions refuse unsigned code (unsigned_packages=refuse,
written by install.sh docker --multi-tenant; see
Installation) carries sites for other people. A copy or
restore there brings the source's whole code tree and its config/, so both
restore scripts check the archive before anything is written or the database
is loaded, and refuse it whole if any check fails:
- Only a site's own folders. The archive may hold nothing at the site root
but
public_html,maintenance_scripts,config,uploads,static_files,storageand the release manifest. Anything else (avendor/, say) holds code no signature covers, and is named in the refusal. An upgrade's own trees are never restored on any host (below), so an older archive carrying them is not refused for it. - Code exactly as shipped. Core, every plugin and every theme are checked
against their own signed manifests (
utils/verify_package.php --tree, overPackageSignature::verifyTree()). The verifier and the keys are the machine's: the tree the restore script runs from, never anything inside the archive. An unsigned style theme passes; nothing in it runs. Each package that fails is named. - The machine's trust files. The archive's copies are never used (above).
restore_project.sh checks the archive it has already unpacked. restore_chain.sh
(which a site copy runs) applies its archives straight into the live site, so on
such a host it first unpacks the chain into a scratch directory beside the site,
leaving out uploads, static_files and storage, which hold nothing that
runs. It checks that copy, removes it, and only then applies. A dry run of
restore_project.sh reports the same refusal.The fix for a refusal is on the source: remove what is named (or upgrade it to a current release), take a new backup, and restore that.
The serving config is always regenerated from the platform's own templates,
in every case. On bare metal the site's vhost is rendered from the template for
the domain, as a converge renders it, and recorded so later converges keep it
current; in a container the internal virtualhost written at install time is
already correct and the public name is served by the host's proxy, which the
restore leaves as it is (manage_domain.sh on the host changes it). A container backup is missing the piece that
terminates TLS, and a bare-metal backup carries a piece a container must never
use, so neither direction can be handled by copying files.
When the captured virtualhost differs from the generated one it is kept beside
the live file as {site}.conf.from-backup and named in the output, so a
hand-added redirect or alias survives on disk without being applied unattended.
The certificate is never waited for. The reconcile arms
joinery-ssl-retry@{domain}.timer (arm_ssl_retry.sh) and disarms the old
domain's. That timer checks DNS every five minutes, does nothing until the
domain resolves to this server, then issues once and disables itself. The
<IfFile> guard on the :443 block means the site serves HTTP until then
rather than Apache refusing to start. Restore now, cut DNS later, certificate
arrives on its own.
How chains work
Each part's archive uses GNU tar's --listed-incremental against its own
snapshot file, {working dir}/.{slug}.data.snar and .{slug}.code.snar,
where the working directory is the profile's own. tar records each directory's
full contents, so
restoring replays deletions as well as additions — a file removed last
Tuesday is absent when you restore to Wednesday, rather than rising from the
dead.
The archive is taken from the live tree, not from a staging copy. This is not a preference: an rsync copy gives every file a new ctime, so tar sees the whole site as changed and every "incremental" silently becomes a full. Measured, and pinned by a test.
Archiving a live tree means a file can change while tar is reading it. That is tolerated — GNU tar reports it with exit status 1, the run notes it and carries on, and the file's settled version ships with the next run. It also means the file set has no single point-in-time: the database dump is taken minutes after the file archive, so a deploy landing mid-backup can leave the two slightly skewed. For a web tree this is the normal trade; restore the newest run if it matters.
The database is dumped in full on every run. A dump is the small part, and a half-applied database is not something anyone wants to restore.
A run starts a new chain when there is nothing to extend, when the chain's
runs went to another target than this run goes to (destination_changed: a
site that switched targets starts a full chain on the new one, and switching
back starts another rather than extending the old), when the chain is a
version-1 chain (layout_split: it is not extended by a run that archives code
and data apart, and its single .{slug}.snar is deleted), when a snapshot file
is missing or empty, when the chain is older than the configured interval
(measured with an hour's slack, so a weekly run on a fixed schedule rolls on the
seventh day even when the tick lands a few seconds earlier in the minute than
the run that started the chain), when one full is carrying more than 180 incrementals (a week of hourly runs), or when the chain's
envelope no longer opens with the site key (the site key is disposable; a chain
sealed to a lost one cannot be extended, only restored). Losing the snapshot —
or the local manifest — is therefore safe: the next run costs one extra full,
and never produces a broken backup. The manifest records why its chain started
(started_because).
An upgrade re-bases the code, not the chain. An upgrade deploys by moving
every directory in public_html/ out and the staged ones in; the new
directories can reuse inode numbers the snapshot recorded for other paths, and
tar would then record directory renames that no extraction can apply. So each
part's engine writes the identity of the tree it archived beside its snapshot
(.snar.tree) — for the code, the inodes of public_html and of each
directory directly inside it; for the data, the inodes of the site directory
and of each directory directly inside it that the data archive carries — and a
part whose tree differs from that record, or that finds no record, starts over
from a full inside the chain (its artifact records
rebased_because: tree_changed). The other part carries on incrementing, so
the night after a release uploads a code full (about 0.1 GB) and only what
changed in the data, not every upload again. A tree laid down fresh by a
restore re-bases the same way; an in-place restore keeps the data's inodes and
the data increments. A directory the data archive leaves out (vendor/,
cache/, …) can be recreated without re-basing anything.
The data archive leaves out public_html/, and every archive but the code
part leaves out an upgrade's own trees: public_html_* (its rollback and
failed trees), upgrades/ and upgrade_archives/ (its staging area, which
holds a whole code tree mid-upgrade, and its downloads) and deploy/ (a
container's deploy volume, which holds all of them). backup_project.sh
leaves the same trees out. They are the machine's: neither restore script
takes them from an archive, on any host (restore_project.sh sets them aside
from what it unpacked; restore_chain.sh never extracts them). Neither
upgrades/ nor upgrade_archives/ counts toward the data's identity: an
upgrade makes its staging anew. These are anchored to the site
directory, so a public_html deeper in the tree — a worktree under sync/,
say — is data and is archived. A code archive is rooted at public_html and
extracts into the site directory, after the data.
A run that fails partway puts every snapshot back as it stood before the
run: a snapshot advances while tar runs, before the run is committed to the
manifest and confirmed in the bucket, so carrying it past a failure would
quietly leave the failed run's changes out of the chain. Each snapshot is held
aside on disk before the engines run (.snar.held, or a .snar.none marker
when there was none) and restored on failure (a snapshot truncated by a tar
that died mid-run is healed the same way), so the next run is a correct
increment on the last committed run and a transient failure costs no extra
full. A run killed outright cannot undo itself; the next run finds what it held
— runs are serialized, so anything held belongs to a dead run — and puts it
back before deciding anything. If an upgrade lands while a run is incrementing the code, the run fails
and the next one re-bases the code. The failed run also removes what it made
and puts the manifest back to its pre-run state: the metadata artifact on disk,
and any object it had already streamed to backup storage where the credential
can delete (the site profile). A manager run deletes nothing: the broker
cancels what the failed run signed, and an already-streamed data or code object
stays until its chain is pruned whole — a bounded orphan the manifest never
names, written over by the next run that reuses its run number (a key a run
never finished is signed again for its retry). A local manifest describing a run the bucket
never received must not survive to be uploaded by anything later.
Runs are serialized with a lock in the working directory; a run that finds another in progress reports itself skipped rather than racing it for the snapshot and the manifest.
Retention over chains is atomic: a chain is kept or deleted whole. Deleting the oldest runs of a chain would leave incrementals whose full is gone, which is not a smaller backup — it is no backup, and it would look like a restore point right up until someone needed it.
Restoring a chain
php maintenance_scripts/sysadmin_tools/backup_envelope.php open \
--sidecar manifest-0007.json --manifest-sha256 {recorded hash} \
--private ~/recovery.key --key-out /tmp/k
bash maintenance_scripts/sysadmin_tools/restore_chain.sh {project} \
--artifacts {downloaded chain dir} --key-file /tmp/k \
--manifest-sha256 {recorded hash} [--seq N] [--domain d]Download the chain folder; the newest manifest-NNNN.json names every run, and
restore_chain.sh reads it (a version-2 chain's manifest.json likewise). The
manifest names every archive's size and hash, so the archives are only as good
as it is: backup_envelope.php open opens a chain manifest only against the
sha256 recorded when it was written — the management node shows it beside each
run on the node's Backups tab, and the run's ledger file in the bucket holds it
(Ledger files, below) — and restore_chain.sh checks it again and prints the
hash it used. A manifest whose hash differs is refused before anything is read.
Only for a bucket with neither record does --trust-bucket-manifest open one,
saying that nothing vouches for it.
Every artifact is checked against its recorded size and hash before anything
is written, so a truncated download fails while the live site is still intact.
--seq N restores as at run N; the default is the newest. --dry-run reports
the plan and needs no key. --domain names the domain the restored site is to
answer to; without it the site keeps the domain this machine's config already
names. --skip-ssl is passed to the reconcile, which then arms no certificate
retry.
Extraction replays the chain the way it was taken: anything in the target that
the archive does not list is removed, at any depth, including a directory the
files engine never archives (vendor/, say) when the source had none of that
name. A directory the source had but the archive left out (logs/) is kept.
A database dump does not open without its site's key. Every secret setting
(mail, payment and API credentials) and every other sealed value is sealed with
the secret_box_key of the site that wrote it, which lives in
config/Globalvars_site.php, not in the database. A dump loaded on a site with
another key restores those values unreadable: the site reads them as unset.
restore_database.sh checks the key canary after the load and says so
(RESTORE_SECRET_KEY_MISMATCH, beside RESTORE_OK); the remedy is the key
the dump was sealed with, which --adopt-secret-key below brings across.
--adopt-secret-key is for a chain from another machine. This machine keeps
its own config/Globalvars_site.php and takes the chain's secret_box_key into
it: read from the chain's copy of the file by pattern (never run), required to be
32 base64-encoded bytes, written with var_export, and linted before it replaces
the file, which keeps its owner and mode. Every value the source sealed then
opens here, and the database password, the paths and the rest stay this
machine's. It is refused before anything is written when this machine has no
config of its own, and after the files when the chain's config carries no usable
key (this machine's key is then left in place and the database is not loaded).
Offloaded files. The archives carry no file the site offloaded to its
file bucket; those are in backup storage under objects/{epoch}/, named by the
run's index (objects-NNNN.json.gz beside the run's archives). Download that
tree with the site's own credential and hand it over:
bash maintenance_scripts/sysadmin_tools/restore_chain.sh {project} \
--artifacts {downloaded chain dir} --key-file /tmp/k --objects {downloaded objects dir} \
[--objects-mode missing|all] [--epoch-key epoch-20260901_000000=/tmp/e]The step runs after the database is loaded, because the blob row is what says
where each file belongs and how big it is. It runs the restored tree's own
utils/restore_objects.php: each object is checked against the index's size
and hash before it is decrypted, decrypted into its placement, checked against
its row (size, and fbb_sha256 where the row records one), put in place, and
only then is the row set to local. missing (the default) brings home only
what the file bucket cannot serve — the bucket is HEADed per file — and all
brings every offloaded file home, for a site leaving its bucket. Nothing on
disk is overwritten (a file already at the placement is adopted when it matches
its row and refused by name when it does not) and nothing in any bucket is
deleted; running it again finishes what an interrupted run left. Variants are
regenerated on demand. Each epoch's envelope opens with the machine's own
backup_site_key; for an epoch it does not open, recover the key with
backup_envelope.php open --sidecar {epoch}/envelope.json --private … and pass
it as --epoch-key. The script can be run on its own, and --dry-run says
how many files would come home and from which epochs:
php public_html/utils/restore_objects.php --index {chain dir}/objects-0003.json.gz \
--objects {objects dir} [--mode missing|all] [--dry-run]From the dashboard: the node's Backups tab lists every run in the node's
backup storage, newest first, with the last backup, the last full backup and the oldest
backup held stated above the list — and, when a run in backup storage carries an
offloaded-files index, an Offloaded files row with Bring them back,
which runs the restore_objects loop below in missing mode against the
newest such run without restoring anything else; each row's Restore button
runs the restore_chain job for that run. Prepare (stage_chain) recovers the
chain key on the node from the node's own backup_site_key, so no recovery
private key travels in a job record. A chain another server made (the server
this site was on before a switch-over) does not open with this machine's key:
Prepare stages it without one (STAGE_KEY=recovery), and the restore's
approval opens it with the recovery key the operator pastes to approve, in
their browser (Approve it on the node, below). Once the restore completes, the run's offloaded files follow in missing
mode as the paged restore_objects jobs described under Restoring a managed
node below. A chain taken by a machine that no longer exists goes onto a new
one as a copy from backups (Handing a chain to a copy, below), or from a shell
with the recovery key, as above.
Checking a restore: the census
A restore onto another machine is checked by counting what each side holds and
comparing the counts. maintenance_scripts/sysadmin_tools/site_census.php (no
arguments, run as root so it can read every directory) prints one CENSUS=
line with a JSON object:
tables: the rows in every table (a table outsidepublicis namedschema.table);files: for each top-level directory of the site, the files, directories and bytes, and a digest over every path with its size, so a renamed file or two sizes that changed by amounts that cancel still show; loose files at the site root are counted under.;secrets: whether the key canary opens (ok,dead,absent, ornokeywhen the site has nosecret_box_key), and how many registered sealed secrets are stored and how many of them are dead, by where they live.
offloaded: total, sampled, answered, and
the names of any that did not answer).It reads and writes nothing (SecretReconciler::census() is the reconciler's
read-only pass). It counts what a backup chain carries and leaves out what each
machine keeps as its own: the names the files engine never archives, at any
depth, and the site's backup_exclude names (a pattern matched as tar matches
it: any tail of the path that starts at a name, * crossing /); public_html_*,
upgrades and upgrade_archives; the local copies of offloaded files; this machine's
own config, every file a restore keeps the machine's copy of
(_restore_guard.sh's MACHINE_CONFIG_FILES: Globalvars_site.php,
backup_site_key, the trust files, tree_owner, disk_allowance and the
host's code_check), and config/backup-ledger; and the rows of
bkh_backup_history, which a backup run writes after its dump. So a copy on a
host that refuses unsigned code still matches a source whose host does not. The
list lives in includes/SiteCensus.php, and tests/backups/site_census_test.php
holds its names equal to backup_files.sh's and holds every restore-guard
machine file in it.
SiteCensus::compare($source, $copy, $exact) lists every difference. A canary
that opens on the source and not on the copy, or more dead secrets on the copy,
always blocks. A difference in rows, files, bytes or stored secrets blocks only
when $exact is true, which is the right setting once the source has stopped
changing; while the source is live they are reported and do not block.
From a management node, the site_census job runs the same script over the
agent (read-only, so it runs on a dormant copy and a frozen source too), and
JobResultProcessor::census_of($job) returns the count it kept.
Handing a chain to a copy
A copy of a site on another machine applies its source's chain, which is sealed
to the source's site key and recorded in the source's ledger, not the copy's.
Three agent words carry it across (specs/site_copy.md), and the management
node that dispatches them can read none of it:
copy_export, on the source. The source's own operator approves it on the source's Backups page with the recovery key (theexportapproval scope), against a statement naming the copy by its agent key fingerprint. The source then requires the chain's local manifest to match its ledger, opens the chain data key with its ownconfig/backup_site_key, and seals the key, the manifest's hash (the vouch) and the host's certificate and DKIM keys to the copy's agent key, signed with its own.backup_site_keyitself never leaves. When both agents carry it (JobCommandBuilder::COPY_HISTORY_MIN_AGENT_VERSION), the export also lists the site's backup history: the newest manifest of each chain the source's ledger holds, by hash, size and upload time.copy_import, on the copy. It checks the signature by the source key its dormant install recorded, the target, the expiry and that the bundle is newer than the last one taken, then writeschain.keyintorestore_<chain_id>, the vouch to/etc/joinery/sites/<site>/vouched, the history tohistorybeside it, and the certificates.copy_stage, on the copy. It downloads the chain from the source's backup storage, keeps the manifest only when its hash is vouched for, and checks every artifact against it (CopyStaging). The ordinary staging, which trusts only this machine's own ledger and key, is not used. It also records in the copy's own upload ledger, marked handed over, the vouched manifest, every artifact it names, and the history's manifests (The upload ledger, below), so once the copy is the site, its backups from before the switch restore there through the ordinary staging; their key opens with the recovery key at the restore's approval. A copy that cannot write its ledger refuses to stage, by name.
copy_restore then runs restore_chain.sh --adopt-secret-key --skip-ssl over
that workspace.When the source is dead, nothing is left to export, and the recovery key is
the one holder of the chain's data key. This is how a dead machine's site is
restored onto a new one: a copy made from backups alone, then switched over
(the Copy tab, plugins/server_manager/docs/overview.md). The copy takes the key
with copy_take_key instead of an import.
First the copy reads the backup itself. The management node sends a link to the
chain's newest manifest, and the copy downloads it from the storage provider and
refuses it unless its bytes hash to the manifest named, it is that chain's, it
seals the very key the job asks to open to the very recovery key, and its newest
run is no later than the moment the provider stored it. That moment is the
provider's Last-Modified: no request sets or backdates it, so a chain made up
after the site's server died, by anyone holding the bucket's key, the management
node included, carries a date after it died. The copy believes the date only
from a provider's own storage host (Backblaze B2, Amazon S3, Cloudflare R2,
Wasabi, DigitalOcean Spaces, Linode, Hetzner): anywhere else whoever signs the
link could also run the server that answers it. The date proves when the backup
was stored, not that the site made it: a management node taken over while the
site was still running could have stored a chain of its own then, sealed to the
recovery public key, and its date would pass. Nor does the copy find the newest
chain itself: it reads the one it is sent, so an older genuine backup shows as
an older newest run, which is the owner's to notice.
Then the copy's own /copy-key page shows the chain, its newest run, when and
by which provider it was stored (or that the date could not be checked), its
manifest's hash and the recovery key's fingerprint. A backup stored two days or
more after its run is flagged: backups are stored as they run, so it was copied
or written again since. The owner pastes the recovery key there, and the
browser works out the X25519 of it with the ephemeral key of the recovery
recipient's sealed box. That value opens that one box and nothing else; the copy
finishes opening it (crypto_box's HSalsa20 key and BLAKE2b nonce) and writes
chain.key and the manifest as the vouch, exactly as copy_import would. The
recovery key never leaves the browser, and the page is the copy's own, so the
management node never sees the key or what it opens. No machine that made the
archive is left to vouch for it: the provider's date and the owner's reading of
it stand in for the source's vouch.
Key model: one envelope per backup
Every run mints its own random data key, encrypts the archive with it, and seals that key to two recipients:
- recovery — the site's own
backup_recovery_public_key, read from this site's settings. Both profiles seal to it: a management node's copies of this site open with the same key the site's own copies do, held by the same custodian. The private half lives in a password manager and never touches a server. A site holds only the public half, so the same key can be configured on any number of sites and one private key opens every backup from all of them — which is a choice each operator makes for their own sites, not something a management node can arrange from outside. - site — a keypair the site itself holds at
config/backup_site_key. This is what lets a site restore itself unattended: routine restores need no operator. It is disposable — lose it and the recovery key still opens everything, and the next run mints a new one.
The plaintext data key exists only as a 0600 file for the length of the run
and is destroyed before the run ends. It is never passed in argv, and on the
fleet path it never enters a management job row.
config/backup_site_key is pinned to 640 www-data:www-data by
fix_permissions.sh, and minted at the same mode. Backups run under more than
one account — the web user takes the scheduled run, the deploy account runs one
from a shell — and both are in the www-data group, so the key is group-readable
rather than owner-only. A key that exists but cannot be read is an error, never
treated as absent — minting over a live key would orphan the site recipient for
every backup already sealed to the first one.
Opening a backup with no Joinery anywhere
The format is deliberately stock crypto, so a backup opens on any machine with
openssl and PHP's sodium extension (or any libsodium binding) — no Joinery
code, no server, no network. You need three things: the encrypted archive, its
envelope (the {archive}.keys.json sidecar beside a standalone archive; for a
chain, the envelope object inside the chain's manifest JSON), and the
recovery private key from the password manager (base64, 32 bytes decoded).
Step 1 — unseal the data key from the envelope (each recipients[].sealed
entry is a libsodium sealed box over the data key; try each until one opens):
php -r '
$env = json_decode(file_get_contents($argv[1]), true);
$sk = base64_decode(trim(file_get_contents($argv[2])));
$kp = sodium_crypto_box_keypair_from_secretkey_and_publickey(
$sk, sodium_crypto_box_publickey_from_secretkey($sk));
foreach ($env["recipients"] as $r) {
$key = sodium_crypto_box_seal_open(base64_decode($r["sealed"]), $kp);
if ($key !== false) { file_put_contents("data_key.txt", $key); exit; }
}
fwrite(STDERR, "no recipient in this envelope opens with that key\n"); exit(1);
' {archive}.keys.json recovery_private.b64Step 2 — decrypt the archive (the data key is the passphrase; the cipher is
recorded in the envelope's cipher field, aes-256-cbc-pbkdf2):
openssl enc -d -aes-256-cbc -pbkdf2 -pass file:data_key.txt \
-in {archive}.tar.gz.enc -out {archive}.tar.gzThen shred data_key.txt. The same two steps work with the site key —
config/backup_site_key is the base64 of the raw sodium keypair, so in step 1
replace the two keypair lines with
$kp = base64_decode(trim(file_get_contents($argv[2]))); — and that is all the
platform's own restore path does. A chain restores by decrypting the full plus
each incremental with the one data key from the chain manifest's envelope and
applying them oldest-first. Check the manifest first — `sha256sum
manifest-NNNN.json` against the hash recorded when it was written (beside the
run on the management node, or in the run's ledger file in the bucket) — since
every archive is checked against the hashes it names.
Recovery key setup
Setup happens on the Backups page and needs no shell. The panel is rendered by
includes/RecoveryKeySetupPanel.php — one class for all four states
(unconfigured / invalid / unproven / ready), so every surface that
offers the setup offers the same one.
The default path is one screen:
- Generate. The page mints an X25519 keypair with WebCrypto
(
recoveryReadiness.generateKeypair()). The private half is shown once, with copy and download, and is never sent anywhere. The public half never appears on screen at all — it rides the API call in step 2. - Paste it back, one button. The operator pastes the private key back from
wherever they saved it; the page refuses a paste that does not match the
generated key, then one button drives two API actions:
backup_recovery_savestores the public half (unproven) and returns a challenge sealed to what was actually stored — re-read from the settings table, not echoed from the input — and the browser opens it with the pasted key (X25519 → HKDF-SHA256 → AES-256-GCM) and posts the recovered sentence tobackup_recovery_prove.
Generate-in-browser is the one setup path (a browser that cannot do X25519 is
told so plainly). A session that dies between save and proof lands on the
unproven state, which runs the same challenge ceremony with the saved private
key — in the browser, or via escrow_keypair.php unseal. A key generated at
the shell can still be installed by POSTing save_recovery_key (the
admin_backups handler), which stores it unproven into the same state.
Once a proven key and a scheduled target both exist, the nightly BackupRun
task switches itself on (BackupNightly::maybe_activate, called from the
setup-completing requests) — nightly backups are not a decision of their own.
escrow_keypair.php is the disaster-recovery tool — it runs on any machine
with PHP and libsodium, with no platform around it:
php maintenance_scripts/sysadmin_tools/escrow_keypair.php generate --private-out ~/recovery.key
php maintenance_scripts/sysadmin_tools/escrow_keypair.php unseal --private ~/recovery.keyThe encoding is one contract across all of them: both halves are the raw 32
bytes, base64, one line. tests/backups/recovery_key_encoding_test.php holds it
by running the shipped generator and checking libsodium agrees;
tests/backups/recovery_one_screen_flow_test.php holds the save → challenge →
prove sequence, and tests/backups/backup_nightly_test.php the activation
rules.
Replacing a proven key is a rotation, not an edit: backups already made carry keys sealed to the old public key. Pasting over a proven value is refused.
Rotating the key
Rotation is offered where the key lives: the Backups page's Recovery key section, Actions → Rotate key. It walks the same generate-and-verify ceremony as setup — new keypair made in the browser, private half into the password manager, pasted back to prove the stored copy — with the save marked as a deliberate rotation. Three properties make it safe:
- Old backups keep opening. Each chain's data key was sealed at chain start; rotation never touches it. Keep the old private key until every chain sealed to it has been retired. Offloaded files' epoch envelopes are re-sealed to the new key by the next run where the site key opens them; one it cannot open is named on Recovery Readiness as opening only with the retired key.
- Nothing seals to the new key until it is proven. An interrupted rotation leaves the key unproven, and backups refuse to run — loudly — until the ceremony is finished (or run again with a fresh key).
- The next run starts a fresh chain. A chain cannot change recipients
mid-life, so the runner ends the current chain when the recovery recipient no
longer matches (
recovery_rotated) and the new chain seals to the new key. - A restore never undoes it. A database restored from before a rotation
holds the key rotated away from.
restore_database.shreads the proven key the database holds before the load and writes it back after, saying so; an unproven key (none yet, or one mid-rotation) gives way to the backup's.
Only this site ever sets this site's key
backup_recovery_public_key is the key every backup of this site seals to,
whoever took it, and its custodian is whoever administers this site. Nothing
writes it from outside. Possession is proven here, against a challenge this site
issued: maintenance_scripts/sysadmin_tools/set_recovery_key.php reports what
this site holds and refuses to write it, and a management job that passes key
material is refused by the tool it passes it to.
An empty slot means this site takes no backups, for anybody. Not its own, and not a management node's copies of it — there is no key those could be sealed to, and an unencrypted whole-site archive in somebody else's backup storage is not a fallback. Set the key up at Admin → System → Backups: the page generates a keypair in the browser and runs the possession challenge in one pass, needing no management node and no shell. A management node managing this site can see that the slot is empty and say so on its dashboard — see Server Manager — and that is the whole of what it can do about it.
Uploads
Artifacts reach the bucket through S3Signer — hand-rolled SigV4 against any
S3-compatible endpoint, so the backup path carries no SDK dependency. It is
also the one presigner (S3Signer::presign(), every verb but DELETE) and the
one lister (S3Signer::list(), with an optional cap).
Providers are data. StorageProvider is the platform's only provider list:
Backblaze B2, Amazon S3, Linode, Cloudflare R2, Wasabi, DigitalOcean Spaces,
Hetzner and a generic S3-compatible service. Each entry is a label, which of
region and endpoint the form asks for, the endpoint pattern a region fills in,
the rule that reads the region out of an endpoint, the addressing style
(Amazon names the bucket in the host; the rest put it in the path) and the
console sign-in link. The target model's allowed providers, every target form,
the setup wizard, utils/install_backup_target.php and every label read it, so
another S3-compatible provider is one catalogue entry. Endpoints are stored in
one form, https://host (StorageProvider::normalise_endpoint(), on save).
BackupTarget::complete_credentials() fills what the provider decides: the
endpoint from the region where the provider names it that way, a fixed region
(R2's auto), and for Backblaze both, from Backblaze's own authorize answer at
save time (StorageProvider::b2_location(), the one Backblaze region rule; an
address the rule does not recognise is said in the save message).
get_credentials() fills a Backblaze row on read when it still lacks either,
writing the completed credential back once (a server-initiated reconciliation)
so the signer never sees an incomplete B2 credential.
BackupTarget::credential_problem() checks the provider's own fields before the
provider is asked anything: a provider that asks for the region needs one, a
region is a short name (us-east-1), never an address, and a Linode endpoint
is the cluster's bare https host (us-east-1.linodeobjects.com).
Streamed artifacts — the data and code archives, the standalone archive, the
database dump — go through S3Signer::put_stream(): an engine's stdout,
unknown length, never re-readable. The stream is read one part at a time,
hashed and counted as it goes. A stream that ends inside the first part is
sent as one signed PUT of the buffered bytes; anything longer opens a
multipart upload once the first full part is in hand, and each part is a
string signed with its real payload hash, so a retry re-sends the bytes it
holds. The runner asks for completion to be deferred: the parts go up (or
the small buffer is held) while the engine runs, and CompleteMultipartUpload
— or the single PUT — is issued only after the engine's report has been read.
A refused archive is aborted, and under the small-stream shape nothing was
ever sent, which is what lets a machine that cannot delete — a Managed node
writing through the broker — refuse an archive with no object to delete.
File artifacts — the metadata artifact, the chain manifest, an envelope
sidecar — go through put_file(). At 1 GiB or less that is one signed
streamed PUT; above it put_file() switches to the multipart API on its
own, with the source re-read from disk on a retry.
Parts are 100 MiB in both paths: each is held in memory, hashed, and signed
with its real payload hash, so the provider verifies every part's bytes
against the signature, and a failed part is retried on the same budget as any
other request. One part is also the peak memory cost, sized for the smallest
node; 100 MiB × the API's 10 000-part cap is about 1 TB per object.
CompleteMultipartUpload responses are checked by body, not just status —
a provider can answer HTTP 200 with an <Error> document, and that response
is retried and then surfaced as a failure, never recorded as a backup. Any
failure aborts the multipart upload so no partial object is left claimable;
because an abort can itself be lost (the process can die), the bucket should
carry a cancel-unfinished-multipart lifecycle rule (B2: cancel unfinished
large files after 7 days) as the backstop.
A delete is a delete on every bucket. A versioned bucket (every Backblaze
bucket, and any with versioning on) answers a delete that names no version by
hiding the object behind a delete marker, and keeps its bytes. So
S3Signer::delete() lists the key's versions and deletes each one and every
delete marker by id; a provider that keeps no versions and says so takes the
plain delete. Retention, Stored Backups, the offloaded-files prune and the
file store all delete through it.
What can still be hidden — a delete the provider cut off between two versions,
or one made some other way — drops out of every listing the pruners read, so
the passes that prune also sweep (HiddenVersionSweep): a site's backup run
sweeps its own folder on each target it prunes, the management node's fleet
pass sweeps the whole folder of each of its backup targets, and the offload
tick sweeps each file store's folder. A sweep lists the folder's versions and
deletes every version of each key whose newest entry is a delete marker,
never touching a key whose newest entry is a live object. Each folder is swept
daily, within a time budget; what a pass runs out of time for is taken up on
the next call, and what each folder last found is kept on the target row
(bkt_hidden_sweep). The pass's message says what a sweep removed.
sha256 and bytes are the hash and count of the bytes that went up — taken
from the stream by the process that pushed them, or from the local file for a
file artifact. A restore verifies against them and cannot tell how the object
was uploaded.
The upload ledger
Every artifact that reaches the bucket is also recorded on the machine that made
it, in config/backup-ledger/{profile}.json: the artifact's name relative to
its backup directory, its sha256, its size, and when it went up. BackupLedger
writes it at the moment of upload: record() hashes a file artifact from disk,
and record_hash() records a streamed artifact from the hash and count the
upload itself took — the same claim, this machine made these bytes, taken by
the process that pushed them from the same bytes.
It exists for one adversary: the party that chooses where a restore's bytes come from. When a management node runs this machine's backups it also owns the bucket and signs the download, and the person approving a restore approves a
name and a date — they never see the bytes. The ledger is the only thing on the machine able to say whether those bytes are the ones it uploaded under that name. Two attacks fail against it, and the second is the one that carries it:- Forgery — an artifact whose content is simply made up. The hash does not match.
- Replay — this machine's own genuine month-old archive, served under a fresh-looking name. Every signature verifies and every envelope opens, because it really is this machine's backup; sealing does not touch this at all. The name it is offered under has no record, so it is refused.
manifest.json, the name a
staged chain is checked by. Each run's manifest (manifest-{seq}.json in the
bucket) is recorded under it, so every run of a chain adds a version. The ledger
answers "did this machine make these bytes", not "are these the newest bytes it
made" — so a chain staged for restore is not refused because a scheduled backup
happened to land while somebody was reading the approval screen. What is
reported back is the version that matched, so the age shown on that screen is
the age of the bytes being restored.Two other kinds of line come in, each from a record the machine has already
checked, and each says how it came in (source):
- Handed over (
record_handed_over()). A site copied onto this machine keeps the backups its previous server made. That server lists the newest manifest of each chain from its own ledger, in the export it signs for the copy, and the copy's final manifest in the vouch it signs at the switch (
record_manifest_artifacts()). A manifest fixes the
name, size and hash of every artifact its runs made. Once a manifest's bytes
match the ledger, staging records the artifacts it names that the ledger
holds no line for, before fetching any of them. A name the ledger already
holds keeps its hash.config/ is a named volume on a container node, so the ledger survives
a container rebuild — a ledger under /var/lib would not, and since a ledger
only records what has been uploaded since, restore would stay broken for as
long as the current chain is old. And restore_project.sh drops
config/backup-ledger from a staged archive the same way it drops
Globalvars_site.php and backup_site_key: they are the machine's, not the
backup's, so the first restore cannot overwrite the record that vouches for the
second.A backup taken by an unprivileged process cannot write the ledger; the run reports that rather than hiding it, because an unledgered artifact is one the machine will refuse to restore from over the agent channel. On a managed node the backups that matter are taken by the root agent, so they are ledgered as a matter of course.
The ledger is 0700/0600, and both the platform and the agent refuse one
that group or other can write rather than reading it. Anything that can write
the ledger can vouch for any bytes it likes, so a loose ledger does not weaken
the check — it makes the check report success. The test is on the mode, not on
the owner: backups legitimately run as root on a managed node and as the site
user elsewhere. fix_permissions.sh pins the directory out of its sweep to
match.
Ledger files in the bucket
The machine's upload ledger and the management node's database are each one record of what a run's bytes are. Lose that record together with the machine that wrote the run, and nothing vouches for the bucket — so every run's record is in the bucket too, written when the run finishes by whoever owns the bucket:
{path_prefix}/{slug}/ledger/{run id}.jsonIt lists every object the run put in backup storage with its key, bytes and
sha256 — its archives, its manifest, its offloaded files — and the run's time,
profile and chain. A site writes its own runs' files with its own key (the run
id is its history row); a management node writes them for the runs its broker
took (ShelfBroker::writeLedgerFile(); the run id is the broker run), with its
key, at a folder no run's base key reaches, so the broker never signs a write
there. A file that could not be written is logged; the management node's
fleet pass writes any its broker runs still lack. A shell restore takes the
manifest's hash from here when nothing else holds it. Ledger files are kept
after their runs are pruned: each is a few kilobytes, and a record of a run
that is gone answers questions too. On a target without object lock a ledger
file is only as trustworthy as the target's key.
Where each run went
Every history row records where its run went (bkh_destination, never null):
bkh_destination | Meaning | Where it points |
|---|---|---|
local | kept on this machine only | nothing |
target | one of this site's own targets, used directly | bkh_bkt_backup_target_id |
service | a management node's storage (a Managed run) | bkh_remote_run_id, the broker run; bkh_remote_space_id, its storage space; bkh_target_name for display |
BackupHistory::stored_target(), BackupRunner::plan_for_run()):
retention deletes each run from its own bucket, a verify signs its links there,
and Bring them back reads the run's index and files there. A target holding
runs cannot be deleted, so a row's target is always reachable.Switching targets moves nothing. The next run starts a full chain on the new target; the old runs stay listable, verifiable and restorable where they are, and retention prunes them there by the same window until the old target holds nothing. Offloaded files are stored per target: the first run on a new target stores every one again, and puts there the envelope of the epoch they are sealed under, so each target's objects open from that target alone. Object retention runs per target too: an object is removed from a target when no kept run on that target names it.
A service run goes through the management node's broker, which takes it in
this site's active storage space there and says which. A run the broker takes in
another space starts a new chain (destination_changed): a management node that
moves a site to another target gets a full backup there on the next run, and the
old chain is never extended into the new bucket — the broker also refuses to
sign a write into a chain whose objects are in a space the site was moved away
from. On the management node each site's backups
are in its storage space on a target, and a move drains the old space until
its backups age out (Server Manager § Storage spaces).
Retention
- Cloud — keep
backup_retention_daysdays of restore points (default 28): every restore point that started inside the window, and the newest one that started before it, which is what a restore to the window's first day replays from. The window is days rather than a count because chains differ in length (the configured interval, a key rotation, a lost snapshot), so a count of chains covers an unpredictable span. The rule isBackupRunner::surplus(), shared with the management node's retention. Older restore points are deleted as What retention will not delete allows, driven by this site's own run history rather than by a bucket listing, so it can only ever delete objects this site recorded writing. Retention runs last in a backup, and only after an upload is confirmed: a run that failed must never be the run that decides an older backup is surplus.
objects/ are a third family, deleted
only when no retained run's index names them (Offloaded files in backup
storage).
- A management node's copies — kept by the same setting. The site decides
how long; the management node deletes, because its broker signs a managed
site no delete (a site that could erase its own offsite copies would lose them
to the first intruder). Every manager-profile run prints
BACKUP_KEEP_DAYS=with the site's window; the management node stores it and prunes by it, never keeping fewer days than its own minimum (see the server_manager overview). The run removes this site's records of manager runs outside the window by the same rule — records only, never objects — so the Backups page lists what is kept. A managed site sets the window in the How long backups are kept box on its Backups page. - Local — keep M days in
/backups(default 7). What a run leaves on this disk is small: the chain's metadata artifact, and a standalone run's envelope sidecar. The archives and the dumps stream to the bucket and are never here. This window says how long those leftovers are kept. Age is per file, so an old chain's early runs go while its recent runs stay, and the emptied chain directory is left for chain retention to retire. A chain's localmanifest.jsonand the snapshot beside it are never swept — they are what make the chain extendable, and without either the next run silently starts a fresh full. The sweep also removes theauto_pre_*snapshots a restore leaves behind, which are the size of a full backup. An archive and its envelope are always swept together.0means never. Delete the local copy once uploaded is on by default: a run removes its metadata artifact (and a standalone run its sidecar) as soon as they are confirmed offsite instead of waiting out the window; a restore or a verify fetches from the bucket either way. Turned off, the leftovers stay until the window ages them out.
A backup file written after the newest run of its own kind that finished uploading is kept whatever its age: while uploads are failing it may be the only copy. A chain's files are judged by chain runs, a standalone archive by standalone runs of its type, so a database run uploading never vouches for a project archive that did not. A failed run records no files, so this goes by time — everything older either went offsite with its run or belongs to one a newer off-site run of the same kind supersedes.
What retention will not delete
Retention picks what is surplus, by age on a site and for a Managed node, by
count for a customer of backup storage. BackupSafety decides which of that
may go, for every pruner alike — a site's own retention and the management
node's prune of its nodes and its customers:
- The newest verified backup stays, and everything newer. A restore point a verify passed (a pass on any of its runs) is kept however old, with every point newer than it, until a newer one passes. Backups that upload fine but do not open can never age the last good one out; verification is weekly by default for that reason, and storage grows while nothing passes.
- The newest backup stays, whatever the rule says.
- Nothing goes the first time it is surplus. A pass that finds a point
surplus records when —
bkh_surplus_timeon its rows on a site,sps_surpluson its storage space on the management node — and deletes nothing. A later pass at least 20 hours on (BackupSafety::CONFIRM_HOURS) that still finds it surplus deletes it; a pass that keeps it clears the record, so a point that becomes surplus again waits again. A wrong rule shows as a day of waiting deletions before anything goes.
The site's Backups page says when no backup of its own has finished going
offsite for two nights, and when none has passed verification for a day past
the verify interval (BackupSafety::site_warnings(); nothing before the
site's first run is that old). The management node raises the same two as
incidents for its nodes (plane:backups_stopped, plane:backup_unverified)
and for its customers of backup storage (plane:customer_backups).
Restoring
On the machine itself, nothing extra is needed — the envelope sits beside the archive and opens with the site's own key:
bash maintenance_scripts/sysadmin_tools/restore_project.sh {project} /backups/{archive}From the bucket, with only the recovery key:
php maintenance_scripts/sysadmin_tools/backup_envelope.php open \
--sidecar {archive}.keys.json --private ~/recovery.key --key-out /tmp/k
bash maintenance_scripts/sysadmin_tools/restore_project.sh {project} {archive} --key-file /tmp/krestore_project.sh decides whether an archive is encrypted by reading the
openssl magic bytes, not the filename, so a renamed archive still restores. It
takes --domain to name the domain the restored site is to answer to; without
it the site keeps the domain this machine's config already names.
Nothing is saved before a restore. The schema is dropped and replaced, and the state that was there is not kept anywhere. A restore happens because the current state is wrong, so preserving it preserves what the operator has decided to discard — and it kept a full copy of the database, per restore, indefinitely, on a disk sized for backups rather than for regret. The approval an operator answers already says in words that anything written since the archive was taken is gone.
What that gives up, stated plainly: a load that fails part way leaves the schema
already replaced and nothing on the machine to put back
(RESTORE_LOAD_FAILED). The answer is the archive itself, which is still in
backup storage and can be retried, or an earlier one.
The target's PostgreSQL must be at least as new as the source's. A dump carries
the syntax of the version that wrote it, so the restore reads that version from
the dump header and refuses before replacing the schema when the target is
older, reporting RESTORE_SERVER_TOO_OLD with the database untouched. Restoring
onto a newer PostgreSQL is ordinary and needs nothing.
A dump carries each object's owner and grants but not the roles they name, since
roles belong to the server rather than to one database. Before replacing the
schema, the restore reads every role the dump names and creates any the target
lacks, unable to log in: the role gets exactly what the dump grants it. The log
names each one created. A dump holds no password, so something that logs in as
such a role needs its login and password set again on the target. The mailbox
plugin's installer does this for its own iemap_* role on every run. A
--db-user that may not create roles is refused as RESTORE_ROLE_MISSING, with
the database untouched.
A restore lands on an installed site. A backup carries the whole site tree,
config/ included, encrypted with everything else — so the source machine's
config/Globalvars_site.php (its database password and secret_box_key) and its
config/backup_site_key are inside it. A restore puts the target machine's own
copies of those two files back over what the backup brought, because they belong
to the machine rather than to the site's history: one is the password its
PostgreSQL accepts and the key its secrets at rest are sealed under, the other is
its identity as a recipient of its own backups. So the sequence for new hardware
is: install the site, then restore onto it. See
Deploy and Upgrade.
Bucket credentials plus the password-manager private key are sufficient to recover from total loss of the machine.
Restoring a managed node from its management node
A managed node is restored over the agent channel, and it takes three steps because they are three genuinely different decisions.
Bring the backup back. Every node deletes its local archive once it is safely uploaded, so the normal state of a machine is that its backups are all offsite — and a restore takes the name of a file it expects to find in its own backup directory.
Bring back to node on the node's Backups tab (or Prepare, for a chain) fetches it. No bucket credential is sent: the management node signs one object key, for no longer than the job's own claim budget, and the node receives the signature. A node holds no bucket credential at all — it writes through the broker, which signs it writes and nothing else — because a node that could read backup storage is a node whose compromise reaches every other node's backups. Everything fetched is checked against the node's own upload ledger before it lands, and lands0600 — created that way, not chmod'd
afterwards, because on a container node the backup directory is inside the site
tree and a descriptor opened during a multi-gigabyte transfer stays open. The
transfer is capped at the size the ledger recorded, so a response that declines
to say how big it is cannot run the node's disk to zero; and a failed download
reports its HTTP status without its body, because the plane picks the URL and
reads the transcript.Ask for the restore. The management node dispatches it as a primitive. It sends a
name and a profile — no path, no key, no bucket, no domain. A chain restore also carries a project, and the node treats it as a claim to check rather than an instruction:restore_chain.sh spends that value twice, on the
tree it replaces and on the database name it loads over, so the node uses its
own project and refuses a job naming any other.Approve it on the node. The node's agent claims the job and runs nothing. It composes its own statement of what it would do — which project, which database, which archive, the archive's real age, size and fingerprint — from its own records, seals a one-time challenge to the backup recovery public key it already holds, binds it to that job and that statement, and stages it for the node's own site. The node's Backups page shows the pending approval; the operator opens the challenge there with their recovery key, in their browser, and answers. The agent verifies the answer against what it sealed and only then restores.
A chain staged without a key (another server made it) carries its key box in
the statement: the recovery recipient's ephemeral public key and the recovery
key's fingerprint, bound into the challenge with the rest. The statement says
the backup opens only with the recovery key, and names the server before the
switch-over as its maker when the ledger line was handed over. The page works
out, from the same pasted key, the X25519 of that key with the box's ephemeral
key (recovery-readiness.js openKeyBox), the one value that opens that box,
and posts it with the answer; a backup sealed to an earlier recovery key asks
for that key in a second box, by its fingerprint. The agent finishes opening
the box as copy_take_key does, writes the chain key where Prepare would have,
and only then restores. A right answer without the value, or a value that does
not open the box, refuses the restore.
Nothing else writes while it loads. restore_database.sh renames the
database {name}__restoring for the load, so a page or a scheduled task that
asks for it by name finds nothing and writes nothing: a row written mid-load
collides with the dump's own and stops the load after the schema is dropped.
It is renamed back once loaded, or as it is when the load fails. A work
database left by a restore that was killed is the one replaced when nothing has
the site's name; with both present the restore refuses (RESTORE_DB_BUSY) and
touches nothing.
A chain restore finishes with its own release's tools. Its extraction puts
back the maintenance_scripts/ the backup carried, which for an older run are
older tools. restore_chain.sh copies this release's sysadmin_tools/ and the
install_tools/ scripts aside before extracting, and runs the database restore
and reconcile_site.sh from that copy.
What a database restore leaves behind, and what it does not roll back. A
database restore replaces the database and nothing else, so a dump taken before
an upgrade lands under files that are still on the newer release: the site's
recorded system_version and its settings go back to the older value while the
code on disk stays put. This is self-correcting — the next upgrade re-runs the
migrations, which are idempotent — but it means the version a freshly restored
node reports from its database can trail its files until then. A restore also leaves the
archive it was given in the backup directory; the local sweep expires it on the
ordinary keep_local_days schedule.
A run records itself as running before it dumps the database, so every dump
holds the run that took it as running. After the load, restore_database.sh
settles every such row, since none is running on this machine: the run a chain
restore names (--restored-run, which restore_chain.sh reads from the
manifest) is recorded as finished, with its chain, run, size and time, because
its archive is the one just loaded; it is the newest row running at or before
that time. Any other running row is marked never finished.
Bring the offloaded files home. The archives carry no file the site
offloaded to its file bucket, so once the chain restore completes the plane
brings those home too, in missing mode — only what the file bucket cannot
serve. The node never lists its backup storage and holds no read credential, so every
object arrives by a link the plane signs, and a job is bounded, so the work is
paged and the plane drives the loop (FleetObjectRestore): a survey job
(the restore_objects primitive with the run's index link and no object
links) has the node read the index — ledger-checked like every artifact —
HEAD its file bucket per offloaded file, and answer with the names it would
bring home (RESTORE_OBJECTS_WANT, at most a thousand, RESTORE_OBJECTS_MORE
when there are more). The plane signs a page of links from that answer —
object links plus the envelope of each epoch those objects are sealed under,
filled to the job's byte ceiling and never more than 150 — and each page's
result issues the next, so no link waits in a queue past its expiry. When the
pages are done and the survey said there was more, a fresh survey names the
next thousand; a survey that names nothing ends the loop. The node opens each
envelope with its own key, fetches objects one at a time, checks each against
the index before decrypting it and against its row after, and sets the row to
local only once the file is in place. restore_objects is an operate
primitive: it overwrites nothing and deletes nothing in any bucket, so no page
needs an approval. A failed page ends the loop with its reason on its own job;
a restore whose result is read long after it finished starts none. The job's
record names the survey it pages and its slice, and the survey's result holds
the names, so a store of ten thousand files is stored once, not once per page.
The management node is not in that path at all — not as a gate, and not as a relay. The challenge and the answer live entirely between the node's own site and its own agent, and the restore vocabulary declares no parameter through which an approval answer could travel, so relaying one is impossible by wire format rather than by care. A management node can dispatch a restore and can do nothing whatsoever to get it approved.
The costs are deliberate and worth stating. Restoring in place requires the node's site to be up. A node whose site will not boot goes onto a new machine as a copy from backups (
Handing a chain to a copy, above), where the owner opens the backup with the recovery key on the new machine's own page, or by hand: a fresh site installed on a new machine, then the backup restored onto it from a shell with the recovery key (Rebuilding a site on new hardware). No approval is asked on either path, because holding the recovery key is the proof. An unanswered challenge expires and the job is refused, so a restore nobody is watching fails rather than pinning the node. And a support-driven restore requires the customer to be reachable: there is no unattended destructive path, including for us.The archive's age is on the approval screen as an age, above the key box, because it is the one fact no automatic check can substitute for — a replayed archive is genuine, signed and openable, and only its date is wrong.
Verifying backups
A backup that has never been opened is a hope. Backup storage listing proves a backup is
present;restore_chain.sh --dry-run proves it is intact against its
manifest; Prepare proves the key this machine holds opens its envelope.
Verification is the step that proves a backup is recoverable, without
restoring it, and records the proof where people look: the site's own Backups
page, the node's Backups tab on its management node, and the node's card on the
management dashboard. The word every page uses is verified restorable, with
a date, and the level it was proven at:| Level | Where it runs | What it does | What it proves | Cost |
|---|---|---|---|---|
| Checked in backup storage | The management node, on every retention listing | Every artifact each backup's manifest names is in the bucket listing at the recorded size, and the manifest carries its envelope. Every offloaded file each restore point's index (every run of every chain still held, and each standalone full's index) marks stored is in the listing under objects/ at its recorded encrypted size, and its epoch's envelope is there; a problem names the restore points that cannot bring a file back, by time | Present and complete | The listing the pass already takes, plus one small read per backup and one per restore point's index. Seconds, no disk |
| Opened and read | The node (or the site itself), on a schedule | The set a restore of the run needs — the full, every incremental up to it, that run's database dump, metadata and offloaded-files index — is downloaded, checked against the manifest's sizes and hashes, decrypted with the machine's own key and read to the end: tar -tz for the archives, a full decompression and a header check for the dump. Every epoch envelope the index names is fetched and opened with the machine's own key; no request is made per offloaded file, because backup storage listing already proved each one present at its size | Decryptable and structurally sound with the key this machine holds, offloaded files included | Downloads the whole set once; disk equal to the set, freed at the end; a minute or three |
| Rehearsed | The node (or the site itself), only when a person asks | Opened and read, then the files are replayed into a scratch directory under the backup working area and the dump is loaded into a throwaway database on the machine's own PostgreSQL; what came back is counted (files, bytes, tables, rows in usr_users and the three largest tables) and both are deleted. A sample of the offloaded files — the 5 largest and 15 drawn at random — is downloaded, checked against the index's hash, decrypted with its epoch key and compared to the rehearsed database's row for it: fbb_sha256 where the row records one, fbb_size_bytes otherwise | Recoverable | The set plus the sample plus roughly twice the site plus the database, freed at the end; a few minutes |
What no level proves, and how the ceremony closes it. No level accepts a
private key — for the same reason stage_chain.php refuses one, a key on the
wire is a key in every job record — so none of them exercises the recovery
private key an operator holds. That path is proven by the recovery-key ceremony
on the Recovery Readiness page, which shows the private key opens a challenge
sealed the way every envelope is. The two together are the proof: the ceremony
shows the key opens an envelope, verification shows the envelope's contents are
sound. The Backups page shows the two dates side by side for that reason.
The node's history row is the authority. A verify stamps the run it opened
(bkh_verify_time, bkh_verify_level 2 or 3, bkh_verify_outcome pass or
fail, bkh_verify_message in plain words) whichever profile took the run. A
verify that was
mgn_backup_verify_*) is refreshed from the job's result lines and from the
node's status report, so a verify the site ran itself is seen from the
management node too.A failed verify is surfaced exactly like a failed backup, in the node's own words, and never triggers anything automatic — not a re-run, not a deletion, not a fresh full. A failing verify is a person's problem to look at.
The engine
utils/verify_backup.php is the script a node runs (as the verify_backup
agent primitive, or from the site's own scheduled task and Backups page). It
takes JSON on stdin and nothing on argv: the chain, the profile, the level, a
signed link to the manifest and a signed link to every object under the chain,
keyed by bare name, optionally the run, and — when the run carries offloaded
files — a signed link per epoch envelope the run's index names
(epoch_envelope_urls, keyed by epoch id) and, for a rehearsal, the sample
(object_urls, keyed by the object's name in the index, at most 20). Whoever
signs the links reads the run's index from backup storage to pick them: the site's
own launcher (BackupVerifyLauncher::object_links()) and the management
node's builder alike. Everything about
BackupStaging's, shared with Prepare, so a verify can never fetch
something a Prepare would refuse: the manifest is read on the machine, the
artifact list comes from BackupChain::restore_plan(), every fetch is checked
against the upload ledger, and the chain key is recovered from the machine's
own config/backup_site_key. Offloaded files are not ledgered — their index
is — so BackupStaging::fetch_objects() checks each sampled object against the
index's recorded size and hash instead, and lands it under
objects/{epoch}/ in the working directory. BackupVerifier is the level 2
and 3 engine over an already-staged directory (read_all, rehearse,
disk_needed, sample_objects). A management node sends the two link maps
with every verify of a run whose index names offloaded files;
a run that carries offloaded files but whose request links no envelope for an
epoch they need fails by name, because the objects under it cannot be opened
here.The script works in {site}/backups/{profile}/verify-{pid}/, under the same
two locks as a backup run (so it never reads a chain a run is writing), checks
free disk before downloading anything (the set for level 2; the set plus twice
each tree archive's full plus three times the dump for level 3), and removes
its working directory, scratch tree and throwaway database on every exit path,
including a fatal. The local sweep removes any verify-* directory older than
a day that a killed process left behind.
It answers with one key per line:
VERIFY_RESULT=pass|fail|skipped
VERIFY_LEVEL=2|3
VERIFY_RUN=<chain_id>/<seq>
VERIFY_RUN_TIME=<manifest run time, UTC>
VERIFY_ARTIFACTS=<n read>
VERIFY_BYTES=<bytes read>
VERIFY_FILES=<entries listed (2) or files restored (3)>
VERIFY_OBJECTS=<offloaded files proven recoverable: stored, epoch envelope opened>
VERIFY_OBJECT_BYTES=<their bytes in backup storage>
VERIFY_OBJECTS_SAMPLED=<n opened and compared> (level 3 only)
VERIFY_TABLES=<n> (level 3 only)
VERIFY_ROWS=usr_users:<n>,<table>:<n>,… (level 3 only)
VERIFY_DURATION=<seconds>
VERIFY_REASON=<one line> (fail or skipped only)
VERIFY_NEEDS_BYTES=<n> (skipped for disk only)
VERIFY_FREE_BYTES=<n> (skipped for disk only)Exit 0 on pass or skipped, 1 on fail, 2 on a request it could not understand. A
skip's reason is disk (with both numbers, so the card can say "needs N free,
has M"), createdb, or busy. An object retention deleted from under a verify
in flight fails with reason gone, naming the object — an archive, an epoch
envelope or a sampled offloaded file alike; the next pass verifies the newer
backup, and the retention pass never holds a deletion for a verify in flight.
What keeps a backup is a verify that passed (What retention will not
delete).
The site's own backups
A site verifies its own backups the same way: the Backup verification
scheduled task (tasks/BackupVerify.php, switched on together with Backup
by BackupNightly) opens and reads the site's newest own backup every
backup_verify_every_days (7 by default; 0 never), signing the links from
the site's own target through S3Signer::presign_get — no credential reaches
the script. It is due when the interval has passed since the last verify (pass
or fail) and a newer backup exists, or when nothing has ever been verified. A
site that takes no backups of its own has nothing to verify and the task says
so, once, as a skip. The Backups page's Verify the newest backup button
runs the same thing on demand, and Rehearse a restore runs level 3; both
run in the background like
BackupVerifyLauncher::detach), and the result lands on the run's row under
Recent backups and in the Status box's Last verified restorable line. A
verify that proves nothing either way — skipped for disk or a busy machine,
or refused before it read anything — leaves its reason as the run's message
with no outcome (BackupVerifier::stamp_history / note_history); the row
says not verified with the reason, or since then: beside a proof that
still stands, and the Status box says Last attempt: when it is about a
backup no older than the last proof.BackupVerifyLauncher is the site side (newest_run, due, request, run,
start).
The file store is checked too
Verification proves backup storage holds what the index says. The other side — does the
file bucket still hold every offloaded file it is serving? — is asked daily by the offload tick (CloudStoreInventory, docs/cloud_storage.md
§ The file store is checked daily): every cloud blob is HEADed in its
bucket, a slice per tick, and a file the bucket lacks or holds at the wrong
size is written down by name. The Backups page's Offloaded files box and
the cloud-storage page's Status block both say when it last looked and, when
anything is missing, "N offloaded files are missing from the file store; the
backup holds M of them" — M counted against the enabled profiles' held sets —
with Bring them back beside it:- A site with a backup target of its own runs it here, in the background
(
BackupObjectRestoreLauncher::start_newest()→utils/bring_back_objects.php, the request on stdin as a verify's is). The launcher reads the newest run's offloaded-files index off the site's own backup storage, surveys it inmissingmode (the bucket isHEADed per file, so a file the bucket has recovered by then is left alone), and brings the rest home a page at a time — each page's object and envelope links signed here with the site's own credential (S3Signer::presign_get) and opened with its own key; the engine is the sameBackupObjectRestorethe node path runs. One at a time, by a lock in the profile'sobjects/directory. What it did lands on the inventory record, both pages read it, and the names it brought home leave the missing list at once. - A site backed up only by its management node holds no backup storage credential,
so the box names the management node and its URL: from that node's Backups
tab, Bring them back (the
restore_objectsbackup action →FleetObjectRestore::start()) runs the paged survey-and-page loop described under
missing mode, against the newest run
in backup storage that carries an offloaded-files index. The management node does
not see the node's missing list; the survey job asks the node.php public_html/utils/bring_back_objects.php --chain chain-20260912_044520 --seq 3 [--mode missing|all]Verifying by hand, with the recovery key
maintenance_scripts/sysadmin_tools/verify_backup.sh drives the same engine
over a chain directory an operator downloaded by hand, with a key the operator
recovered. This is the
# 1. Download the chain's whole directory from the bucket to DIR
# (its manifests and every artifact they name).
# 2. Recover the chain key with the recovery private key, against the newest
# manifest's recorded sha256 (Restoring a chain, above).
php backup_envelope.php open --sidecar DIR/manifest-NNNN.json --manifest-sha256 HASH \
--private /path/to/recovery.key --key-out /tmp/chain.key
# 3. Open and read (level 2), or rehearse a restore (level 3).
./verify_backup.sh --artifacts DIR --key-file /tmp/chain.key --level 2
./verify_backup.sh --artifacts DIR --key-file /tmp/chain.key --level 3 [--seq N] [--project NAME]
# 4. Shred the key file.
shred -u /tmp/chain.keyIt runs from a site's maintenance_scripts/sysadmin_tools (the engine is PHP
in the public_html beside it, and a rehearsal uses restore_chain.sh and
restore_database.sh from the same directory), prints the same VERIFY_*
report, leaves the chain directory as it was (a rehearsal's scratch/ is
removed), and exits 0 on pass or skipped, 1 on fail, 2 on a request it could
not understand. --project names the directory the archive carries, for a
rehearsal; left out it is read from the archive, and a wrong name is refused.
This path holds a chain key, not the site key, so it proves the archives and
reports the run's offloaded files unproven (VERIFY_OBJECTS=0); those are
proven by the fetching path, which opens their epoch envelopes with the site
key.
Disk and egress
Opened-and-read downloads the whole set each time; on Backblaze that is egress. At the monthly default on a 1 GB set it is inside the free allowance for a fleet of nodes; weekly across nine nodes would still be under 40 GB a month. A rehearsal needs scratch disk of roughly twice the site plus the database, and a PostgreSQL role that can create a database. Both are checked before anything is downloaded, and the machine says the numbers when it declines.
A backup needs no scratch disk beyond the site itself. Every archive and every dump streams from its engine into the bucket; a chain run's peak disk is the live tree plus the metadata artifact, a database-only run's is nothing, and a standalone whole-site run's is the live tree plus its compressed dump for the length of the tar. Memory is one upload part, 100 MiB. The double-space need is a restore's and a verify's, not a backup's.
The node tool
maintenance_scripts/sysadmin_tools/backup_envelope.php reads a backup's key
back. open, relabel and site-key are deliberately standalone — no platform
bootstrap — so they work during disaster recovery when the site will not boot,
which is the moment that matters. mint runs only on a live site and does read
its settings, because it must know which recovery key that site holds: nobody
hands it one, and --recovery-pub is refused.
| Command | What it does |
|---|---|
mint | Mints a data key, seals it to this site's own verified recovery key, writes the key file and the envelope |
open | Recovers the data key from an envelope or manifest, given a recovery or site key |
relabel | Points an envelope at the archive it belongs to |
site-key | Prints this site's public key, minting the keypair if absent |
backup_files.sh archives the file tree, incrementally when given a snapshot
path; --part data or --part code archives one part, and without it the whole
site directory is one archive. restore_chain.sh applies a chain in order. Both take an explicit
directory override so the incremental and deletion-replay behaviour is tested
against a throwaway tree rather than a live site.Three guards keep a tree archive honest:
- Elevation is a rule, not a credential. An unprivileged run uses
sudoonly whensudo -n -lshows aNOPASSWD: ALLrule. An account holding one narrow rule can validate and still be refused the real command, and a refusedsudo tarexits 1 with no output — the same status tar gives for a file that changed while being read. Listing the rules sends none of the mail an attempt does.tests/backups/backup_files_sudo_gate.shholds every shape. - An archive of nothing is never a backup. Fewer than 64 bytes is refused and deleted whatever tar's exit status said: an envelope around an empty stream is 32 bytes, and a gzipped tar holding even one entry is longer.
- A full a tenth the size of the previous full is said out loud. The run
is kept — the archive is real — but its history row, the task message and
the
BACKUP_WARNINGline a management node reads all carry the two sizes. It compares data fulls (a version-1 run's files full counts as one), because the data is the part a backup of nothing would shrink. The comparison is with the previous full only, so a site that really did shrink is flagged once and then measured against its new size (BackupRunner::full_size_warning()).
BackupRunner::local_need()
adds those up — each current snapshot twice (it is held aside while its engine
runs) and the manifest by size, the small files as a fixed 64 MiB — and the run
refuses when free space is under that need plus a
fifth, plus 1 GiB kept free on top. The refusal names both figures ("Not
started: this run needs about 1.1 GB on disk and 800 MB is free.") and, when the
local chain manifest knows it, the size the tree archives are expected to be.
It is thrown before a chain is minted, a snapshot cleared or a key file
written, so a refused run leaves the chain exactly as it was, and it is
recorded as a failed run like any other. A run that streams essentially never
refuses; the check exists so the one that would take the last of the disk says
so first. When the filesystem cannot report its free space the run proceeds.A run says its level and size as numbers. utils/run_backup.php prints a
small contract after its human line: BACKUP_RESULT, BACKUP_TIME, and — on a
success — BACKUP_LEVEL (0 for a full, 1 for an incremental; always 0 for a
standalone archive) and BACKUP_BYTES (the run's whole size — every artifact it put
in backup storage, the database included), then
BACKUP_WARNING when there is one. A management node stores the level and
size on the job result, so a run's size is a field and never prose to parse.
One file is left out of an unprivileged run on purpose, and announced: the
release signing key on a publishing box, config/agent_signing_key, is
readable by root only. The root-run manager backup of that box carries it. Any
other unreadable file fails the run rather than being skipped.
| Script | What it does |
|---|---|
reconcile_site.sh | A site's shape, read both ways: --print-shape records it for a backup, the default mode makes a restored site agree with the machine it landed on |
verify_backup.sh | Proves a downloaded chain restorable with a key the operator recovered — the one path that exercises the recovery private key; see Verifying by hand |
arm_ssl_retry.sh | Arms (or disarms) the DNS-gated certificate retry for a domain |
includes/BackupEnvelope.php reads and writes the same format;
tests/backups/backup_envelope_cli_test.php holds both to that contract in
both directions, because drift there is silent and only surfaces at disaster
time.Scheduling
The Backup scheduled task (tasks/BackupRun.php) runs this site's own
backups — it is pinned to the site profile, so a management node's copies can never
be started by editing a row in this site's task table. It is not active
on install: a site with no target configured runs nothing and warns about
nothing. It switches itself on once a scheduled target and a proven recovery
key both exist (see Recovery key setup);
tasks/BackupVerify.php) is switched on with it and proves the newest backup
restorable on its own interval — see Verifying backups.A run is recorded in bkh_backup_history before it starts and updated when it
finishes — including when it fails. Every row carries bkh_profile (whose backup
it was) and bkh_recovery_fpr (which private key opens it), so a restore never
has to infer from today's settings what was true when the archive was made. A site whose backups have been failing for a
month looks identical to a healthy one if only successes are written down.
A failed site run is an admin notice. From the first failure, every admin
page (superadmin) carries the notice (includes/SiteBackupNotice.php, a core
AdminNotices renderer): when the run failed, which target, and the engine's
last line (bkh_message, colour codes stripped, the last |-joined segment).
It reads the newest site-profile row; a run still recorded as running is not a
failure, and a manager-profile row never speaks for the site's own backup. The
next site-profile success clears it. A site that has never configured a target
has no row and hears nothing.
A row still marked running whose start is more than six hours old
(SiteBackupNotice::STALE_RUN_HOURS) is a run that died without writing its
end — the kernel killed it, or it lost its database and could not get it back.
The notice names it as
BackupRunner::fail() asks
DbConnector::reconnect() for a fresh connection once and saves again, because
the failure that ended the run (a full disk, a PostgreSQL restart) is often the
one that killed the connection. reconnect() rebuilds the current mode's link
from the same settings; it is for a long-running CLI process, and nothing that
runs inside a web request calls it.Because manager-profile rows land in the site's own database, the site can
answer "does someone back me up?" locally: BackupHistory::manager_coverage()
returns the newest manager-profile success that reached its bucket within
MANAGER_COVERAGE_DAYS (7), or null. The setup wizard's Backups step reads it
as a green condition, so a fleet-backed node is not asked to configure a bucket
it is already archived to; coverage goes stale on its own if the management
node's runs stop.
Saving a target
One form adds and edits a target: BackupTargetForm, drawn by the site's
Backups page, the setup wizard (a short form: no name, folder or Enabled box)
and, on a management node, the Targets page. One save path sits behind all
three (BackupTargetForm::save()). The provider select shows only the region
and endpoint fields the provider asks for, and only those are read from the
post; the lock days field shows only for a provider that takes object lock
(§ Object lock, below).
A site's first enabled target becomes where its backups go
(backup_target_id) as it is saved. Nothing ever picks "the one enabled
target" later.
The file store is a target row too, of purpose files (bkt_purpose), set up on
the Cloud Storage page through the same form in its files mode
(docs/cloud_storage.md). It is never a place backups go: every list of targets
is the backup targets unless it asks for file stores (MultiBackupTarget,
purpose), and this form's save refuses one. Its location is fixed once an
offloaded file records it.
A target's location is fixed once it is used. Provider, endpoint, region,
bucket, folder and lock days are drawn read-only, and refused on save, once anything was
stored in the target (a history row points at it) or, on a management node, a
storage space on it is still live (BackupTarget::location_refusal()). The name
and the key stay editable. To use another bucket, add a target. The folder is
stored in one form — no leading or trailing slash, no empty segment
(BackupTarget::normalise_prefix()) — and no two targets share a name, since a
name says which target a backup went to.
Switching off and deleting. A switched-off target takes no new backups;
listings, restores and pruning carry on. It is not switched off while it is
where new backups go or, on a management node, an owner's new backups go to it
(an active storage space; disable_refusal()). It is not deleted while either
holds, while it still holds backups (runs not yet pruned), or while it keeps an
owner's older backups (a draining space; delete_refusal()). Each refusal is a
sentence naming what still uses the target.
A target is proven before it is saved: the save runs TargetTester::test() on
an enabled target and refuses when it fails, printing why with the values
kept; the separate Test button runs the same check on a saved target. A
disabled target is saved untested, and enabling it is a save. The steps, in
order, stopping where the rest would be meaningless:
- Its own bucket. Not the file store's bucket
(
BucketCheck::collision_step, by name and endpoint host). Refused outright, before the network: one bucket for files and backups means one deletion or one public-read setting takes both. - Reach. The main key lists the bucket (one
ListObjectsV2, one key). - Write. The main key stores a probe object under the target's prefix.
- Private. An anonymous read of that probe is refused. Backups are encrypted, but a bucket anyone can read is refused all the same.
- Prune. The main key deletes the probe; retention needs that. A bucket that locks every new object by default (object lock with a default retention) fails here, saying so: retention could not prune on its own schedule. Lock days on the target are how backups are locked.
- Backblaze key. The key states what it may do
(
b2_authorize_account'sallowed, kept byB2Client::authorize()): a key pinned to another bucket is refused; one that opens every bucket on the account passes with a warning naming the file store buckets it also opens; a missing capability (listFiles,readFiles,writeFiles,deleteFiles, plusreadBucketRetentions,readFileRetentions,writeFileRetentionson a target that locks) is refused naming it. - Lock, on a target that locks. The bucket's object lock setting is on
(
GetObjectLockConfiguration), and a probe written locked for one day is refused a delete. A bucket without lock is refused, saying lock can only be turned on when a bucket is created. The probe stays until its date, under_joinery_lock_probe/beside the target's folders; each test first deletes the probes whose date has passed.
docs/cloud_storage.md § Test Connection Steps). includes/BucketCheck.php
holds the shared pieces.Object lock
A backup target can lock what is written to it: under object lock in compliance mode, nobody can delete or replace an object before its retain-until date — not this site, not the management node, not the key's owner at the provider's console. A break-in on the machine holding the key cannot erase the backups.
- Where. A backup target on a provider that takes object lock
(
StorageProvider::object_lock(): Backblaze B2, Amazon S3, Linode). The bucket must have object lock on, which a provider allows only when the bucket is created; the connection test refuses a target set to lock whose bucket has none. A file store never locks: deleting a member's file has to delete it. - How long.
bkt_lock_days, 0 for off, part of the location (fixed once anything is stored). The form suggests the site's retention plus its full-backup interval (BackupTarget::suggested_lock_days()), which covers each backup for as long as its set can be restored from. - Every write is locked. A site writes through the target's write
credential (
BackupTarget::write_credentials(),S3Signer::with_lock()): every whole-object PUT and multipart create carriesx-amz-object-lock-mode: COMPLIANCEand a retain-until date that many days out, and a locked PUT carries its body'sContent-MD5, which Backblaze requires. Through the broker, the lock headers are signed into each link (ShelfBroker::lockHeaders(),S3Signer::presign()'s signed headers) and named in its answer, so the node sends them and cannot leave them out: the provider refuses a request missing a signed header. The run's ledger file is locked the same way. A node on a release older thanJobCommandBuilder::LOCK_MIN_CORE_VERSIONis refused a backup or a re-upload to a space that locks, naming the release. - A delete waits for the date.
S3Signer::delete()answersLOCKED(423, withlocked_until) for a version the lock still holds, read from the version's retain-until date. Retention deletes a point whole or not at all: while the lock holds any of it — judged from when it was written plus the lock days, with fifteen minutes for clocks (BackupTarget::held_until(),BackupHistory::held_until(), and for a chain the newestLastModifiedin its folder, which a failed run may have written after the last success; the listing'sLastModifiedon the management node) — a surplus point is left as it is, still recorded surplus, and the first pass after its date deletes it. Should the clocks disagree by more than that and a delete inside a point be refused, the rest of the point waits the same way, without an error. The offloaded-files prune remembers each delete it could not make, and every candidate of a pass that could not read a retained run's index (unpruned.jsonbeside the site's objects, per target), and offers it again, through the same check of the retained runs' indexes, once its date has passed; an epoch whose re-sealed envelope is still locked is tidied again the same way. A customer's lapse prune is finished — its ledger rows marked, the customer told — only once nothing the lock holds is left; until then each pass tries again. Stored Backups and Empty backup storage say the date in their refusal. - What a refusal is read as. A delete the provider refuses is read as locked when the version's retain-until date is still ahead. So on a locking target, a key that has lost its delete permission looks held until that date, and then fails with the real reason; the connection test's Prune step, whose probe is not locked, shows a missing permission at once.
- Cost. Storage for a locked object is paid until its date even when retention would delete it sooner, and nothing can shorten that; a mistaken large upload costs its lock period. The form says so beside the field.
A target an installer creates
A first-boot installer that was handed a bucket and its key pair (the Linode
deploy form's optional backup fields) creates the first target itself with
utils/install_backup_target.php: the target is saved, a Backblaze
credential's region and endpoint are filled from Backblaze's authorize answer
(BackupTarget::complete_credentials, the same call the Backups page makes),
the target is proven (§ Saving a target), and a first target becomes the
scheduled one. A target that fails is removed again so the setup wizard asks
for one. The
recovery key is never created here — it is shown once to a human — so nightly
runs still wait on the wizard's key ceremony, exactly as they do for a target
saved on the Backups page. Inputs are environment variables
(JOINERY_BACKUP_BUCKET, JOINERY_BACKUP_KEY_ID, JOINERY_BACKUP_KEY,
optional JOINERY_BACKUP_PROVIDER, any provider in StorageProvider, default
b2, with JOINERY_BACKUP_REGION or JOINERY_BACKUP_ENDPOINT where the provider
asks for one);
the first output line is INSTALL_BACKUP_TARGET=ok or =error.
Artifact naming
includes/BackupNaming.php owns which files are backups, what each one is, and
what restoring it would do. Every surface consults it — the management API's
local listing, the node Backups tab, the job builder's globs.
Recognized suffixes are matched longest-first: .sql.gz is a suffix of
.sql.gz.enc, so a shortest-first match would classify every encrypted dump as
plaintext and hand the restore engine a file it will not decrypt.
Settings
| Setting | Default | What it controls |
|---|---|---|
backup_recovery_public_key | — | The key every backup seals to |
backup_target_id | none | Which target scheduled backups upload to |
backup_type | project | Whole site, or database only |
backup_mode | chain | Incremental chains, or a full every time |
backup_full_interval_days | 7 | Days before a chain rolls to a fresh full |
backup_verify_every_days | 7 | Days between verifications of the newest backup by opening and reading it; 0 never |
backup_retention_days | 28 | Days of restore points kept offsite; blank reads as 28 |
backup_output_dir | /backups | Working directory backups are built in |
backup_exclude | — | Extra directory names to skip (build output, caches). A name matches a directory of that name at any depth — this is tar's exclude semantics, and it applies to the built-in skips (cache, tmp, logs, …) too. vendor/ is skipped at the site root only: there it is Composer's tree; inside public_html (assets/vendor, a plugin's own) it is signed code and is archived |
backup_local_retention_days | 7 | Days kept locally; 0 never sweeps |
backup_delete_local_after_upload | on | Remove what a run left on disk — the metadata artifact, a standalone run's envelope sidecar — once uploaded; archives and dumps stream and are never on disk |
backup_path_slug | project dir | Folder in the bucket this site files under |
The account backups run as
The scheduled task runs as the web user, which is what fix_permissions.sh
makes the owner of the site tree. A backup that cannot read part of the tree
fails rather than shipping a partial archive, and says which file it could
not read — so a permissions problem surfaces as a failed run with a specific
cause, not as a backup that turns out to be incomplete on the day it is needed.
