Fork Horse repository, listed by its owner. Publisher snapshot.
docs/migrate-from-hosted.md
Snapshot at 34e3175
1# Migrating from the hosted deploy
2
3The [hosted flow](https://os.cloudflare.app/deploy) installs the same upstream release into your
4account without this repository. Ejecting to the starter means taking over that deployment.
5
6The whole migration is **redeploy over the workers that already exist, and rebind the
7resources they already use.**
8
9Read this end to end before running `pnpm deploy`. Sections 2 and 3 are the ones that lose data if
10you skip them.
11
12The starter's pinned release should be the same as or newer than the one your instance is running,
13so migrating is a version upgrade as well as a move. That means the usual upgrade caveats apply:
14upstream changes can be breaking, and a newer release can carry Durable Object migrations that run
15against your existing data the first time you deploy. If you want the full gate before touching
16production โ compatibility review, migration plan, rollback matrix โ it is in
17[Upgrade and rollback](../.agents/skills/cloudflare-os-operator/references/upgrade-and-rollback.md).
18Do not deploy a pin *older* than what the instance runs; downgrade the code and the storage stays
19migrated.
20
21## 1. What carries over, and why
22
23Nothing is stored *in* a Worker. Your users, gadgets, workspaces and settings live in SQLite
24Durable Objects belonging to the **backend Worker's script identity**, and DO namespaces are scoped
25to that identity.
26
27So:
28
29- Redeploying over the same script name is an in-place version update. The DOs are the same DOs and
30 the data is still there.
31- Deploying under a different name creates a *new* script with empty DO namespaces. The old data is
32 not lost, but nothing reads it any more, and the app looks freshly installed.
33
34KV and R2 work the same way, except that the binding names the resource explicitly โ which is why
35leaving `resources.*` as `null` is the trap in section 3.
36
37## 2. Match the Worker names exactly
38
39Find your hosted instance name โ call it `N` โ in the wizard, or read it off the worker list in the
40[dashboard](https://dash.cloudflare.com/). The hosted flow names everything after it.
41
42| Hosted Worker | What it holds | `deployment.jsonc` |
43| --- | --- | --- |
44| `N-backend` | **All user data** (Durable Objects) | `workers.workshop.name` |
45| `N-gk-context` | Context collections | `workers.context.name` |
46| `N-gk-scheduler` | Scheduled and recurring work | `workers.scheduler.name` |
47| `N` | The public URL | `workers.router.name` |
48
49The Gatekeeper suffixes are the hosted flow's choice, not the starter's, so confirm all three
50against the dashboard worker list rather than assuming the pattern. The starter also deploys a Worker the hosted flow does not: the Error Reporter. Give it a new name; nothing depends on it yet. It also deploys the upstream MCP connector by default: if
51one already exists, preserve its Worker identity and MCP accounts; otherwise choose a new unique
52`workers.mcp.name`.
53
54```jsonc
55"workers": {
56 // Hosted instances run on workers.dev; section 4 has the custom-domain form.
57 "router": { "name": "N", "route": { "workersDev": true } },
58 "workshop": { "name": "N-backend" },
59 "context": { "name": "N-gk-context" },
60 "scheduler": { "name": "N-gk-scheduler" },
61 "mcp": { "name": "N-gk-mcp" },
62 "errorReporter": { "name": "N-errors" }
63}
64```
65
66Context and Scheduler are both there because upstream marks them ambient and the hosted flow
67preinstalls them on every instance. Point them at the existing Workers and existing schedules and
68collections keep working; give either a new name and the app comes up with an empty library or an
69empty schedule list, while the originals keep running as Workers nothing is bound to.
70
71## 3. Reuse the storage
72
73`resources.*` and `context.kvNamespaceId` default to `null`, which asks Wrangler to **provision new,
74empty** namespaces. On a fresh install that is what you want. On a migration it is data loss in
75everything but name: the deploy succeeds, and the app comes up with no blueprints, no avatars and no
76Context collections.
77
78The hosted flow names each resource after the instance and the binding:
79
80```jsonc
81"context": {
82 "sharingDomain": null,
83 "kvNamespaceId": "<id of N-context-collections>"
84},
85"resources": {
86 "blueprintsKvNamespaceId": "<id of N-blueprints>",
87 "avatarsKvNamespaceId": "<id of N-avatars>",
88 "blueprintContentBucket": "N-blueprint-content"
89}
90```
91
92KV bindings take the namespace **ID**, not the name โ list them with
93`pnpm exec wrangler kv namespace list`. R2 takes the bucket name as-is.
94
95The Scheduler needs nothing here. Its state is entirely in its own Durable Objects, so section 2's
96name is the whole of it.
97
98### The second trap: the Context sharing boundary
99
100Right KV, wrong boundary, and the collections are still invisible. `context.sharingDomain` isolates
101Context data, and the hosted flow sets it to the instance's **public origin** โ the router's URL,
102scheme included, such as `https://N.<subdomain>.workers.dev`.
103
104`sharingDomain: null` reproduces that: the deploy derives it from the public origin, exactly as
105hosted does. So the boundary survives as long as the origin does, which makes it one more reason to
106keep the URL in [section 4](#4-your-url).
107
108If you *do* move the instance to a new hostname, the derived value changes with it. Pin the old one
109instead, so the boundary stays where the data is:
110
111```jsonc
112"context": { "sharingDomain": "https://N.<subdomain>.workers.dev" }
113```
114
115`publicBaseUrl` is the other half of this. It is the origin both `sharingDomain` and
116`PUBLIC_BASE_URL` derive from: leave it `null` on a custom domain and the deploy takes the domain,
117but on a `workers.dev` route it must be set by hand โ nothing in `deployment.jsonc` knows your
118account's workers.dev subdomain. Set it to the hosted instance's exact URL, no trailing slash:
119
120```jsonc
121"publicBaseUrl": "https://N.<subdomain>.workers.dev"
122```
123
124`pnpm check` requires that value to be the router's own workers.dev origin โ
125`https://<workers.router.name>.<subdomain>.workers.dev` โ because on this route there is no custom
126domain to check it against, and a typo would quietly become both `PUBLIC_BASE_URL` and the Context
127boundary.
128
129## 4. Your URL
130
131The starter deploys the same topology as the hosted flow, router included, so the public origin is a
132Worker named `N` in both. Either route type preserves the URL:
133
134| Hosted URL | `deployment.jsonc` |
135| --- | --- |
136| `https://N.<subdomain>.workers.dev` | `"router": { "name": "N", "route": { "workersDev": true } }`, plus `publicBaseUrl` set to that URL |
137| A custom domain you already moved it to | `"router": { "name": "N", "route": { "customDomain": "os.example.com" } }` |
138
139Redeploying over `N` is an in-place version update, like every other Worker here. There is no
140router to strip a domain from and no router to delete, and the Access application keeps covering
141the hostname it already covered.
142
143## 5. AI Gateway โ the one that silently empties the model picker
144
145**Do not leave `aiGateway.enabled` at `false`.** `wrangler deploy` replaces a Worker's `vars`
146wholesale, so the `CF_AI_GATEWAY*` variables the hosted deploy set are gone the moment you deploy
147without your own. With `CF_AI_GATEWAY` unset, `getAiGatewayConfig()` returns `null`, the backend
148drops out of gateway mode, and `listModels()` returns only each user's own BYOK models โ of which a
149freshly migrated instance has none. The result is a deployment that looks healthy and offers no
150models at all.
151
152Point it at the gateway the hosted flow already created for you, `N-ai`:
153
154```jsonc
155"aiGateway": {
156 "enabled": true,
157 "name": "N-ai",
158 "accountId": null,
159 "providers": ["cloudflare"]
160}
161```
162
163- `accountId: null` keeps the gateway in your own account, which is what lets the Workshop reach it
164 over the `WORKERS_AI` binding. **No `CF_AI_GATEWAY_API_TOKEN` is needed.**
165- Add `"anthropic"` or `"openai"` to `providers` if you entered BYOK keys in the wizard. Those keys
166 live in the gateway's own Secrets Store, not in the Worker, so they survive the migration and keep
167 working.
168- Adding `"google"` does require a token โ see
169 [AI models](customization.md#ai-models).
170
171## 6. Cloudflare Access
172
173The hosted flow created a self-hosted Access application named `Cloudflare OS <instance> <fp8>`,
174using the one-time-PIN identity provider. Reuse it rather than making a new one, so existing users
175keep signing in the same way:
176
177- `access.issuer`: your team origin, `https://<team>.cloudflareaccess.com`.
178- `access.audience`: that application's existing AUD tag.
179- `access.admins`: the admin email you gave the wizard.
180
181The application already covers the hostname the router answers on, so keeping the URL keeps Access
182working untouched. Only if you are moving the instance to a new hostname do you have to extend the
183application to cover it โ before deploying, and along with `context.sharingDomain` from section 3.
184
185## 7. What you lose
186
187- **The wizard link.** The hosted deploy sets a `DEPLOY_URL` var that renders a "manage this
188 deployment" link. The starter does not set it, so the link disappears. This is not a regression to
189 work around: the hosted wizard refuses to redeploy over its own workers once you have taken them
190 over, which is the whole reason to eject.
191- **Gatekeepers you installed through the wizard.** The starter deploys the two ambient ones,
192 Context and Scheduler, plus the upstream MCP connector and whatever is in `packages/`. Any *other* Gatekeeper the wizard
193 installed keeps running as its own Worker, but the generated configs no longer carry its
194 `GATEKEEPER_*` service binding, so it disappears from the app. Port it into `packages/` and add
195 the binding in `scripts/deploy.ts` โ to the Workshop for its vendor RPC, and to the router if it
196 serves HTTP under `/gatekeeper/<name>` โ or delete the orphaned Worker.
197
198## 8. Verify
199
200Beyond the [standard checks](starter-reference.md#4-verify-the-deployment):
201
2021. Sign in as an existing user and confirm the identity is recognised, not re-created.
2032. Open the model picker. It must list models โ an empty picker means section 5 was skipped.
2043. Open a gadget that existed before the migration, and confirm its history is intact.
2054. Open the Context library and confirm its collections are still listed.
2065. Confirm schedules made before the migration are still listed, and that a new one fires.
2076. Send one message to confirm inference actually routes.
208
209If the model picker is empty or the app looks freshly installed, **stop before writing anything**.
210Both are binding problems, not data loss: compare the deployed Worker names and resource IDs against
211sections 2 and 3 rather than re-provisioning.
212