Add one call to this line, deploy, and every existing workspace looks empty:

const stub = env.WORKSPACES.jurisdiction("eu").getByName(workspaceId); 

Nothing is lost. Cloudflare just doesn't move a Durable Object when you ask for a jurisdiction. The same name in the EU is a different, empty object, with a different id and its own storage. An object's jurisdiction is fixed when it's created, so moving one means making a new object and copying the data yourself.

I build a small set of open-source tools that run entirely on Cloudflare Workers, and each workspace lives in its own Durable Object: an issue tracker's issues, an agent's conversations, and so on. This week I moved all of it into the EU, because the people I want to sell the hosted version to are European companies, and "where is our data?" is the first question their lawyers ask. This is how I copied live workspaces without losing writes, and six things that surprised me on the way.

Knowing where each workspace lives

After the move, some workspaces are in the EU and some aren't yet, so the code can't guess. I added a workspace_location table in D1, and every place that opens a workspace's object reads it first. New workspaces are created in the EU from the start. (D1 and R2 were easier: you create a new database or bucket with the jurisdiction and copy into it.)

Moving one workspace

A cron run moves a few workspaces each day. For each one:

  1. Freeze the old object. SQLite triggers on every table refuse writes, its WebSockets close and its alarm waits. Reads still work.
  2. Copy every table, page by page, over RPC into the new object. The new object makes each table from the old one's own CREATE statement in sqlite_schema, so a table a migration added only there comes along too.
  3. Check row counts and a digest on both sides.
  4. Commit by updating workspace_location in D1. From that moment, requests go to the new object.
  5. Seal the old object. It still answers reads, but it never takes another write.

If anything fails before the commit, the old object thaws and the workspace carries on where it was. The next day's run tries again.

My own production workspace, about 17,500 rows, took 3.2 seconds. The other product's largest workspace (8 conversations, each its own object) took about 11 seconds, most of it round trips.

What surprised me

1. Secrets sealed with the object's id. One product encrypts API keys with a key derived (HKDF) from a server secret and the Durable Object's id, so a key copied to another object can't be opened there. A moved object has a new id, so after the copy every stored key failed to decrypt. The fix: the copy stores the original id and derives keys from that.

2. An idle frozen object gets evicted, and a restart thawed it. In the agents product, a workspace is one registry object plus one object per conversation. While its conversations were being copied, the frozen registry sat idle. On staging, Cloudflare evicted it after about 30 seconds. My constructor treated a restart as "the move must have died" and ended the freeze, so the move failed its final check. Worse, in production a restart in that window could have let a write land after the copy. Now the freeze is stored with a deadline (10 minutes). It holds through restarts and only lapses if the move really died.

3. The ocean is in the hot path. The Worker runs near the user. When the user is in the US and the object is in the EU, every RPC call crosses the Atlantic. A seed script that wrote a demo workspace one call per row went from fast to about 20 seconds. Batching the same writes into two calls brought it under 2 seconds. Anything that loops over RPC to an EU object needs batching.

4. Not everything can be pinned. Workflows take a jurisdiction in their config; I created mine again under a new name with it set. Containers started from a Durable Object can't be pinned at all: placement constraints are rejected next to scheduling_policy: "durable_object". Analytics Engine has no jurisdiction option, and Workers Logs aren't covered either. I wrote each of these into the privacy policy, because "everything is in the EU" would have been false.

5. Local workerd ignores jurisdictions. Tests can't check "is it in the EU". Mine move a workspace to another name instead, which to the move code is just another set of stubs. It was also enough to reproduce the eviction bug above in a test, by restarting the object mid-move, before I fixed it.

6. D1 imports care about order. Moving D1 into an EU database meant wrangler d1 export and an import into a new database. The import failed with "foreign key mismatch": one table's foreign key pointed at a column that only a unique index makes a key, and the export put the rows before that index. I reordered the dump (tables, then indexes, then rows, parent tables first, then triggers). The import is atomic, so a failed one leaves the database empty, which made retrying safe.

Was it worth it?

For me, yes. The move code is a few hundred lines per product, and most of the work was tests. If you're starting a new Cloudflare project and you might ever care where data lives, set the jurisdiction from day one. Moving later costs much more than starting there.

Did you set a jurisdiction from day one, or are you looking at a move like this? I'd like to hear what bit you.

The code is open source if you want the details: packages/sync-server/src/move.ts in the tracker's repository.

I drafted this with an AI assistant (Claude), which also wrote much of the code it describes. I checked the facts and numbers against the code and the logs.