Operating

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:

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.