Migrate a Durable Object from Cloudflare
Same Worker, same objects, new home. Every key and row checked.
Shelley handles the setup. About 1 hour.
Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/migrate-durable-objects/prompt.txt | ssh exe.dev new --prompt=/dev/stdinCloudflare has no bulk export for Durable Objects. Its dashboard's Data
Studio opens one object at a time, its API lists objects only by ID, and
each object's storage is private to the object. So the way out is through
your own code: this recipe adds a small export and import route to your
Worker, copies every object, key, row, and alarm into celld
on an exe.dev VM, and proves each copy identical. celld runs the same
cloudflare:workers APIs, so your Worker barely changes.
Examples use a Worker named chat with a Durable Object class Room,
bound as ROOMS and addressed by room name: the chat from
A Durable Object with celld. Use your own names.
What you get#
- Your Worker running on celld at
https://<vm>.exe.xyz/, private, with its state in your bucket. - Every Durable Object with data on Cloudflare exported to
~/migrationand imported into celld. - Each imported object checked identical to its export from Cloudflare.
- A report of what moved, which objects had no name, and anything celld can't run.
- Cloudflare serving as before, plus a token-protected export route.
What's running, and where
https://my-worker.exe.xyz/
Your Worker on celld, until you move your domain
celld.json beside its Wrangler config
Deploy with Shelley#
Click Deploy and Shelley will set up Durable Objects from Cloudflare on a new VM, then check that it works. No config-file wrangling required.
Have these ready; Shelley asks for them:
APP_REPO: your Worker's Git repository; for a private GitHub repository, connect the GitHub integration firstFROM_URL: where the Worker answers on Cloudflare; itsworkers.devaddress is best, because it stays put when your domain movesCLOUDFLARE_ACCOUNT_ID: the account that runs the WorkerCLOUDFLARE_API_TOKEN: an API token from the Edit Cloudflare Workers templateCELLD_BUCKET: an empty bucket for celld, writtens3://name(how)S3_ENDPOINT: R2:https://<ACCOUNT_ID>.r2.cloudflarestorage.com; Amazon S3: leave emptyAWS_REGION:autofor R2 and Tigris; the bucket's region for Amazon S3AWS_ACCESS_KEY_ID: a key that can read and write that bucket, and nothing elseAWS_SECRET_ACCESS_KEY: the key's secret
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/migrate-durable-objects/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhat Shelley is told
APP_REPO=<fill-this-in>
FROM_URL=<fill-this-in>
CLOUDFLARE_ACCOUNT_ID=<fill-this-in>
CLOUDFLARE_API_TOKEN=<fill-this-in>
CELLD_BUCKET=<fill-this-in>
S3_ENDPOINT=<fill-this-in>
AWS_REGION=<fill-this-in>
AWS_ACCESS_KEY_ID=<fill-this-in>
AWS_SECRET_ACCESS_KEY=<fill-this-in>
Set up Durable Objects from Cloudflare on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/migrate-durable-objects.md from top to bottom.
The values at the top of this message are mine:
- APP_REPO: your Worker's Git repository; for a private GitHub repository, connect the [GitHub integration](/docs/integrations-github) first
- FROM_URL: where the Worker answers on Cloudflare; its `workers.dev` address is best, because it stays put when your domain moves
- CLOUDFLARE_ACCOUNT_ID: the account that runs the Worker
- CLOUDFLARE_API_TOKEN: an API token from the **Edit Cloudflare Workers** template
- CELLD_BUCKET: an empty bucket for celld, written `s3://name` ([how](/deploy/celld#get-a-bucket))
- S3_ENDPOINT: R2: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`; Amazon S3: leave empty
- AWS_REGION: `auto` for R2 and Tigris; the bucket's region for Amazon S3
- AWS_ACCESS_KEY_ID: a key that can read and write that bucket, and nothing else
- AWS_SECRET_ACCESS_KEY: the key's secret
Ask me for any that still say <fill-this-in> before you need them.
The recipe's example VM name is my-worker. This VM's name is the output of `hostname`: use it wherever the recipe writes my-worker. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- Your Worker running on celld at `https://<vm>.exe.xyz/`, private, with its state in your bucket.
- Every Durable Object with data on Cloudflare exported to `~/migration` and imported into celld.
- Each imported object checked identical to its export from Cloudflare.
- A report of what moved, which objects had no name, and anything celld can't run.
- Cloudflare serving as before, plus a token-protected export route.
Read the whole recipe, including the closed details, and do "Prepare the
move by hand" in order, skipping VM creation; the walkthrough above it is
for the owner. Where
it names steps of the celld recipe, follow /deploy/celld.md on this site.
Ask the owner before deploying anything to Cloudflare. Never freeze the
Worker, change DNS, run share set-public, or delete anything on
Cloudflare: making the move is the owner's call.
Keep the tokens and bucket keys out of your replies and out of Git;
celld.json holds secrets, so make sure Git ignores it.
If the Worker uses something celld lacks, stop and report it rather than
rewriting the app. Before exporting a class that is addressed by name,
ask the owner where its names are recorded.
Finish with the dry run's numbers per binding (by name, by id only),
https://<vm>.exe.xyz/, and the owner's steps in "Make the move".
Finish with the URLs I should open.
Also at /deploy/migrate-durable-objects/prompt.txt. The recipe it follows is /deploy/migrate-durable-objects.md.
How the move works#
- celld runs your Worker on the VM, with an empty bucket.
- A dry run copies every object while Cloudflare keeps serving. You try the app on the VM, where your changes stay.
- The move: you freeze the Worker on Cloudflare, copy again, and point your domain at the VM.
Shelley does the first two and asks before deploying anything to Cloudflare. The move is yours.
About addresses: Cloudflare keeps each object's ID, not the name you
passed to idFromName() or getByName(). So for classes addressed by
name, you supply the names, usually from your own records, such as a
users table. An object you can't name still moves, under the name
cloudflare:<id>. So do objects made with newUniqueId(), because celld
can't read Cloudflare's IDs; a three-line fallback in your code finds
them.
Look around#
When Shelley finishes, open https://my-worker.exe.xyz/ and sign in to
exe.dev. That's your Worker on celld, with your data as of the dry run.
Poke at it: changes here stay here. It is your code with your secrets,
though, so anything it does elsewhere, like sending email, still happens.
Read Shelley's report too. Objects copied by id only in a class that you address by name are there, but your code can't find them until you add their names and copy again.
Make the move#
Pick a quiet moment; the copy takes about as long as the dry run. On the
VM, freeze the Worker on Cloudflare. The secret redeploys it: objects
restart, WebSockets close, and the Worker answers 503 to all but the
migration route and static files. If other
Workers call these objects directly, pause them too.
cd ~/app
echo 1 | ~/migration/run npx wrangler secret put MIGRATION_FREEZE
Clear out the dry run and your testing, and load your latest code:
~/celld/restore ~/migration/empty-celld
~/celld/deploy ~/app/celld.json
Copy everything again, binding by binding. Once every binding checks out, set the objects' alarms:
node ~/migration/do-migrate.mjs list ROOMS Room
node ~/migration/do-migrate.mjs export ROOMS
node ~/migration/do-migrate.mjs import ROOMS
node ~/migration/do-migrate.mjs verify ROOMS
node ~/migration/do-migrate.mjs alarms ROOMS
Expect verified N objects: identical, then set N alarms. If you held
back cron triggers, add them to celld.json now and run ~/celld/deploy ~/app/celld.json. Then send your domain to the VM:
- In Cloudflare, remove the domain from the Worker (Settings → Domains
& Routes). Then point it at the VM with a DNS record
app.example.com CNAME my-worker.exe.xyz, proxy off (grey cloud). See custom domains. - On your computer, register the domain and open the VM to everyone:
ssh exe.dev domain add my-worker app.example.com
ssh exe.dev share set-public my-worker
Changed your mind before step 1? Stop celld, so no alarm runs twice, and unfreeze; Cloudflare carries on where it stopped:
sudo systemctl disable --now celld celld-snapshot.timer
cd ~/app && ~/migration/run npx wrangler secret delete MIGRATION_FREEZE
Clean up#
Once the app has run well on celld for a while:
- Remove the migration route: delete
src/do-migration.jsand its hooks,npm uninstall devalue, takeMIGRATION_TOKENout ofcelld.json, and run~/celld/deploy ~/app/celld.json. Keep thenewUniqueId()fallback. - Delete
~/migration/settingsand revoke the Cloudflare API token. - Keep
~/migrationitself, off the VM too: it's the last copy of your Cloudflare data. - Delete the Worker on Cloudflare, and its objects, when you're sure.
The nuts and bolts#
These are the steps Shelley follows for the dry run, in order.
Prepare the move by hand
Set up celld#
On your computer (pick another name if my-worker is taken):
ssh exe.dev new --name=my-worker
ssh my-worker.exe.xyz
On the VM, follow the celld recipe's Install celld
and Connect the bucket, including its
check that the bucket is empty. Skip Write the chat: your Worker takes
its place. Everything below runs on the VM, as exedev.
Get your Worker#
Install a current Node.js for Wrangler, then clone the project and its
dependencies, the way the project installs them (npm ci, pnpm install,
and so on):
uvx nodeenv -n lts ~/node
ln -sf ~/node/bin/node ~/node/bin/npm ~/node/bin/npx ~/.local/bin/
git clone https://github.com/you/chat.git ~/app
cd ~/app && npm ci
node --version
Use your repository's address; with the GitHub integration, that's
https://github.int.exe.xyz/you/chat.git. Expect Node 24 or newer.
Write celld.json#
celld reads a Wrangler-style JSON file but refuses any key it doesn't
support, rather than ignoring it. So the project gets its own copy,
celld.json, beside wrangler.jsonc:
- Keep
name,main,compatibility_date,compatibility_flags,durable_objects,assets,vars, and anykv_namespaces,d1_databases,r2_buckets,queues,workflows,services,define, andrules. Hold back crontriggersuntil the move, so no job runs on both sides. Awrangler.tomlconverts withpython3 -c 'import json, tomllib; print(json.dumps(tomllib.load(open("wrangler.toml", "rb")), indent=2))'. - Drop Cloudflare's own settings:
account_id,routes,route,workers_dev,preview_urls,observability,placement,limits,logpush,tail_consumers,upload_source_maps,build, anddev. If you deploy with--env, use that environment's values instead. - Replace
migrations(orexports) with one entry that lists every Durable Object class. Key-value-backed classes become SQLite-backed; their storage calls work the same. - Add your Worker's secrets to
vars: celld keeps them with the deployment, in your bucket. This makescelld.jsonsecret, so keep it out of Git.
For the chat:
cd ~/app
cat > celld.json <<'EOF'
{
"name": "chat",
"main": "src/worker.js",
"compatibility_date": "2026-10-01",
"assets": { "directory": "./public" },
"durable_objects": {
"bindings": [{ "name": "ROOMS", "class_name": "Room" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }],
"vars": {}
}
EOF
chmod 600 celld.json
grep -qx celld.json .gitignore 2>/dev/null || printf '\n# celld\ncelld.json\n.celld/\n' >> .gitignore
Bindings that celld doesn't run (ai, vectorize, hyperdrive,
browser, send_email, and others on its
compatibility page) need a
code change first. So do Durable Objects defined in another Worker
(script_name) and jurisdiction().
Find objects made with newUniqueId()#
celld refuses IDs that Cloudflare made, and the import puts their objects
under the name cloudflare:<id>. Where your code turns a stored ID back
into an object, use this instead of env.SESSIONS.idFromString(id):
// Durable Objects from Cloudflare moved to celld under "cloudflare:<id>".
function idFromString(ns, id) {
try {
return ns.idFromString(id);
} catch {
return ns.idFromName(`cloudflare:${id}`);
}
}
It works on Cloudflare too, so you can deploy one version everywhere.
Classes addressed only by name need nothing here. If your code also
stores the IDs of objects it made by name, look those up by name instead:
the first line of each file in ~/migration/<BINDING>/objects/ pairs an
ID with its name.
Try it on celld#
Run the Worker on celld with empty storage, and click around at
https://my-worker.exe.xyz:9876/. Ctrl-C stops it:
cd ~/app
celld dev celld.json --host 0.0.0.0 --port 9876
It reads .dev.vars, as wrangler dev does. An error that names an API
or a config key is something celld doesn't support; see the compatibility
page, and fix it before you go on.
Add the migration route#
This file goes into the project. It answers /__migrate/... requests
that carry the migration token: on Cloudflare it exports each object, and
on celld it imports them. It needs devalue
to carry every kind of stored value, from Dates to ArrayBuffers:
cd ~/app
npm install --save-exact devalue@6.0.3
cat > src/do-migration.js <<'EOF'
// do-migration.js: copy Durable Object storage from one runtime to another,
// such as from Cloudflare to celld. Add it for the move; remove it after.
//
// In your Worker's fetch, before anything else:
// if (isMigration(request)) return migrationRoute(request, env);
// In each Durable Object class's fetch (add one if the class has none):
// if (isMigration(request)) return migrateObject(this.ctx, request);
//
// The Worker then answers /__migrate/<BINDING>/<action>?name=<name> (or
// ?id=<id>), and only with "Authorization: Bearer <MIGRATION_TOKEN>":
// id the object's id
// export one page, with the next page's cursor in X-Migrate-Cursor and,
// on the last page, the object's alarm in X-Migrate-Alarm
// import load pages in order; the one sent with ?first=1 empties the
// object first
// alarm set the alarm to ?at=<ms>
// do-migrate.mjs drives them.
import * as devalue from "devalue";
const PREFIX = "/__migrate/";
const PAGE_BYTES = 4 * 1024 * 1024;
const PAGE_ITEMS = 5000;
// The runtimes' own tables (and local Wrangler's), which are not yours.
// So are tables a runtime won't let you read, such as celld's _litestream_.
const RESERVED = /^(_cf_|sqlite_|__miniflare)/i;
// devalue carries everything Durable Object storage holds, except Errors.
const reducers = { Error: (v) => v instanceof Error && [v.name, v.message, v.stack] };
const revivers = {
Error: ([name, message, stack]) => {
const types = [EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError];
const error = new (types.find((t) => t.name === name) ?? Error)(message);
if (typeof stack === "string") error.stack = stack;
return error;
},
};
export function isMigration(request) {
return new URL(request.url).pathname.startsWith(PREFIX);
}
export async function migrationRoute(request, env) {
const token = env.MIGRATION_TOKEN;
if (!token || request.headers.get("Authorization") !== `Bearer ${token}`) {
return new Response("forbidden\n", { status: 403 });
}
const url = new URL(request.url);
const [binding, action] = url.pathname.slice(PREFIX.length).split("/");
const ns = env[binding];
const name = url.searchParams.get("name");
const hex = url.searchParams.get("id");
if (typeof ns?.idFromName !== "function" || (name === null && hex === null)) {
return new Response("want /__migrate/<BINDING>/<action>?name= or ?id=\n", { status: 404 });
}
const id = name !== null ? ns.idFromName(name) : ns.idFromString(hex);
if (action === "id") return Response.json({ id: id.toString() });
return ns.get(id).fetch(`https://do${PREFIX}${action}${url.search}`, {
method: request.method,
body: request.method === "POST" ? await request.text() : undefined,
});
}
export async function migrateObject(ctx, request) {
const url = new URL(request.url);
const action = url.pathname.slice(PREFIX.length);
try {
if (action === "export") {
const [items, next] = await exportPage(ctx, JSON.parse(url.searchParams.get("cursor") || "null"));
const headers = { "X-Migrate-Cursor": JSON.stringify(next) };
if (next === null) headers["X-Migrate-Alarm"] = JSON.stringify(await ctx.storage.getAlarm());
return new Response(devalue.stringify(items, reducers), { headers });
}
if (action === "import" && request.method === "POST") {
const items = devalue.parse(await request.text(), revivers);
return Response.json(await importPage(ctx, items, url.searchParams.get("first") === "1"));
}
if (action === "alarm" && request.method === "POST") {
await ctx.storage.setAlarm(Number(url.searchParams.get("at")));
return Response.json({ ok: true });
}
return new Response("unknown migration action\n", { status: 404 });
} catch (error) {
return new Response(`${error?.stack ?? error}\n`, { status: 500 });
}
}
// The object's SQL database, or null for a key-value-backed object.
function sqlOf(ctx) {
try {
const sql = ctx.storage.sql;
sql.exec("SELECT 1").toArray();
return sql;
} catch {
return null;
}
}
const q = (name) => `"${String(name).replaceAll('"', '""')}"`;
// About how many bytes a value takes, to keep pages near PAGE_BYTES.
function size(value) {
let bytes = 0;
const seen = new Set();
const todo = [value];
while (todo.length) {
const v = todo.pop();
if (typeof v === "string") bytes += v.length + 8;
else if (v instanceof ArrayBuffer || ArrayBuffer.isView(v)) bytes += v.byteLength + 8;
else if (v === null || typeof v !== "object" || seen.has(v)) bytes += 16;
else {
seen.add(v);
bytes += 16;
if (v instanceof Map) for (const [k, x] of v) todo.push(k, x);
else if (v instanceof Set) for (const x of v) todo.push(x);
else for (const x of Object.values(v)) todo.push(x);
}
}
return bytes;
}
function readable(sql, table) {
try {
sql.exec(`SELECT * FROM ${q(table)} LIMIT 0`).toArray();
return true;
} catch {
return false;
}
}
// Your tables, in creation order: the CREATE statement, the columns to copy
// (not generated ones), the rowid to page by (none WITHOUT ROWID) or else
// the primary key to sort by, and the columns that keep whatever type a
// value has (no type, BLOB, or STRICT ANY), where a REAL like 5.0 must not
// come back as the INTEGER 5.
function tables(sql) {
if (!sql) return [];
const kinds = new Map(sql.exec("PRAGMA table_list").toArray().map((t) => [t.name, t]));
const out = [];
for (const { name, sql: ddl } of sql.exec(
"SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY rowid",
)) {
const kind = kinds.get(name);
if (RESERVED.test(name) || kind?.type === "shadow" || !readable(sql, name)) continue;
if (kind?.type === "virtual" && /\bcontent\s*=\s*(''|"")/i.test(ddl)) {
throw new Error(`${name} is a contentless full-text table: its index can't be copied, only rebuilt`);
}
const info = sql.exec(`PRAGMA table_xinfo(${q(name)})`).toArray();
const taken = new Set(info.map((c) => c.name.toLowerCase()));
const anyType = (type) => {
const t = String(type).toUpperCase();
if (/INT|CHAR|CLOB|TEXT/.test(t)) return false;
return t === "" || t.includes("BLOB") || (kind?.strict === 1 && t === "ANY");
};
const columns = info.filter((c) => c.hidden === 0);
out.push({
name,
ddl,
columns: columns.map((c) => c.name),
anyType: new Set(columns.filter((c) => anyType(c.type)).map((c) => c.name)),
rowid: kind?.wr ? null : ["rowid", "_rowid_", "oid"].find((r) => !taken.has(r)),
key: info.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => q(c.name)),
});
}
return out;
}
// The tables in the order their rows go out: a table after the tables
// its foreign keys point to, so each page's rows can refer to rows already
// in. A table that refers to itself, or a loop of them, can still fail.
function rowOrder(sql, list) {
const byName = new Map(list.map((t) => [t.name.toLowerCase(), t]));
const out = [];
const seen = new Set();
const visit = (t, path) => {
if (seen.has(t) || path.has(t)) return;
path.add(t);
for (const fk of sql.exec(`PRAGMA foreign_key_list(${q(t.name)})`)) {
const parent = byName.get(String(fk.table).toLowerCase());
if (parent && parent !== t) visit(parent, path);
}
seen.add(t);
out.push(t);
};
for (const t of list) visit(t, new Set());
return out;
}
// Your indexes, triggers, or views, in creation order. Indexes go out with
// the tables, because a foreign key can rely on a unique one; triggers and
// views after the rows, so that no trigger fires during the import.
function schema(sql, ...types) {
if (!sql) return [];
const yours = new Set(tables(sql).map((t) => t.name));
return sql
.exec(`SELECT type, name, tbl_name, sql FROM sqlite_master
WHERE type IN (${types.map(() => "?").join(", ")}) AND sql IS NOT NULL ORDER BY rowid`, ...types)
.toArray()
.filter((s) => !RESERVED.test(s.name) && (s.type === "view" || yours.has(s.tbl_name)));
}
// JavaScript numbers can't hold every SQLite value: integers past 2^53 lose
// digits, and a REAL like 5.0 reads as 5. So each row also gets a list of
// such values, "<column>=<integer>" or "<column>~", and exact() turns them
// into BigInts and { real } before they go out.
function exactness(t, names) {
const parts = names.map((name, i) => {
const x = name === t.rowid ? name : q(name);
const big = `iif(typeof(${x}) = 'integer' AND ${x} NOT BETWEEN -9007199254740991 AND 9007199254740991, '${i}=' || ${x} || ',', '')`;
return t.anyType.has(name) ? `${big} || iif(typeof(${x}) = 'real' AND ${x} = round(${x}), '${i}~,', '')` : big;
});
return `(${parts.join(" || ")})`;
}
function exact(row) {
for (const mark of row.pop().split(",")) {
if (mark.endsWith("~")) row[mark.slice(0, -1)] = { real: row[mark.slice(0, -1)] };
else if (mark) row[mark.split("=")[0]] = BigInt(mark.split("=")[1]);
}
return row;
}
// One page of the object, and the cursor of the next (null after the last).
// Items: ["table", ddl], ["kv", key, value], ["rows", table, columns, rows],
// ["schema", ddl], and ["seq", table, n]. Pages agree with each other only
// while nothing writes to the object.
async function exportPage(ctx, cursor) {
const sql = sqlOf(ctx);
const items = [];
let bytes = 0;
const full = () => bytes >= PAGE_BYTES || items.length >= PAGE_ITEMS;
let c = cursor ?? { step: "tables" };
if (c.step === "tables") {
for (const t of tables(sql)) items.push(["table", t.ddl]);
for (const s of schema(sql, "index")) items.push(["schema", s.sql]);
c = { step: "kv", after: null };
}
while (c.step === "kv" && !full()) {
// A few keys at a time: one value can take up to 2 MB.
const page = await ctx.storage.list(c.after === null ? { limit: 16 } : { limit: 16, startAfter: c.after });
let taken = 0;
for (const [key, value] of page) {
items.push(["kv", key, value]);
bytes += size(key) + size(value);
c.after = key;
taken++;
if (full()) break;
}
if (taken === page.size && page.size < 16) c = { step: "rows", table: 0, after: null };
}
const list = c.step === "rows" ? rowOrder(sql, tables(sql)) : [];
while (c.step === "rows" && !full()) {
const t = list[c.table];
if (!t) {
c = { step: "rest" };
break;
}
// Each batch is read twice, the rows' sizes first and then the rows that
// fit the page, and every query is read to its end: celld keeps a table
// locked while a query on it is unfinished.
const select = (what, limit) => {
const where = t.rowid && c.after !== null ? `WHERE ${t.rowid} > CAST(? AS INTEGER)` : "";
const order = t.rowid
? `ORDER BY ${t.rowid} LIMIT ${limit}`
: `${t.key.length ? `ORDER BY ${t.key.join(", ")} ` : ""}LIMIT ${limit} OFFSET ${c.after ?? 0}`;
return [...sql.exec(`SELECT ${what} FROM ${q(t.name)} ${where} ${order}`, ...(where ? [c.after] : [])).raw()];
};
const sizes = select(t.columns.map((col) =>
`iif(typeof(${q(col)}) IN ('text', 'blob'), octet_length(${q(col)}) + 8, 16)`).join(" + "), 500);
let take = 0;
let stopped = false;
while (take < sizes.length && !stopped) {
bytes += sizes[take++][0];
stopped = full();
}
const names = t.rowid ? [t.rowid, ...t.columns] : t.columns;
const picked = names.map((name) => (name === t.rowid ? name : q(name)));
const rows = take ? select([...picked, exactness(t, names)].join(", "), take).map(exact) : [];
if (rows.length) items.push(["rows", t.name, names, rows]);
if (!stopped && sizes.length < 500) c = { step: "rows", table: c.table + 1, after: null };
else c = { ...c, after: t.rowid ? String(rows.at(-1)[0]) : (c.after ?? 0) + rows.length };
}
if (c.step === "rest" && !full()) {
for (const s of schema(sql, "trigger", "view")) items.push(["schema", s.sql]);
if (sql?.exec("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'").toArray().length) {
for (const s of sql.exec("SELECT name, CAST(seq AS TEXT) AS seq FROM sqlite_sequence")) items.push(["seq", s.name, s.seq]);
}
c = null;
}
return [items, c];
}
// Load one exported page. The first page of an object empties it first, so
// an import can run again. Alarms come last, from do-migrate.mjs alarms.
async function importPage(ctx, items, first) {
const sql = sqlOf(ctx);
if (first) {
await ctx.storage.deleteAlarm();
await ctx.storage.deleteAll();
for (const s of schema(sql, "trigger", "view").reverse()) {
sql.exec(`DROP ${s.type.toUpperCase()} IF EXISTS ${q(s.name)}`);
}
for (const t of tables(sql).reverse()) sql.exec(`DROP TABLE IF EXISTS ${q(t.name)}`);
}
const kv = [];
const apply = () => {
sql?.exec("PRAGMA defer_foreign_keys = ON");
for (const item of items) {
if (item[0] === "table" || item[0] === "schema") sql.exec(item[1]);
else if (item[0] === "kv") sql ? ctx.storage.kv.put(item[1], item[2]) : kv.push(item);
else if (item[0] === "rows") {
const [, table, columns, rows] = item;
const into = `INSERT INTO ${q(table)} (${columns.map(q).join(", ")}) VALUES `;
const insert = `${into}(${columns.map(() => "?").join(", ")})`;
const big = (v) => typeof v === "bigint";
const real = (v) => v?.real !== undefined;
for (const row of rows) {
if (!row.some((v) => big(v) || real(v))) sql.exec(insert, ...row);
else {
sql.exec(`${into}(${row.map((v) => big(v) ? "CAST(? AS INTEGER)" : real(v) ? "CAST(? AS REAL)" : "?").join(", ")})`,
...row.map((v) => big(v) ? String(v) : real(v) ? v.real : v));
}
}
} else if (item[0] === "seq") {
sql.exec("DELETE FROM sqlite_sequence WHERE name = ?", item[1]);
sql.exec("INSERT INTO sqlite_sequence (name, seq) VALUES (?, CAST(? AS INTEGER))", item[1], item[2]);
}
}
};
if (sql) ctx.storage.transactionSync(apply);
else apply();
for (let i = 0; i < kv.length; i += 128) {
await ctx.storage.put(Object.fromEntries(kv.slice(i, i + 128).map(([, k, v]) => [k, v])));
}
return { ok: true, items: items.length };
}
EOF
Now hook it up, along with a switch that freezes the Worker for the
move. In the Worker's fetch, first thing:
import { isMigration, migrationRoute, migrateObject } from "./do-migration.js";
// in export default { async fetch(request, env) { ... } }
if (isMigration(request)) return migrationRoute(request, env);
if (env.MIGRATION_FREEZE) {
return new Response("Moving house. Back in a few minutes.\n", {
status: 503,
headers: { "Retry-After": "300" },
});
}
In every Durable Object class, first thing in fetch, with the ctx (or
state) its constructor received:
// A class with a fetch of its own: add the first line.
async fetch(request) {
if (isMigration(request)) return migrateObject(this.ctx, request);
// ...the rest of your fetch, unchanged
}
// A class without one, or whose base class has it (Agents, PartyServer):
async fetch(request) {
if (isMigration(request)) return migrateObject(this.ctx, request);
return super.fetch?.(request) ?? new Response("not found", { status: 404 });
}
The freeze stops requests, not timers. So that nothing writes during the
final copy, start each alarm() by putting itself off while the Worker is
frozen; the alarm then moves with its object:
async alarm() {
if (this.env.MIGRATION_FREEZE) return this.ctx.storage.setAlarm(Date.now() + 60_000);
// ...the rest of your alarm, unchanged
}
A scheduled handler should return early instead. Let queues drain
before the move.
Deploy both copies#
Make the token and the settings the scripts share. The settings file opens all of your data: keep it private.
mkdir -p ~/migration && cd ~/migration
if [ ! -e settings ]; then
touch settings && chmod 600 settings
cat >> settings <<EOF
# Settings for do-migrate.mjs and Wrangler. They open all of your data:
# delete this file when the move is done.
MIGRATION_TOKEN=$(openssl rand -hex 32)
FROM_URL=https://chat.example.workers.dev
TO_URL=http://localhost:8000
WORKER_NAME=chat
CLOUDFLARE_ACCOUNT_ID=paste-the-account-id
CLOUDFLARE_API_TOKEN=paste-the-api-token
EOF
fi
nano settings
cat > run <<'EOF'
#!/bin/sh
# Run a command with the migration settings: ~/migration/run npx wrangler whoami
set -a
. "$HOME/migration/settings"
set +a
exec "$@"
EOF
chmod +x run
Set FROM_URL to the Worker's address on Cloudflare and WORKER_NAME to
the name it's deployed under; leave TO_URL alone. Give celld's copy the
token, and deploy it:
command -v jq >/dev/null || sudo apt-get install -y jq
cd ~/app
~/migration/run sh -c 'jq --arg t "$MIGRATION_TOKEN" ".vars.MIGRATION_TOKEN = \$t" celld.json > celld.json.new'
mv celld.json.new celld.json && chmod 600 celld.json
~/celld/run celld deploy ~/app/celld.json
Now start celld: follow the celld recipe's Start celld
and Set up snapshots, skipping Try
it and the restore test, which use the chat. From then on,
~/celld/deploy ~/app/celld.json ships changes. Keep a snapshot of the
app before any data arrives; the move starts from it:
cp -r "$(~/celld/snapshot)" ~/migration/empty-celld
The Cloudflare copy needs the same token. Ask the owner before this step: it deploys to production, and every object restarts, so open WebSockets reconnect.
cd ~/app
~/migration/run npx wrangler deploy
~/migration/run sh -c 'printf %s "$MIGRATION_TOKEN" | npx wrangler secret put MIGRATION_TOKEN'
If you'd rather deploy from your own computer or CI as usual, do; the
secret's value is MIGRATION_TOKEN in ~/migration/settings. Check both
copies answer to the token, and only to it:
~/migration/run sh -c 'curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $MIGRATION_TOKEN" "$FROM_URL/__migrate/ROOMS/id?name=lobby"'
~/migration/run sh -c 'curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $MIGRATION_TOKEN" "$TO_URL/__migrate/ROOMS/id?name=lobby"'
~/migration/run sh -c 'curl -s -o /dev/null -w "%{http_code}\n" "$FROM_URL/__migrate/ROOMS/id?name=lobby"'
Expect 200, 200, and 403.
Copy the objects#
This script drives the copy, one binding at a time. It needs Node 18 or newer and nothing else:
cat > ~/migration/do-migrate.mjs <<'EOF'
#!/usr/bin/env node
// do-migrate.mjs: copy Durable Objects from one Worker to another through
// the /__migrate/ routes of do-migration.js. Node 18 or later.
//
// node do-migrate.mjs list BINDING CLASS objects with data (Cloudflare API)
// node do-migrate.mjs export BINDING save them from FROM_URL
// node do-migrate.mjs import BINDING load them into TO_URL
// node do-migrate.mjs verify BINDING export from TO_URL and compare
// node do-migrate.mjs alarms BINDING set their alarms in TO_URL
//
// Settings come from the file "settings" beside this one: MIGRATION_TOKEN,
// FROM_URL, TO_URL, and for list, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN,
// and WORKER_NAME. Files live in BINDING/ beside it: ids.txt (from list),
// names.txt (yours: object names, one per line), unnamed.txt, and
// objects/<id>.ndjson: a header line, one line per page, and the alarm.
// An object exported without its name is imported under the name
// "cloudflare:<id>".
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const [command, binding, className] = process.argv.slice(2);
if (!binding || !["list", "export", "import", "verify", "alarms"].includes(command)) {
fail("usage: node do-migrate.mjs list|export|import|verify|alarms BINDING [CLASS]");
}
// settings: NAME=value lines, as in a shell. The environment wins.
for (const line of (await fs.readFile(path.join(here, "settings"), "utf8").catch(() => "")).split("\n")) {
const [, name, value] = line.match(/^\s*([A-Za-z_]\w*)=(.*)$/) ?? [];
if (name && !(name in process.env)) process.env[name] = value.trim();
}
const dir = path.join(here, binding);
const objectsDir = path.join(dir, "objects");
const PARALLEL = 8;
function fail(message) {
console.error(`do-migrate: ${message}`);
process.exit(1);
}
function env(name) {
return process.env[name] || fail(`set ${name} in ${path.join(here, "settings")}`);
}
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
const importName = (o) => o.name ?? `cloudflare:${o.id}`;
// One /__migrate/ request. A 5xx or a network error is retried, up to
// `tries` times; any other failure throws at once.
async function call(base, action, query, init = {}, tries = 5) {
const url = `${base.replace(/\/$/, "")}/__migrate/${binding}/${action}?${new URLSearchParams(query)}`;
for (let attempt = 1; ; attempt++) {
const response = await fetch(url, {
...init,
headers: { Authorization: `Bearer ${env("MIGRATION_TOKEN")}` },
}).catch((error) => ({ ok: false, status: 0, text: async () => String(error.cause ?? error) }));
if (response.ok) return response;
const detail = (await response.text()).trim().split("\n")[0];
if ((response.status > 0 && response.status < 500) || attempt >= tries) {
throw new Error(`${action} ${url}: ${response.status} ${detail}`);
}
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
// The pages of one object, as text, one at a time; then its alarm.
async function* exportPages(base, query) {
let cursor = null;
do {
const response = await call(base, "export", { ...query, cursor: JSON.stringify(cursor) });
yield await response.text();
cursor = JSON.parse(response.headers.get("X-Migrate-Cursor"));
if (cursor === null) return JSON.parse(response.headers.get("X-Migrate-Alarm") ?? "null");
} while (true);
}
async function lines(file) {
const text = await fs.readFile(file, "utf8").catch(() => "");
return text.split("\n").map((l) => l.trim()).filter(Boolean);
}
async function inParallel(list, work) {
let next = 0;
let done = 0;
await Promise.all(Array.from({ length: PARALLEL }, async () => {
while (next < list.length) {
await work(list[next++]);
if (++done % 100 === 0) console.log(`${done}/${list.length}`);
}
}));
}
// The lines of a file, read as they are needed.
async function* fileLines(file) {
const input = createReadStream(file);
try {
yield* readline.createInterface({ input, crlfDelay: Infinity });
} finally {
input.destroy();
}
}
// The objects of the last export, which must have finished.
async function objectFiles() {
const files = await fs.readdir(objectsDir).catch(() => []);
if (!files.includes(".complete")) fail(`no finished export in ${objectsDir}; run export first`);
return files.filter((f) => f.endsWith(".ndjson")).map((f) => path.join(objectsDir, f));
}
// An exported object: its id, name, and alarm, and its pages, read from
// disk one at a time.
async function readObject(file) {
let header = null;
let last = null;
for await (const line of fileLines(file)) {
header ??= JSON.parse(line);
last = line;
}
return {
file: path.basename(file), ...header, ...JSON.parse(last),
async *pages() {
for await (const line of fileLines(file)) if (line.startsWith("[")) yield line;
},
};
}
async function main() {
if (command === "list") {
if (!className) fail("usage: node do-migrate.mjs list BINDING CLASS");
const api = async (p) => {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env("CLOUDFLARE_ACCOUNT_ID")}/workers/durable_objects/${p}`,
{ headers: { Authorization: `Bearer ${env("CLOUDFLARE_API_TOKEN")}` } });
const body = await response.json();
if (!body.success) fail(`Cloudflare API ${p}: ${JSON.stringify(body.errors)}`);
return body;
};
let namespace;
for (let page = 1; !namespace; page++) {
const { result } = await api(`namespaces?per_page=1000&page=${page}`);
namespace = result.find((n) => n.script === env("WORKER_NAME") && n.class === className);
if (!namespace && result.length < 1000) fail(`${env("WORKER_NAME")} has no Durable Object class ${className}`);
}
const ids = [];
let cursor = "";
do {
const query = `limit=10000${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
const body = await api(`namespaces/${namespace.id}/objects?${query}`);
for (const o of body.result) if (o.hasStoredData) ids.push(o.id);
cursor = body.result.length ? body.result_info?.cursor ?? "" : "";
} while (cursor);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "ids.txt"), ids.map((id) => `${id}\n`).join(""));
console.log(`${count(ids.length, `${className} object`)} with data -> ${path.join(dir, "ids.txt")}`);
}
if (command === "export") {
const from = env("FROM_URL");
await fs.rm(objectsDir, { recursive: true, force: true });
const named = new Map();
await inParallel(await lines(path.join(dir, "names.txt")), async (name) => {
named.set((await (await call(from, "id", { name })).json()).id, name);
});
const listed = await fs.access(path.join(dir, "ids.txt")).then(() => true, () => false);
const ids = listed ? await lines(path.join(dir, "ids.txt")) : [...named.keys()];
if (!listed && !named.size) fail(`run list first, or write object names to ${path.join(dir, "names.txt")}`);
const names = new Set(named.values());
const clash = ids.find((id) => !named.has(id) && names.has(`cloudflare:${id}`));
if (clash) fail(`an object is named cloudflare:${clash}, where the object ${clash} would go; name that one too`);
await fs.mkdir(objectsDir, { recursive: true });
await inParallel(ids, async (id) => {
const name = named.get(id) ?? null;
const file = path.join(objectsDir, `${id}.ndjson`);
const out = await fs.open(`${file}.part`, "w");
await out.write(`${JSON.stringify({ id, name })}\n`);
const pages = exportPages(from, name === null ? { id } : { name });
let step = await pages.next();
for (; !step.done; step = await pages.next()) await out.write(`${step.value}\n`);
await out.write(`${JSON.stringify({ alarm: step.value })}\n`);
await out.close();
await fs.rename(`${file}.part`, file);
});
const unnamed = ids.filter((id) => !named.has(id));
await fs.writeFile(path.join(dir, "unnamed.txt"), unnamed.map((id) => `${id}\n`).join(""));
await fs.writeFile(path.join(objectsDir, ".complete"), "");
console.log(`exported ${count(ids.length, "object")} to ${objectsDir}: ${ids.length - unnamed.length} by name, ${unnamed.length} by id only`);
const withData = new Set(ids);
const empty = listed ? [...named.keys()].filter((id) => !withData.has(id)).length : 0;
if (empty) console.log(`${count(empty, "name")} in names.txt had no stored data to copy`);
}
if (command === "import") {
const to = env("TO_URL");
const files = await objectFiles();
let alarms = 0;
await inParallel(files, async (file) => {
const o = await readObject(file);
if (o.alarm != null) alarms++;
// A page that fails midway may have landed, so start the object over.
for (let attempt = 1; ; attempt++) {
try {
let first = true;
for await (const page of o.pages()) {
const query = first ? { name: importName(o), first: "1" } : { name: importName(o) };
await call(to, "import", query, { method: "POST", body: page }, 1);
first = false;
}
return;
} catch (error) {
if (attempt === 3) throw error;
await new Promise((r) => setTimeout(r, 2000 * attempt));
}
}
});
console.log(`imported ${count(files.length, "object")} into ${to}`);
if (alarms) console.log(`${count(alarms, "alarm")} not set yet: run alarms when you make the move`);
}
if (command === "verify") {
const to = env("TO_URL");
const files = await objectFiles();
const differ = [];
await inParallel(files, async (file) => {
const o = await readObject(file);
const theirs = exportPages(to, { name: importName(o) });
let same = true;
for await (const page of o.pages()) {
const step = await theirs.next();
if (step.done || step.value !== page) {
same = false;
break;
}
}
if (same && !(await theirs.next()).done) same = false;
if (!same) differ.push(`${o.file} (${importName(o)})`);
});
if (differ.length) fail(`${differ.length} of ${count(files.length, "object")} differ:\n${differ.join("\n")}`);
console.log(`verified ${count(files.length, "object")}: identical in ${to}`);
}
if (command === "alarms") {
const to = env("TO_URL");
let set = 0;
await inParallel(await objectFiles(), async (file) => {
const o = await readObject(file);
if (o.alarm == null) return;
await call(to, "alarm", { name: importName(o), at: String(o.alarm) }, { method: "POST" });
set++;
});
console.log(`set ${count(set, "alarm")} in ${to}`);
}
}
await main().catch((error) => fail(error.message));
EOF
For each binding, list the objects that have data. For a class addressed
by name, write the names you know to ~/migration/<BINDING>/names.txt,
one per line, before you export:
node ~/migration/do-migrate.mjs list ROOMS Room
mkdir -p ~/migration/ROOMS
printf '%s\n' lobby general > ~/migration/ROOMS/names.txt
Then copy and check:
node ~/migration/do-migrate.mjs export ROOMS
node ~/migration/do-migrate.mjs import ROOMS
node ~/migration/do-migrate.mjs verify ROOMS
export reports how many objects it found by name and by id only, and
writes the latter to unnamed.txt. verify exports every object back
out of celld and compares it with Cloudflare's, page by page; expect
verified N objects: identical. Each export starts over, and each
import replaces the objects it brings, so you can repeat any step. Alarms
stay on Cloudflare during the dry run, so nothing fires twice; the move
sets them with alarms.
That's the dry run. Report the numbers, and open
https://my-worker.exe.xyz/ to look around.
Something not working?
403from the migration route: the token differs between~/migration/settings, the Cloudflare secret, andcelld.json, or the latest version isn't deployed yet.404 want /__migrate/...: the binding name in the command isn't inenv. Use the binding (ROOMS), not the class (Room).500with a stack trace: the object threw while exporting or importing; the trace names the line.- Some other answer from
exportorimport, such as426or one of your app's pages: that class'sfetchlacks themigrateObjecthook, so your own code answered. listfinds no class:WORKER_NAMEmust be the deployed name, including any environment suffix such aschat-production.- Many objects "by id only" in a class you address by name: add their
names to
names.txtand runexport,import, andverifyagain. Their earlier copies undercloudflare:<id>stay behind, unused. FOREIGN KEY constraint failedon import: a row reached celld on an earlier page than the row it refers to. Tables go out parents first, but a table that refers to itself, or two that refer to each other, can still send a row early. RaisePAGE_BYTESandPAGE_ITEMSindo-migration.js, deploy both copies, and runexportandimportagain, so that the object fits one page.contentless full-text tableon export: an FTS5 table made withcontent=''keeps only its index, which can't be copied. Drop it on Cloudflare and rebuild it on celld from the data it indexed.- Trying the move again after backing out: first turn celld back on,
with
sudo systemctl enable --now celld celld-snapshot.timer. celld devorcelld deployrefuses the config: a key incelld.jsonis one celld doesn't support; remove it.- A build step makes your Worker (Vite, a framework): build first,
and point
mainandassetsincelld.jsonat the output.
On the VM, journalctl -u celld -n 100 shows celld's side.
Frequently asked questions#
Why not just copy the SQLite files?
Cloudflare doesn't hand them out. Each object's storage is private to the object, so the only way in is your own code, running inside it. That's what the migration route is.
How long is my app down?
Only for the final copy: from freezing the Worker until your domain points at the VM. The dry run tells you how long the copy takes; add a few minutes for DNS.
What about KV, D1, R2, and Queues?
They have their own tools. D1: wrangler d1 export --remote, then celld d1 execute --file. KV: celld kv bulk put reads Wrangler's bulk format. R2: copy the objects into your celld bucket under r2/<bucket_name>/, for example with rclone. Let queues drain before you freeze. See celld's services.
Does my code change?
For good, a little: a celld.json, and a three-line fallback if you use newUniqueId(). For the move, a migration route that you remove afterwards. More only if you use something celld lacks, such as Workers AI, Vectorize, Hyperdrive, Browser Rendering, email, or jurisdictions; see Cloudflare compatibility.
Can I go back?
Until your domain moves, yes: stop celld and unfreeze the Worker, and Cloudflare carries on as if nothing happened. After that, new writes exist only on celld. The scripts can copy objects back by name, but treat the move as one-way.
What does it cost?
Your VM plan and your bucket; the celld recipe has the details. On Cloudflare, each export reads every key and row at least once, and those reads are billed as usual.
Updated Oct 16, 2026 · Markdown · manifest.json · Something wrong? Tell us