Skip to content

Migrate a Durable Object from Cloudflare

Same Worker, same objects, new home. Every key and row checked.

Deploy on exe.dev

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/stdin

Cloudflare 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 ~/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.
What's running, and where
Private https://my-worker.exe.xyz/ Your Worker on celld, until you move your domain
my-worker.exe.xyz exe.dev VM
celld 0.6 Runs your Worker, set up as in A Durable Object with celld :8000~/celld/state
Your Worker Your project, with a celld.json beside its Wrangler config ~/app
Migration files Every object's export, one file each; also your fallback copy ~/migration
Your bucket Every object's database, saved before celld answers R2, S3, or Tigris

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 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)
  • 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
Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/migrate-durable-objects/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin
What 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#

  1. celld runs your Worker on the VM, with an empty bucket.
  2. A dry run copies every object while Cloudflare keeps serving. You try the app on the VM, where your changes stay.
  3. 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:

  1. 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.
  2. 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.js and its hooks, npm uninstall devalue, take MIGRATION_TOKEN out of celld.json, and run ~/celld/deploy ~/app/celld.json. Keep the newUniqueId() fallback.
  • Delete ~/migration/settings and revoke the Cloudflare API token.
  • Keep ~/migration itself, 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 any kv_namespaces, d1_databases, r2_buckets, queues, workflows, services, define, and rules. Hold back cron triggers until the move, so no job runs on both sides. A wrangler.toml converts with python3 -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, and dev. If you deploy with --env, use that environment's values instead.
  • Replace migrations (or exports) 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 makes celld.json secret, 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?
  • 403 from the migration route: the token differs between ~/migration/settings, the Cloudflare secret, and celld.json, or the latest version isn't deployed yet.
  • 404 want /__migrate/...: the binding name in the command isn't in env. Use the binding (ROOMS), not the class (Room).
  • 500 with a stack trace: the object threw while exporting or importing; the trace names the line.
  • Some other answer from export or import, such as 426 or one of your app's pages: that class's fetch lacks the migrateObject hook, so your own code answered.
  • list finds no class: WORKER_NAME must be the deployed name, including any environment suffix such as chat-production.
  • Many objects "by id only" in a class you address by name: add their names to names.txt and run export, import, and verify again. Their earlier copies under cloudflare:<id> stay behind, unused.
  • FOREIGN KEY constraint failed on 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. Raise PAGE_BYTES and PAGE_ITEMS in do-migration.js, deploy both copies, and run export and import again, so that the object fits one page.
  • contentless full-text table on export: an FTS5 table made with content='' 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 dev or celld deploy refuses the config: a key in celld.json is one celld doesn't support; remove it.
  • A build step makes your Worker (Vite, a framework): build first, and point main and assets in celld.json at 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