Accepted
ADR 0042/0043 — Backup and import engines
ExportBackup writes a verifiable gzipped tarball to R2; ImportContent restores it into a fresh deployment
A deployment's recovery story is a pair of workflows. ExportBackup dumps the database and an inventory of stored objects into a single verifiable tarball in R2. ImportContent reads that tarball back into a fresh deployment. They share one table enumeration, so the dump order and the restore order cannot drift, and one canonical tar encoder, so neither adds bundle weight.
ExportBackup — what a backup contains
Each step writes its slice to a staging prefix, then a final wrap step assembles a gzipped tarball at backups/{isoTimestamp}/backup.tar.gz. Inside it:
- manifest.json at the root — the canonical compatibility record: cuspVersion, schemaVersion, per-table row counts and SHA-256, media and rendered object counts, and who and what triggered the run.
- d1/{table}.jsonl — one JSON row per line, per table. Pagination is cursor-based on the primary key, never OFFSET, which is pathological on large version tables.
- media-inventory.json — references (key, size, etag) to media in R2, not the bytes. Media stays in R2.
- rendered-pointer.json — a pointer to the rendered tree, which is regenerable from D1 plus the theme.
The dump covers versioned tables, the operational scratchpads, and sessions, for fidelity. Each table's SHA-256 is a pure function of its source rows, so a retry against the same database produces byte-equal output. Downstream consumers compare against the manifest's hashes, never against an R2 etag.
Scheduling and concurrency
Backups run daily at 03:00 UTC from a long-lived workflow that sleeps between runs with step.sleepUntil — the same shape as the prune workflow, with no cron trigger anywhere. The setup handler creates the instance at install time, and it sleeps until the first 03:00 UTC so a fresh install never backs up an empty database. A manual trigger is also available from the admin. A partial unique index on backup_runs where status is running makes the two flows a singleton: a manual backup while the daily run is mid-flight returns 409 BACKUP_ALREADY_RUNNING. Old tarballs accumulate — there is no automated retention in v1, and the operator's R2 usage is surfaced on the Health page.
ImportContent — restoring a backup
The admin points the import at a tarball key already in R2 (the operator stages it with wrangler r2 object put if it came from another deployment). Accepting an arbitrary URL or a browser upload is deliberately not supported — that would invite an SSRF surface and a buffering problem; the narrow key-in-R2 surface dodges both.
The import refuses to run against a database that already carries content. The two real cases — disaster recovery onto a fresh deploy, and migrating between deployments — both produce an empty target; a verify-empty-target step fails with IMPORT_TARGET_NOT_EMPTY otherwise. Refusing is far safer than wipe-then-restore, where one misclick destroys live data, or merge semantics, where reconciling cross-referenced version history is a rabbit hole; both are deferred.
Before any rows land, a verify step asserts that the manifest's cuspVersion and schemaVersion equal the running deployment's (cross-version restore is deferred) and that every per-table SHA-256 matches what the workflow computes from the tarball, plus the outer tarball hash. Tables are then imported in the shared FK-parent-before-child order, in chunks of 50, with a cursor (current table, row offset) so a retry resumes mid-table without double-inserting. Media is verified against the inventory and missing keys are reported as a load-bearing warning — media bytes are not regenerable — while missing rendered output is merely informational, since re-publishing regenerates it.
Coexistence with the daily backup
An import and the daily backup must not run at once, or the backup would capture a mid-restore database. Each side claims its own singleton row first, then checks the peer — insert-then-verify, because the database-atomic singleton index is the only cross-process synchronisation primitive available. If the import finds a backup running it self-cancels with IMPORT_BACKUP_IN_FLIGHT and frees its slot; the daily backup symmetrically skips its iteration if an import is in flight. Sleeping backup instances hold no running row, so an import during the long sleep window proceeds normally.
Trust boundary
Backup tarballs are unsigned in v1. The manifest's hashes are integrity checks against a manifest that travels inside the tarball, so anyone who can write to the backups prefix in R2 can plant a tampered backup with self-consistent hashes. The defended boundary is therefore "write access to the backups prefix is privileged" — the same trust level as the Wrangler and R2 credentials themselves. Compensating controls are administrator-only, rate-limited import, the strict version check, and a post-restore audit-log entry recording the source key and hash. Signed backups, keyed to a per-install secret, are a v1.x hardening.
Consequences
- Positive — one shared table enumeration and one shared tar encoder mean dump and restore cannot drift, at zero extra bundle cost.
- Positive — the per-table SHA-256 chain gives a tamper-evident integrity reference independent of R2 etag stability.
- Negative — no automated retention; the operator's R2 quota is on the line, with Health-page visibility as the mitigation.
- Negative — cross-version restore and media-bytes restore are out of scope for v1; the version check fails loudly and missing media is reported rather than silently dropped.
ImportContent is the restore side of ExportBackup only. There is no WordPress, Markdown, or Strapi importer in core — source-format importers belong to plugins.
Both engines are Cloudflare Workflows. See async work for how Cusp runs durable, retryable, self-scheduling jobs.