The hosted flow installs the same upstream release into your account without this repository. Ejecting to the starter means taking over that deployment.
The whole migration is redeploy over the workers that already exist, and rebind the resources they already use.
Read this end to end before running pnpm deploy. Sections 2 and 3 are the ones that lose data if
you skip them.
The starter's pinned release should be the same as or newer than the one your instance is running, so migrating is a version upgrade as well as a move. That means the usual upgrade caveats apply: upstream changes can be breaking, and a newer release can carry Durable Object migrations that run against your existing data the first time you deploy. If you want the full gate before touching production — compatibility review, migration plan, rollback matrix — it is in Upgrade and rollback. Do not deploy a pin older than what the instance runs; downgrade the code and the storage stays migrated.
Nothing is stored in a Worker. Your users, gadgets, workspaces and settings live in SQLite Durable Objects belonging to the backend Worker's script identity, and DO namespaces are scoped to that identity.
So:
- Redeploying over the same script name is an in-place version update. The DOs are the same DOs and the data is still there.
- Deploying under a different name creates a new script with empty DO namespaces. The old data is not lost, but nothing reads it any more, and the app looks freshly installed.
KV and R2 work the same way, except that the binding names the resource explicitly — which is why
leaving resources.* as null is the trap in section 3.
Find your hosted instance name — call it N — in the wizard, or read it off the worker list in the
dashboard. The hosted flow names everything after it.
| Hosted Worker | What it holds | deployment.jsonc |
|---|---|---|
N-backend |
All user data (Durable Objects) | workers.workshop.name |
N-gk-context |
Context collections | workers.context.name |
N-gk-scheduler |
Scheduled and recurring work | workers.scheduler.name |
N |
The public URL | workers.router.name |
The Gatekeeper suffixes are the hosted flow's choice, not the starter's, so confirm all three against the dashboard worker list rather than assuming the pattern. The starter also deploys two Workers the hosted flow does not: the example custom Gatekeeper and the Error Reporter. Give those new names; nothing depends on them yet.
Context and Scheduler are both there because upstream marks them ambient and the hosted flow preinstalls them on every instance. Point them at the existing Workers and existing schedules and collections keep working; give either a new name and the app comes up with an empty library or an empty schedule list, while the originals keep running as Workers nothing is bound to.
resources.* and context.kvNamespaceId default to null, which asks Wrangler to provision new,
empty namespaces. On a fresh install that is what you want. On a migration it is data loss in
everything but name: the deploy succeeds, and the app comes up with no blueprints, no avatars and no
Context collections.
The hosted flow names each resource after the instance and the binding:
"context": {
"sharingDomain": null,
"kvNamespaceId": "<id of N-context-collections>"
},
"resources": {
"blueprintsKvNamespaceId": "<id of N-blueprints>",
"avatarsKvNamespaceId": "<id of N-avatars>",
"blueprintContentBucket": "N-blueprint-content"
}KV bindings take the namespace ID, not the name — list them with
pnpm exec wrangler kv namespace list. R2 takes the bucket name as-is.
The Scheduler needs nothing here. Its state is entirely in its own Durable Objects, so section 2's name is the whole of it.
Right KV, wrong boundary, and the collections are still invisible. context.sharingDomain isolates
Context data, and the hosted flow sets it to the instance's public origin — the router's URL,
scheme included, such as https://N.<subdomain>.workers.dev.
sharingDomain: null reproduces that: the deploy derives it from the public origin, exactly as
hosted does. So the boundary survives as long as the origin does, which makes it one more reason to
keep the URL in section 4.
If you do move the instance to a new hostname, the derived value changes with it. Pin the old one instead, so the boundary stays where the data is:
"context": { "sharingDomain": "https://N.<subdomain>.workers.dev" }publicBaseUrl is the other half of this. It is the origin both sharingDomain and
PUBLIC_BASE_URL derive from: leave it null on a custom domain and the deploy takes the domain,
but on a workers.dev route it must be set by hand — nothing in deployment.jsonc knows your
account's workers.dev subdomain. Set it to the hosted instance's exact URL, no trailing slash:
"publicBaseUrl": "https://N.<subdomain>.workers.dev"pnpm check requires that value to be the router's own workers.dev origin —
https://<workers.router.name>.<subdomain>.workers.dev — because on this route there is no custom
domain to check it against, and a typo would quietly become both PUBLIC_BASE_URL and the Context
boundary.
The starter deploys the same topology as the hosted flow, router included, so the public origin is a
Worker named N in both. Either route type preserves the URL:
| Hosted URL | deployment.jsonc |
|---|---|
https://N.<subdomain>.workers.dev |
"router": { "name": "N", "route": { "workersDev": true } }, plus publicBaseUrl set to that URL |
| A custom domain you already moved it to | "router": { "name": "N", "route": { "customDomain": "os.example.com" } } |
Redeploying over N is an in-place version update, like every other Worker here. There is no
router to strip a domain from and no router to delete, and the Access application keeps covering
the hostname it already covered.
Do not leave aiGateway.enabled at false. wrangler deploy replaces a Worker's vars
wholesale, so the CF_AI_GATEWAY* variables the hosted deploy set are gone the moment you deploy
without your own. With CF_AI_GATEWAY unset, getAiGatewayConfig() returns null, the backend
drops out of gateway mode, and listModels() returns only each user's own BYOK models — of which a
freshly migrated instance has none. The result is a deployment that looks healthy and offers no
models at all.
Point it at the gateway the hosted flow already created for you, N-ai:
"aiGateway": {
"enabled": true,
"name": "N-ai",
"accountId": null,
"providers": ["cloudflare"]
}accountId: nullkeeps the gateway in your own account, which is what lets the Workshop reach it over theWORKERS_AIbinding. NoCF_AI_GATEWAY_API_TOKENis needed.- Add
"anthropic"or"openai"toprovidersif you entered BYOK keys in the wizard. Those keys live in the gateway's own Secrets Store, not in the Worker, so they survive the migration and keep working. - Adding
"google"does require a token — see AI models.
The hosted flow created a self-hosted Access application named Cloudflare OS <instance> <fp8>,
using the one-time-PIN identity provider. Reuse it rather than making a new one, so existing users
keep signing in the same way:
access.issuer: your team origin,https://<team>.cloudflareaccess.com.access.audience: that application's existing AUD tag.access.admins: the admin email you gave the wizard.
The application already covers the hostname the router answers on, so keeping the URL keeps Access
working untouched. Only if you are moving the instance to a new hostname do you have to extend the
application to cover it — before deploying, and along with context.sharingDomain from section 3.
- The wizard link. The hosted deploy sets a
DEPLOY_URLvar that renders a "manage this deployment" link. The starter does not set it, so the link disappears. This is not a regression to work around: the hosted wizard refuses to redeploy over its own workers once you have taken them over, which is the whole reason to eject. - Gatekeepers you installed through the wizard. The starter deploys the two ambient ones,
Context and Scheduler, plus whatever is in
packages/. Any other Gatekeeper the wizard installed keeps running as its own Worker, but the generated configs no longer carry itsGATEKEEPER_*service binding, so it disappears from the app. Port it intopackages/and add the binding inscripts/deploy.ts— to the Workshop for its vendor RPC, and to the router if it serves HTTP under/gatekeeper/<name>— or delete the orphaned Worker.
Beyond the standard checks:
- Sign in as an existing user and confirm the identity is recognised, not re-created.
- Open the model picker. It must list models — an empty picker means section 5 was skipped.
- Open a gadget that existed before the migration, and confirm its history is intact.
- Open the Context library and confirm its collections are still listed.
- Confirm schedules made before the migration are still listed, and that a new one fires.
- Send one message to confirm inference actually routes.
If the model picker is empty or the app looks freshly installed, stop before writing anything. Both are binding problems, not data loss: compare the deployed Worker names and resource IDs against sections 2 and 3 rather than re-provisioning.