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— theSchedulerDOthat ownsrunAfter/runAt/ cron timers.ctx.containers— container DOs and their lifecycle reporting.
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.- Auth sessions —
@lunora/authstores sessions in D1 (better-auth), so they follow D1's residency, not this option. @lunora/mailinbound / capture shards — mail is a scaffolded add-on, not codegen-wired, so its shard access is not auto-pinned. Both entry points take an optionaljurisdiction(createMailerFromEnv({ jurisdiction })for the dev capture inbox,dispatchToLunoraFunction({ jurisdiction })for inbound routing) — pass the same value you declared on the schema so the mail shard co-resides with app data. Omit it and mail uses the un-pinned global namespace.
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.
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, and container 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 tablesScope --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.ndjsonChange 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 codegenDeploy, 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-baselineThe worker now resolves DOs in the new region — 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.comVerify 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.
See also
- Sharding — the
__root__DO,.shardBy(), and.global(). - Migrations —
defineMigrationand the pre-deploy drift gate. - CLI — full
lunora export,lunora import, andlunora deployreference.