Skip to content
DocsconceptsDocumentation

Data residency

Pin Durable Objects to a Cloudflare jurisdiction with .jurisdiction(), and the export→import runbook for moving regions.

Last updated:

Cloudflare Durable Object jurisdictions restrict where a DO runs and stores data, for GDPR, FedRAMP, or US data residency. Declare one on your schema and Lunora pins every DO the app reaches to that region.

import { defineSchema, defineTable, v } from "lunorash/server";

export const schema = defineSchema({
    messages: defineTable({ channelId: v.id("channels"), text: v.string() }).shardBy("channelId"),
}).jurisdiction("eu");

Supported values: "eu", "us", "fedramp" (Cloudflare adds more over time). It composes with .rls(...) and .extend(...) in any order.

What it pins

Codegen reads .jurisdiction(...) off the schema and threads it through the generated worker, so a single declaration pins every Durable Object the app reaches:

  • Shard DOs: every shard-local table (__root__ and .shardBy(...)), so all reads, writes, and the live-query subscriptions over them stay in-region.
  • Fan-out: cross-shard query coordination.
  • ctx.scheduler: the SchedulerDO that owns runAfter / runAt / cron timers.
  • ctx.containers: container DOs and their lifecycle reporting.
  • Voice agents: each voice-enabled agent's VoiceSessionDO, which holds the live session for /_lunora/voice/<agent> (see Pinning auth and voice).
  • DO-backed auth: when .auth() is wired with a namespace (the auth tables live in a Durable Object), that object is pinned too, so users, sessions, and credentials stay in-region — once acknowledged.
  • @lunora/mail: inbound dispatch (dispatchToLunoraFunction) and the dev capture inbox (createMailerFromEnv) reach the app's shards, and follow the schema's jurisdiction with no option passed (see Pinning mail).

It does not pin, by design:

  • .global() tables: these are D1-backed, not Durable Objects. D1 has its own location settings; the DO jurisdiction does not apply.
  • D1-backed auth: when .auth() is wired with d1, better-auth stores users and sessions in D1, so they follow D1's residency, not this option. DO-backed auth is pinned, see above.
  • Workflows and agent runs: defineWorkflow and defineAgent runs are Cloudflare Workflows, not Durable Objects, so the jurisdiction does not apply to their step state. Their reads and writes go back through the worker and land in the pinned shards.
  • Outbound mail delivery: a send goes to Cloudflare Email or Resend, and mailer.queue() to a Cloudflare Queue. Neither is a Durable Object.

Fail-closed. If the bound DO namespace can't honour the jurisdiction (an older @cloudflare/workers-types without .jurisdiction()), the worker throws rather than silently routing to the un-pinned global namespace: a dropped residency constraint is never allowed to leak data out of region.

Pinning auth and voice

Voice sessions and DO-backed auth were not pinned by earlier releases, even with .jurisdiction(...) declared. Nothing about the upgrade changes your schema, so the drift gate below does not fire for either.

Voice: pinned automatically, nothing to move

A VoiceSessionDO keeps no storage of its own. It holds the live socket and the audio of the utterance in progress, and writes every transcript turn to the agent's thread tables, which are rows in the app's shards: already pinned, and covered by lunora export. So a declared jurisdiction pins voice sessions with no acknowledgement. The only effect is that sessions live at the deploy drop; clients reconnect into the pinned objects and continue the same threads.

DO-backed auth: acknowledge first

Pinning the auth object moves it the way a jurisdiction change moves everything else: it resolves to a new, empty object, and every user, account, session and credential stays in the un-pinned one until you copy them across. So it is pinned only on an explicit acknowledgement:

export const schema = defineSchema({/* … */}).jurisdiction("eu", { pinAuth: true });

Without it, codegen refuses a project that declares .jurisdiction(...) and has an .auth({ namespace }) (DO-backed auth), pointing at the declaration. It also refuses when it cannot tell — an .auth(…) whose options it cannot read — because for D1-mode auth the acknowledgement changes nothing. Until you acknowledge, DO-backed auth stays on the un-pinned object, where its data is.

DO-backed auth: copy, verify, then purge

lunora export does not read the auth object, so users and sessions need their own copy. Two admin operations do it, served by the deployed worker and gated by the admin bearer like every other __lunora_admin__:* op:

  • __lunora_admin__:copyAuthToJurisdiction copies every table in the un-pinned auth object into the pinned one: better-auth's tables for whatever plugins you run, the auth audit log, the rate limiter, and any table a plugin adds later. A table the pinned object lacks is created from the un-pinned object's own definition, with its indexes. user goes first, then account and session, so sign-in works again as early as possible; the audit log and the rate limiter go last.
  • __lunora_admin__:purgeUnpinnedAuth drops every table in the un-pinned object.

The copy takes a snapshot: each row as the un-pinned object held it when the copy read it. The pinned object records every row it copied. The copy call that reaches the end of every table, and the purge, compare a fingerprint of the un-pinned object against that record, so a copy re-run after it finished checks again. That check is the only thing that reconciles an update or delete made on the un-pinned object after its rows were read: when it finds one, the next copy re-scans the changed table and applies it — a changed row is updated and a deleted row is deleted in the pinned object, as long as the pinned object still holds the copied version.

Do this in a short maintenance window. Once the acknowledgement is deployed, sign-ins land in the pinned object, which is empty until the copy runs.

Acknowledge and deploy

export const schema = defineSchema({/* … */}).jurisdiction("eu", { pinAuth: true });
lunora codegen && lunora deploy

From here this deployment writes auth only to the pinned object. The un-pinned object can still take writes from an older version during a gradual rollout, or after a rollback; the fingerprint check above is what catches those.

Copy

LUNORA_ADMIN_TOKEN=… lunora run __lunora_admin__:copyAuthToJurisdiction \
  --url https://app.example.com

The answer lists every table with its counts:

{
    "done": true,
    "tables": [
        { "table": "user", "sourceRows": 1840, "copied": 1840, "updated": 0, "deleted": 0, "unchanged": 0, "conflicts": 0, "targetRows": 1840 },
        { "table": "account", "sourceRows": 1840, "copied": 1840, "updated": 0, "deleted": 0, "unchanged": 0, "conflicts": 0, "targetRows": 1840 }
    ]
}

It is safe to run again. A run that stopped part-way (a dropped connection, a timeout) resumes where the pinned object got to, and a run after a finished copy changes nothing unless the un-pinned object changed. One call copies at most 16 pages of 100 rows, which keeps it under the 50-subrequest limit of a Workers Free plan; a larger auth object answers done: false, and you call it again until it answers done: true. done: true means every table matches the un-pinned object's fingerprint.

Two refusals protect the pinned object's own data, and both are checked on every call, not only the first:

  • AUTH_MOVE_TARGET_NOT_EMPTY: the pinned object holds users the copy did not write, because someone signed up there between the deploy and the copy. Copying would merge two user bases.
  • AUTH_MOVE_CONFLICT: a copied row collides with a row the pinned object holds on its own: the same email as a user who signed up there, or a row both objects changed. Nothing from that page is written. Plugin state the pinned object created on its own collides the same way, for example a SCIM connection binding created by a provisioning request after the deploy.

--args '{"force":true}' copies anyway: the pinned object's rows are kept as they are, and each collision is counted under conflicts.

Verify

Check that done is true, that targetRows matches sourceRows for every table, and sign in as a known user. conflicts is 0 unless you forced the copy; a forced copy that reports conflicts left those source rows out.

Purge the un-pinned copy

LUNORA_ADMIN_TOKEN=… lunora run __lunora_admin__:purgeUnpinnedAuth \
  --url https://app.example.com

Do not skip this. Until you purge, the users, password hashes and sessions you pinned to keep in the jurisdiction are still stored outside it. The copy never deletes them on its own: a copy you have not verified is not a reason to lose the only other one. The purge refuses with AUTH_MOVE_INCOMPLETE until a copy has finished for every table the un-pinned object holds. It refuses with AUTH_MOVE_SOURCE_CHANGED if any of them changed after it was copied, whether by an insert, an update or a delete. It checks this inside the transaction that drops the tables. Run the copy again to carry the change, then purge.

A purge is final for that object. If it serves requests again afterwards (a rollback to a build without the pin), it re-creates its tables empty; the copy and the purge then refuse with AUTH_MOVE_SOURCE_PURGED rather than read the empty tables as deletions and remove users from the pinned object.

Pinning mail

@lunora/mail has no Durable Object of its own. Its two shard paths call the app's __root__ shard:

  • dispatchToLunoraFunction runs your inbound function there (for example inbound:onEmail), and that function writes to your own tables.
  • In development, createMailerFromEnv records each captured message in the studio's Mail inbox, a table in the same shard.

The generated worker records the schema's jurisdiction when it loads, in the worker and in every Durable Object class it exports. Both paths read it and pin the SHARD namespace, so you do not pass anything. The jurisdiction option on each is only for a hand-written worker that does not use the generated defineApp. A value that contradicts the schema's throws.

Nothing to acknowledge. Before this, an app that declared a jurisdiction but did not pass it to mail sent mail's writes to the un-pinned __root__. The rest of the app reads the pinned one. So those rows were already out of the app's view, and pinning mail hides nothing the app could see. After the deploy, inbound functions write where your queries read, and captured dev mail shows in the studio inbox.

What stays behind. Rows written through the un-pinned path before the deploy stay in the un-pinned __root__: the rows your inbound function wrote there, and captured dev mail. lunora export reads the pinned shards only, so it does not include them. The mail that produced them has already been delivered or bounced. Mail has no outbox, retry state or suppression list in a Durable Object, and mailer.queue() uses a Cloudflare Queue.

Set it once: changing it strands data

A Durable Object name maps to a different ID in each jurisdiction. So toggling, changing, or removing .jurisdiction(...) on an app that has already deployed makes every shard, scheduler job, container, voice session, and DO-backed auth object resolve to a new, empty DO. The previous data stays in the old region's DOs and is no longer reachable through the worker. There is no in-place migration.

Because this is the most destructive change a schema can express, the pre-deploy drift gate flags it as breaking and blocks the deploy:

Durable Object jurisdiction changed from (none) to us — this re-homes every DO
and strands all existing shard, scheduler, and session-DO data in the old region
(no in-place migration; export then import to move it). Revert the change, or
override the gate to proceed intentionally.

Revert the change, or, if a region move is genuinely intended, follow the runbook below and override the gate explicitly.

Runbook: moving an app to a new jurisdiction

The only way to move existing data across jurisdictions is to export it while the worker can still read the old region, then import it after the worker is pinned to the new one. Order matters: once you deploy the jurisdiction change, the old DOs become unreachable through the worker.

Do this in a maintenance window: writes that land between the export and the import are not carried over.

Export first, before changing anything

While the deployed worker still resolves the current region's DOs, dump every table to NDJSON. This is your only window to read the old data.

LUNORA_ADMIN_TOKEN=… lunora export --prod --url https://app.example.com \
  --out backup.ndjson \
  --tables messages,channels,users   # scope to DO-backed tables

Scope --tables to your shard-local tables. .global() (D1) tables are not re-homed by the jurisdiction change, so leaving them out avoids re-inserting their rows in the import step.

Verify the dump

Confirm row counts and file size look right before you touch the schema; this file is the only copy that bridges the two regions.

wc -l backup.ndjson

Change the schema and regenerate

Edit the .jurisdiction(...) declaration and run codegen so the generated worker picks up the new region.

export const schema = defineSchema({
    // …
}).jurisdiction("us"); // was "eu"
lunora codegen

Deploy, overriding the drift gate

The gate will block on the changedJurisdiction drift. Since you have already exported, override it and re-bless the baseline so the new region becomes the recorded shape:

lunora deploy --allow-schema-drift --update-schema-baseline

The worker now resolves DOs in the new region, and they start empty.

Import into the new region

Replay the dump. The worker is now pinned to the new jurisdiction, so the rows land in the new region's DOs.

LUNORA_ADMIN_TOKEN=… lunora import backup.ndjson --prod --url https://app.example.com

Verify and clean up

Check parity (row counts, a few spot reads) against the dump. The old region's DOs are now orphaned: they hold no further traffic and bill nothing once idle. Leave them to expire or delete them from the Cloudflare dashboard.

Scheduled jobs and sessions don't ride along. lunora export covers table rows, not SchedulerDO timer state. In-flight runAfter / runAt / cron jobs in the old region are lost; re-enqueue anything that must survive the move. D1-backed auth sessions are unaffected by the jurisdiction change and stay put. DO-backed auth is not: its users and sessions stay in the old region's auth object, and lunora export does not read them, so they do not move with the table rows. The auth copy above only moves an un-pinned object into its pinned one; it does not move auth between two jurisdictions.