---
title: "Migrate a Durable Object from Cloudflare"
description: "Move a Cloudflare Worker and its Durable Objects' data to celld on an exe.dev VM: same code, every object copied and checked, in your own bucket."
url: https://exe-staging.dev/deploy/migrate-durable-objects
category: "Migration"
updated: 2026-10-16
---

# Migrate a Durable Object from Cloudflare

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

- **Software:** Durable Objects from Cloudflare (https://celld.dev)
- **Source:** https://github.com/denoland/celld
- **License:** Apache-2.0 (celld)
- **Time:** 1 hour
- **Example VM name:** `my-worker` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe-staging.dev/deploy/migrate-durable-objects/manifest.json

## 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.

Running on the VM:

- **celld 0.6**: Runs your Worker, set up as in [A Durable Object with celld](/deploy/celld), port 8000, data in `~/celld/state`
- **Your Worker**: Your project, with a `celld.json` beside its Wrangler config, data in `~/app`
- **Migration files**: Every object's export, one file each; also your fallback copy, data in `~/migration`
- **Your bucket**: Every object's database, saved before celld answers, data in `R2, S3, or Tigris`

How it is reached:

- https://my-worker.exe.xyz/ (private): Your Worker on celld, until you move your domain

## Deploy with Shelley

Open https://exe-staging.dev/new?prompt=APP_REPO%3D%3Cfill-this-in%3E%0AFROM_URL%3D%3Cfill-this-in%3E%0ACLOUDFLARE_ACCOUNT_ID%3D%3Cfill-this-in%3E%0ACLOUDFLARE_API_TOKEN%3D%3Cfill-this-in%3E%0ACELLD_BUCKET%3D%3Cfill-this-in%3E%0AS3_ENDPOINT%3D%3Cfill-this-in%3E%0AAWS_REGION%3D%3Cfill-this-in%3E%0AAWS_ACCESS_KEY_ID%3D%3Cfill-this-in%3E%0AAWS_SECRET_ACCESS_KEY%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Durable%20Objects%20from%20Cloudflare%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe-staging.dev%2Fdeploy%2Fmigrate-durable-objects.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20APP_REPO%3A%20your%20Worker%27s%20Git%20repository%3B%20for%20a%20private%20GitHub%20repository%2C%20connect%20the%20%5BGitHub%20integration%5D%28%2Fdocs%2Fintegrations-github%29%20first%0A-%20FROM_URL%3A%20where%20the%20Worker%20answers%20on%20Cloudflare%3B%20its%20%60workers.dev%60%20address%20is%20best%2C%20because%20it%20stays%20put%20when%20your%20domain%20moves%0A-%20CLOUDFLARE_ACCOUNT_ID%3A%20the%20account%20that%20runs%20the%20Worker%0A-%20CLOUDFLARE_API_TOKEN%3A%20an%20API%20token%20from%20the%20%2A%2AEdit%20Cloudflare%20Workers%2A%2A%20template%0A-%20CELLD_BUCKET%3A%20an%20empty%20bucket%20for%20celld%2C%20written%20%60s3%3A%2F%2Fname%60%20%28%5Bhow%5D%28%2Fdeploy%2Fcelld%23get-a-bucket%29%29%0A-%20S3_ENDPOINT%3A%20R2%3A%20%60https%3A%2F%2F%3CACCOUNT_ID%3E.r2.cloudflarestorage.com%60%3B%20Amazon%20S3%3A%20leave%20empty%0A-%20AWS_REGION%3A%20%60auto%60%20for%20R2%20and%20Tigris%3B%20the%20bucket%27s%20region%20for%20Amazon%20S3%0A-%20AWS_ACCESS_KEY_ID%3A%20a%20key%20that%20can%20read%20and%20write%20that%20bucket%2C%20and%20nothing%20else%0A-%20AWS_SECRET_ACCESS_KEY%3A%20the%20key%27s%20secret%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-worker.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-worker.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Your%20Worker%20running%20on%20celld%20at%20%60https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%60%2C%20private%2C%20with%20its%20state%20in%20your%20bucket.%0A-%20Every%20Durable%20Object%20with%20data%20on%20Cloudflare%20exported%20to%20%60~%2Fmigration%60%20and%20imported%20into%20celld.%0A-%20Each%20imported%20object%20checked%20identical%20to%20its%20export%20from%20Cloudflare.%0A-%20A%20report%20of%20what%20moved%2C%20which%20objects%20had%20no%20name%2C%20and%20anything%20celld%20can%27t%20run.%0A-%20Cloudflare%20serving%20as%20before%2C%20plus%20a%20token-protected%20export%20route.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details%2C%20and%20do%20%22Prepare%20the%0Amove%20by%20hand%22%20in%20order%2C%20skipping%20VM%20creation%3B%20the%20walkthrough%20above%20it%20is%0Afor%20the%20owner.%20Where%0Ait%20names%20steps%20of%20the%20celld%20recipe%2C%20follow%20%2Fdeploy%2Fcelld.md%20on%20this%20site.%0AAsk%20the%20owner%20before%20deploying%20anything%20to%20Cloudflare.%20Never%20freeze%20the%0AWorker%2C%20change%20DNS%2C%20run%20share%20set-public%2C%20or%20delete%20anything%20on%0ACloudflare%3A%20making%20the%20move%20is%20the%20owner%27s%20call.%0AKeep%20the%20tokens%20and%20bucket%20keys%20out%20of%20your%20replies%20and%20out%20of%20Git%3B%0Acelld.json%20holds%20secrets%2C%20so%20make%20sure%20Git%20ignores%20it.%0AIf%20the%20Worker%20uses%20something%20celld%20lacks%2C%20stop%20and%20report%20it%20rather%20than%0Arewriting%20the%20app.%20Before%20exporting%20a%20class%20that%20is%20addressed%20by%20name%2C%0Aask%20the%20owner%20where%20its%20names%20are%20recorded.%0AFinish%20with%20the%20dry%20run%27s%20numbers%20per%20binding%20%28by%20name%2C%20by%20id%20only%29%2C%0Ahttps%3A%2F%2F%3Cvm%3E.exe.xyz%2F%2C%20and%20the%20owner%27s%20steps%20in%20%22Make%20the%20move%22.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open.%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe-staging.dev/deploy/migrate-durable-objects/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

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](/deploy/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](/deploy/celld). Use your own names.

## 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.

```bash
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:

```bash
~/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:

```bash
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](/docs/cnames).
2. On your computer, register the domain and open the VM to everyone:

```bash
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:

```bash
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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Prepare the move by hand</summary>

### Set up celld

On your computer (pick another name if `my-worker` is taken):

```bash
ssh exe.dev new --name=my-worker
ssh my-worker.exe.xyz
```

On the VM, follow the celld recipe's [Install celld](/deploy/celld#install-celld)
and [Connect the bucket](/deploy/celld#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):

```bash
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:

```bash
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](https://celld.dev/docs/cloudflare-compat)) 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)`:

```js
// 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:

```bash
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](https://github.com/sveltejs/devalue)
to carry every kind of stored value, from `Date`s to `ArrayBuffer`s:

```bash
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:

```js
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:

```js
// 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:

```js
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.

```bash
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:

```bash
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](/deploy/celld#start-celld)
and [Set up snapshots](/deploy/celld#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:

```bash
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.

```bash
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:

```bash
~/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:

```bash
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:

```bash
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:

```bash
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](#look-around).

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

- **`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.

</details>

## 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](https://celld.dev/docs#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](https://celld.dev/docs/cloudflare-compat).

### 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](https://exe.dev/pricing) and your bucket; the [celld recipe](/deploy/celld) has the details. On Cloudflare, each export reads every key and row at least once, and those reads are billed as usual.


## Related

- [A Durable Object with celld](https://exe-staging.dev/deploy/celld.md): Durable Objects on a VM you own. Same code, your bucket.
- [Migrate a DigitalOcean Droplet to exe.dev](https://exe-staging.dev/deploy/migrate-digitalocean-droplet.md): Same server, new home. Rehearsed while the droplet keeps serving.
- [Self-host n8n](https://exe-staging.dev/deploy/n8n.md): Connect your apps. Let the boring bits run themselves.
- [Run Claude Code on a remote server](https://exe-staging.dev/deploy/claude-code.md): A computer for Claude. A breather for your laptop.
- [Host OpenClaw](https://exe-staging.dev/deploy/openclaw.md): An AI sidekick with its own computer. Your laptop can take a nap.
- [VS Code in your browser, with code-server](https://exe-staging.dev/deploy/code-server.md): Your editor in a tab. Your projects right where you left them.
