Recovery
A site is a Durable Object holding a SQLite database. Four primitives can move it backwards, and they answer different failures. Picking the wrong one either does nothing or destroys more than it restores.
| Primitive | Scope | Window | Survives object loss |
|---|---|---|---|
| Point-in-time recovery | the object's whole storage | 30 days | no |
/export dump |
the site database, as SQL text | as long as you keep it | yes |
wrangler rollback |
Worker code and config | 100 versions | n/a |
| The R2 mirror | rendered pages and uploaded files | current state | yes |
PITR is operational recovery, not backup. It reverses a change to an object that still exists.
It lives in the same place as the data it protects, so a deleted namespace, a deleted Worker or a
lost account takes the recovery log with it. The /export dump is the only thing on this page that
leaves the platform, and it is the only thing that answers "the object is gone".
Point-in-Time Recovery
SQLite-backed Durable Objects can be restored to any point in the past 30 days. The window
covers the whole of ctx.storage, both SQL tables and key-value pairs.
Recovery is addressed by bookmarks, not timestamps. A bookmark is a mostly alphanumeric string
such as 0000007b-0000b26e-00001538-0c3e87bb37b3db5cc52eedb93cd3b96b, and bookmarks are lexically
comparable, so ordering them as strings orders them in time. Three methods:
| Method | Returns |
|---|---|
ctx.storage.getCurrentBookmark() |
a bookmark for now |
ctx.storage.getBookmarkForTime(t) |
a bookmark for a Date or epoch ms within the last 30 days |
ctx.storage.onNextSessionRestoreBookmark(b) |
a bookmark that undoes the restore it schedules |
The restore is applied on the object's next start, not immediately. The bookmark returned by
onNextSessionRestoreBookmark() is the undo, so capture it before restarting.
There is no wrangler command and no dashboard button. PITR is a runtime API only, so an operator can reach it only through code running inside the Durable Object.
GET /pitr is that code. It takes the owner token or PW_DIAGNOSTICS=1. /restore is
diagnostic-only: a restore body names a state the caller invents, where a /pitr bookmark names
one the platform holds.
| Request | Answers |
|---|---|
GET /pitr |
the current bookmark and the window |
GET /pitr?at=<ms or ISO> |
the bookmark for that time, refused outside 30 days |
POST /pitr?bookmark=<b> |
schedules the restore and returns the undo bookmark |
The restore applies on the object's next start. The undo bookmark is obtainable only from the call that schedules the restore, so it is returned rather than logged.
It is unsupported in local development, and the local runtime signals that badly. Cloudflare's
wording is that "a durable log of data changes is not stored locally". All three
methods exist in the local runtime. getBookmarkForTime() throws "This Durable Object's storage
back-end does not implement point-in-time recovery", while getCurrentBookmark() returns
00000000-00000000-00000000-00000000000000000000000000000000 and throws nothing.
A feature-detect on the method therefore passes and hands back a bookmark that would schedule a
restore to the beginning of time. /pitr refuses an all-zero bookmark by value, and reports
supported: false with the platform's own sentence when the API throws. Restore behaviour is
verified on a deployed object only.
The Export Dump
GET /export with Authorization: Bearer <ownerToken> dumps the site database as replayable SQL.
It is one of the owner routes, which the owner token reaches without PW_DIAGNOSTICS=1;
OWNER_ROUTES in src/site/routes.ts is the list.
The dump is produced host-side by dumpDatabase() in src/db/export-sql.ts, not by PHP. Every value
is read as typeof() plus hex() and never as the column, because a Durable Object SQLite integer
read loses precision above 2^53 and a hex() of an integer never passes its digits through a double.
REAL is the one class read directly.
What a dump contains:
- Structure only for tables whose rows regenerate: every
cache_*bin,cachetags,sessions,semaphore,flood,queue,watchdog,history,search_dataset,search_index,search_totalandcfw_page. A restoredcache_containerwould boot the site on the previous site's service container, andcachetagschecksums that disagree with the bins they describe leave rows that are present and permanently rejected. - Nothing at all from
cfw_migrate,cfw_importandcfw_import_chunk. A dump carryingcfw_import_chunkwould contain itself, and one carryingcfw_migratewould have a replay overwrite the cursor that makes the replay resumable. - No secrets by default. The owner token, the Cloudflare OAuth token and client id, the SMTP
settings,
hash_salt, and the git provider tokens and webhook secrets (git_token_*,git_hooksecret_*) are withheld.?secrets=1carries them. A faithful restore needshash_salt, since it signs one-time login links and form tokens; a backup stored anywhere else should not have it.
Options:
| Parameter | Effect |
|---|---|
?cursor=start |
chunked and resumable; each response carries nextCursor |
?body=1 |
include the SQL text in a chunked response rather than its length |
?all=1 |
carry rows for the regenerable tables too |
?secrets=1 |
carry the withheld cfw_meta secrets |
?limit=N |
cap rows per table |
?chunkChars=N |
override the 1,000,000-character chunk size |
A dump that cannot be replayed answers 409 rather than 200. A restore point nobody can replay
reads as a backup while being useless. ?all=1 includes the cache_container row, which measures
960,544 characters against the 100,000-character Durable Object statement ceiling, so a dump carrying
it is not replayable. A chunked export also answers 409 on a shape mismatch, so two dumps cannot be
spliced into one file unnoticed.
Restore
POST /restore with the dump as the request body. This route is diagnostic-gated, not
owner-gated: it needs PW_DIAGNOSTICS=1, because it overwrites a whole database. Export is a
supported customer-facing property; restore is an operator action.
The body is stored, not executed. storeImport() splits it into statements, refuses at store time if
any statement exceeds the 100,000-character ceiling, and writes the parent row and every chunk in one
transactionSync(). Then the object arms its alarm and the replay runs there, a chunk at a time. The
invocation that accepts the upload is never the one holding a half-overwritten site open.
?label= names the restore point; it defaults to the current time. GET /health reports whether a
complete stored import exists.
The Recovery Matrix
| Failure | Primitive | Does not cover | Command |
|---|---|---|---|
| Accidental content deletion | PITR to just before the delete | anything written after the bookmark; the object must still exist | GET /pitr?at=<t> then POST /pitr?bookmark=<b> |
| Bad module update | PITR, and wrangler rollback if code moved |
files already mirrored to R2; a config change an editor made after the update | as above, plus wrangler rollback [VERSION_ID] |
| Corrupted config | /restore of the last good dump |
content created since that dump; needs PW_DIAGNOSTICS=1 |
POST /restore?label=pre-fix with the dump as the body |
| Failed migration | re-run the migration | nothing, if the cursor survived; cfw_migrate is resumable and a dump excludes it |
drangler migrate ...; GET /migrate reports chunk, chunks and done |
| Whole-object loss | /restore into a new object |
PITR entirely; the log died with the object; hash_salt unless the dump used ?secrets=1 |
POST /restore against the new site name |
| Worker version rollback | wrangler rollback |
the database. Code moves, data does not. Refused if a Durable Object class lifecycle change or a deleted binding sits between the two versions | wrangler rollback [VERSION_ID] --name <worker> |
| R2 asset loss | the mirror refills from the object | nothing; the object is the durable copy and R2 is a cache in front of it | no operator route; /__mirror is DO-internal and the alarm drains the queue |
The flowchart reads the matrix from the top: whether the object survived, then what caused the change.
flowchart TD
A["Something went wrong"] --> B{"Does the site object still exist?"}
B -->|no| C["POST /restore into a new object, from the last /export dump"]
B -->|yes| D{"Did a Worker deploy cause it?"}
D -->|yes| E["Roll back the Worker and restore the data with PITR"]
D -->|no| F{"Content or configuration?"}
F -->|content| G["PITR to a bookmark taken before the change"]
F -->|configuration| H["POST /restore with the last good dump, needs PW_DIAGNOSTICS=1"]Two rows carry the distinctions that matter most.
A Worker rollback is not a data rollback. wrangler rollback creates a new deployment from a
previous version and becomes active across every route immediately. It reaches the 100 most recent
versions. It does not touch ctx.storage, so a bad deploy that also wrote bad rows needs both a
rollback and a PITR restore. Cloudflare refuses the rollback outright if a Durable
Object class lifecycle change happened between the two versions, or if the target version binds an R2
bucket, KV namespace or queue that no longer exists.
R2 is a mirror, not a backup. With no FILES bucket bound, which is the shipped configuration, the
object is the durable copy and there is nothing to lose. With one bound, the bucket holds rendered pages and
uploaded files that the object still has, and the mirror queue refills it on the alarm.
Keeping a Dump
Nothing schedules an export. A dump exists because someone asked for one, and it lives wherever they put it. For a site whose object still exists, PITR covers the last 30 days with no storage of your own; the dump is what covers everything else.
Two properties make an unattended export practical: ?cursor= walks the database in bounded chunks
instead of building the whole file in one invocation, and a 409 tells the caller the file it just
built is not replayable, so a backup job can fail loudly instead of storing something that reads
like a backup.
Related
- Configuration:
PW_DIAGNOSTICS, theFILESbinding and the runtime overrides - External Databases and Hyperdrive: why the data lives in the object
- Technical report, Platform Constraints: the 100,000-character statement ceiling and the other measured Durable Object SQLite limits