You can have your own node running this afternoon.
By the end, the globe is running at your own address, showing your own datasets, and only your team can publish to it. Getting there means setting up a handful of Cloudflare services, pointing your copy of the code at them, and putting your first data in.
Work straight down the page. Nothing asks you for something an earlier step has not already handed you, so you will not get halfway and discover you needed to do something else first. Fill in your details once and every command below fills itself in. Tick off each step as you finish it — this page remembers where you stopped, so you can walk away and come back.
This page is a front door. The canonical text — every click path, every caveat — is SELF_HOSTING.md, and each phase links straight into it.
This is the only decision that changes the shape of the install. Pick one and the rest of the page shows you only the phases, bindings and variables that node type needs.
You can run this on the free plan
Nothing in the install requires a paid Cloudflare account, and nothing is switched off without one. What you get on the free plan is a smaller daily allowance of each thing. Orbit is the one that runs out first, and a busy node can also reach the daily limit on Functions requests. Pick the plan you are on and the rest of the page adjusts to it.
Your model has to be reachable from Cloudflare's network, so a laptop on the museum wifi will not do — it needs a stable address. Phase 8 shows the variables to set, and the `AI` binding stays wired either way, because embeddings still use it.
Nothing has been removed. Every product this node binds has a free allocation, so you install the same node and create the same resources — you just have less headroom on each. The one that runs out first is Orbit.
This is the part that is easy to miss: R2 and D1 bill identically whether or not you pay the $5. Both have a free allowance, and for most nodes that allowance is the whole story — a catalog of metadata, images and tours does not come close to it. Publishing your own video is the only thing that moves it. The free 10 GB holds roughly 12 video datasets; past that it is still cents rather than a budget line.
A real number, not a model. This project's own node publishes 178 datasets, 126 of them video, and stores 97.1 GB in R2. Its Cloudflare bill for that storage is $1.31 a month — 87 GB-month after the free 10 GB. Every operations line on the same invoice was $0.00. The estimate below is that measurement scaled, not a formula.
Storage only, and an order of magnitude rather than a quote — real transcode output swings with resolution and motion. Scaled from a measured node at 0.77 GB per video dataset — yours will differ with clip length and resolution. Requests are left out because the reference node runs at 2% of the free Class A allowance and 4% of Class B. Rates read from Cloudflare on 2026-08-03: R2 · D1. They change; those pages are authoritative, this one is a copy.
Transcoding a video into its HLS ladder, and running a Zyra pipeline to build a data-encoded dataset, are the heaviest things your node does — and Cloudflare never sees them. They run as GitHub Actions in your repository, fired by the publisher API: transcode-hls, zyra-run, and the scheduled feed, analytics and refresh jobs.
Keep your fork public and that compute is free. GitHub's billing docs put it plainly: "GitHub Actions usage is free for self-hosted runners and for public repositories that use standard GitHub-hosted runners." An open-source node pays nothing for transcode. A private fork draws on your account's monthly Actions minutes instead, which is the one configuration where this stops being free.
Within reason, though. GitHub's terms limit Actions to work connected to the repository it runs in — excluding "any other activity unrelated to the production, testing, deployment, or publication of the software project associated with the repository." Building and publishing your own node's datasets sits inside that. Pointing the runners at unrelated batch compute does not, and GitHub monitors for it. Jobs also stop hard at 5 days, and a free account runs at most 20 standard jobs at once.
If your pipelines outgrow that — or you would rather not lean on it — self-hosted runners are free too, and you supply the hardware. Read on 2026-08-03: billing · limits · terms.
For a gallery kiosk, a pilot, or a node you are still making your mind up about, free is a perfectly respectable place to run. Pay the $5 when you need to report on reach, or when Orbit is going to carry a busy public floor.
Your install sheet
One page to print and keep next to the keyboard. Two lists, in the order you need them: first the things only you can do, then every phase of the install with what finished looks like.
This sheet is the map, not the instructions. The steps themselves are below it, one section per phase, with the commands and the click paths. Work the prerequisites here, then keep scrolling — the page is already in order.
An account, a domain, a login — the things no script can do on your behalf. Most the setup tool will notice if you skip; the ones marked on you it cannot, so those are the ones to be sure about. Have a password manager open before you start: four of these produce a secret shown exactly once. Workers Paid is a choice rather than a task — see what it costs.
How to do this ▸
Anything older than the version this repo requires and setup will stop with the number it found. You do not have to get this right up front — it is checked, not trusted.
Take an LTS release — 22 or 24 both work, and the download button gives you 24. One dependency ships precompiled binaries only for the Node majors current when it was published. On anything older or newer, npm install tries to compile it and stops on a missing C++ toolchain.
How to do this ▸
Seven of the images the globe renders are stored in LFS. Clone without it and you get small text files carrying .jpg names. Nothing reports it — the build passes, the deploy passes, and the globe comes up missing its stars.
You will also need a browser you can sign in to Cloudflare with — most of the steps below are dashboard clicking. On a headless machine, see the wrangler login note in the guide.
How to do this ▸
main is the only branch a node needs, so leaving that box ticked is correct.
GitHub creates every fork with Actions turned off. Leave them off and the transcode and deploy workflows never run.
npm install is not optional and not just for contributors — it puts the tooling every later npm run command needs on your path. Skip it and the first one fails with something like 'tsx' is not recognized.
Clone your fork, not zyra-project/terraviz. A clone of upstream runs fine and then has nowhere to push the resource IDs Phase 3 writes.
How to do this ▸
Billing is per account, not per project. One subscription covers every node you run on this account.
How to do this ▸
You do not need to transfer the registration. Cloudflare only needs to answer DNS for the domain.
The change usually takes minutes to take effect, occasionally a few hours.
How to do this ▸
These two are the ones people cannot find. Each permission row has three dropdowns, and the first one — the scope — starts on Account. Zone permissions are not in the Account list at all. Change that first dropdown to Zone and the middle one refills with Zone, Zone WAF and the rest.
A zone-scoped row also needs the Zone Resources section below Permissions. Leave it unset and the token carries the permission but reaches no zone. Include the zone your node runs on, or every zone in the account.
Both names above are current. If some other permission is missing from the list, GET /user/tokens/permission_groups returns every one with its scope.
Cloudflare shows the token once. If you lose it, revoke it and mint another.
That export also outranks wrangler login. Wrangler prefers the token over your browser session, so Phases 2 and 4 run with these scopes rather than your own account access. A token holding only the Pages permission gets through the Pages step and then fails on D1, KV or Vectorize.
How to do this ▸
The dialog asks for both. terraviz_events is what the Grafana dashboards and the export pipeline read; ANALYTICS is what functions/api/ingest.ts writes through.
Creating the dataset here is not what makes it real — a dataset appears on first write either way. What this does is enable the product on your account, which is the part the deploy checks for.
How to do this ▸
One-time PIN emails a code and needs no identity provider, so it is the quickest way to get working. Google, Okta or Entra are better once real staff are signing in.
The free Zero Trust plan covers up to 50 users, which is more than most nodes need.
terraviz init-node reads in Phase 9.How to do this ▸
It writes NODE_ID_PRIVATE_KEY_PEM into .dev.vars, readable only by you, and node-public-key.txt beside it.
Back that file up. Generating a second key gives your node a new identity, which means re-provisioning it everywhere.
How to do this ▸
Both options below happen in the Cloudflare dashboard, not on GitHub. Cloudflare asks GitHub for access partway through the first one; you never start from the GitHub side.
VITE_* is a naming convention, not a product. Vite is the bundler that builds this app, and it copies variables with that prefix into the JavaScript it emits. That is why they belong wherever the build runs — by the time a visitor loads the page, the values are already inside the file being served.
None of them is required — the build succeeds with all of them unset, so this is tuning rather than a gate. §5.2 of the guide lists them with their values. VITE_API_ORIGIN is the one most likely to be wanted later: desktop builds and deep-link host recognition read it. The Earth textures need no variable at all — they ship in your own build.
Set the VITE_* variables in the dashboard afterwards, under the same Settings tab. Cloudflare rebuilds on every push to your default branch.
VITE_* variables in the CI job instead, then deploy the finished directory.npm run setup already made you a Direct Upload project, so B is keeping what you have and A is converting it. Take A if you want a push to deploy itself and would rather not maintain a workflow — that is most nodes. Take B if you want deploys gated on the tests your fork already runs, or would rather not grant Cloudflare access to the repository.
Cloudflare moves this menu occasionally. If Builds is not where this says, look for Git repository or Connect to Git anywhere in the project settings — the wording changes, the capability does not.
How to do this ▸
The secret is shown once. If you lose it, delete the token and make another.
Every step in order, with the one thing you should see once it has landed. Not extra work — it is the same check that sits at the foot of each step below, gathered here so the printed sheet stands on its own.
That is the whole install on one page — but only the summary. Start Phase 0 below, which is where the actual commands are. Each phase ends by asking for whatever it produced, so the values fill themselves into every later command as you go.
Where every value comes from
A filled dot is where a value is born; the rings are every later phase that needs it. Read the shape, not the rows: nothing ever reaches backwards.
Dots turn blue as you fill each value in, so this doubles as a resume view. Click a row to edit it; click a phase number to jump there.
Your hostname and the Access service token stretch across most of the install. Mistype the hostname in Phase 0 and you will be correcting it in six later places; lose the token secret and you re-mint and re-attach across five.
Six values born, none consumed until Phase 3. That gap is deliberate — it is what lets Phase 3 and Phase 8 ask for IDs you already have written down instead of sending you back to the dashboard mid-edit.
It used to own five — the R2 origin, its key pair and endpoint, and the GitHub dispatch token. All five moved to Phase 8. Neither asset storage nor video transcode was really optional: a dataset with no readable image is not published, and 138 of upstream’s 204 datasets are video. What is left in Phase 13 needs nothing written down, which is a fair test of whether something belongs there.
Let the tool do the mechanical parts
Most of what follows is dashboard clicking that a script can do faster and without typos. npm run setup provisions the resources, rewrites the config, and applies the migrations in the order that works. It creates the Access application (Terraviz Publisher) with its Staff and Automation policies, then writes every binding to both environments.
Four ways to run it. These are alternatives, not a sequence — one block, one command, so whatever you copy is the whole of what you meant to run.
npm run setup -- --manual
What only a human can do, with click paths. Prints and exits.
npm run setup -- --interactive
Asks the 7 questions only you can answer, validating each at the prompt.
npm run setup
Plan. Says what it would do and writes nothing — this is the default.
npm run setup -- --apply
Provisions the resources and wires them up. The one that changes things.
Every phase below leads with the tool. Where a human is genuinely required — billing, an OAuth handshake, the first SSO sign-in — it says so and shows you the clicks. Where you would rather do it yourself anyway, open Do it by hand.
Before you touch Cloudflare
An account, a registrar, a login, and your own copy of the repository. Get these five values written down and everything after this has something to stand on.
Phase 3 rewrites wrangler.toml with your resource IDs, Phase 5 hands your remote to Cloudflare Pages, and Phase 8.6 runs the transcode workflow in your repo. A clone of upstream does all of that fine right up until you have IDs to push and nowhere to push them.
Use GitHub’s Fork button and keep every default. Then enable workflows in the Actions tab — GitHub creates forks with Actions off, so the transcode and deploy workflows never fire until you do.
node --version # must be >= 22 npm install -g wrangler git lfs install # once per machine, before the clone wrangler login # opens a browser wrangler whoami # confirms the account git clone https://github.com/.git cd terraviz && npm install
You should have these 5 values now. Typing them here fills them into every command further down the page, so what you copy is ready to run.
wrangler whoami prints the account matching .
Run it on your laptop
Do this before touching Cloudflare. Five minutes now tells you whether a problem later is yours or the deploy's — and that's worth a great deal at hour three.
npm run dev # http://localhost:5173
Run these three in exactly this order. Reversing the last two leaves your node identity holding the literal placeholder key, and /.well-known/terraviz.json will serve it. The script warns but exits 0, so it is easy to miss.
npm run db:reset is only the first two steps — it re-seeds the placeholder and does not re-stamp the key. Follow it with npm run gen:node-key every time: npm run db:reset && npm run gen:node-key.
npm run db:migrate # 1. schema into .wrangler/ SQLite npm run db:seed # 2. 20 datasets + the node_identity row npm run gen:node-key # 3. keypair, and stamps its public half # onto the row seeded in step 2
Phase 1 runs entirely on your laptop. Every binding is served from .wrangler/ on local disk, and .dev.vars sets MOCK_AI=true so the paths that would call Workers AI use a local mock. You do not need wrangler login until Phase 2.
To exercise the real Workers AI — Orbit chat, voice, live embeddings — sign in and run npm run dev:functions:ai instead. That adds the AI binding, which wrangler can only run against Cloudflare rather than locally, so it is the one thing here that needs credentials.
cp .dev.vars.example .dev.vars npm run gen:node-key # appends NODE_ID_PRIVATE_KEY_PEM npm run dev:functions # http://localhost:8788
All four endpoints answer: 200 with datasets, a real public key, role admin, and a mock-embedder search result. If /publish/me returns 503, you copied to .dev.vars.example rather than .dev.vars.
Create the Cloudflare resources
Nothing consumes these yet — and that is deliberate. This phase exists so that when Phases 3 and 8 ask for IDs, you already have them written down.
npm run setup -- --apply --only=resources
Creates or adopts the D1 database, both KV namespaces, the R2 bucket, and the Vectorize index with its three metadata indexes — and records every ID for you. Re-running adopts what already exists rather than making a second one.
You should have these 6 values now. Typing them here fills them into every command further down the page, so what you copy is ready to run.
W4 through W8 hold the IDs Cloudflare just printed, and W9 holds the dataset name you intend to use. No command creates W9 — an Analytics Engine dataset appears on first write. Turning the product on is a separate one-time click, and Phase 8.8 will not deploy without it.
Point the repo at your resources
wrangler.toml ships with the upstream project's real resource IDs. Replace them now that yours exist.
npm run setup -- --apply --only=wrangler-toml
It edits per binding block rather than by string replace. A global replace cannot tell the blocks apart: the two D1 blocks share a database name, and the two KV blocks share a section header. It refuses to apply while any ID is still unknown.
Pages reads its live bindings from the dashboard. But every wrangler command you run from your shell resolves its target through wrangler.toml. Getting this wrong means Phase 4 runs migrations against upstream’s database, not yours.
To recognise an unedited file: the two database_id lines both read 78fbe5c3…, which is upstream's database, not yours.
wrangler d1 info CATALOG_DB shows your database, 0 tables.
Create the schema
npm run setup -- --apply --only=migrations
Applies both sets and stops on any failure. Add --local-migrations to rehearse against your local database first.
Both [[d1_databases]] blocks declare database_name = "sphere-feedback" with different migration directories. Passing the bare name is ambiguous: wrangler resolves it to the first match, silently applies the wrong set, and leaves the catalog tables uncreated.
The symptom lands a long way from the cause — table datasets has no column named bbox_n, the first time somebody clicks Save draft in the portal.
+ Do it by hand
wrangler d1 migrations apply CATALOG_DB --remote # migrations/catalog/ wrangler d1 migrations apply FEEDBACK_DB --remote # migrations/
Either order works now. FEEDBACK_DB used to have to run second: the generated catalog snapshot sat in its migrations directory and would apply for real on an empty database. That file lives in schema/.
Both migration lists report nothing pending. Re-run both after every git pull that brings new migration files — they are idempotent.
Create the Pages project
Push your fork, then create the Pages project that builds from it and attach your hostname. This is the phase where your node gets a public address.
npm run setup -- --apply --only=pages
Creates the project with the right build settings and attaches your custom domain. What it cannot do is connect the Git remote — that handshake is an OAuth flow between Cloudflare and GitHub with no API behind it. A project the tool creates is Direct Upload, so Cloudflare never runs your build and the VITE_* variables must be set wherever the build actually runs. Click Connect in the dashboard afterwards to convert it in place, or stay on Direct Upload and deploy from CI.
The repo ships a deploy job that targets the project name terraviz. On a fresh fork it either fails for lack of secrets or — worse, if you have set them — deploys to a project that is not yours.
Dashboard Git integration (recommended): delete or disable the deploy job in ci.yml and poster.yml. Keep type-check, unit-tests and build — they are fork-safe and need no secrets.
GitHub Actions (Direct Upload): set repo secrets for your API token and account ID. Change every --project-name to yours, and set the repo variable TERRAVIZ_SERVER. Do not connect the Git integration. Note that forks land with Actions disabled and no secrets — GitHub never copies those.
You should have these 2 values now. Typing them here fills them into every command further down the page, so what you copy is ready to run.
The build goes green, the site loads at .pages.dev, and then https:// serves it over TLS. Backend features will not work yet — no bindings.
Cloudflare Access and the service token
Access is not optional for a publisher node. The middleware fails closed: without a team domain and an AUD, every publish route returns 503 and the CLI can do nothing.
npm run setup -- --apply --only=access
Discovers your team domain, creates the publisher application with all six destinations, creates both policies, mints the service token and attaches it. It records the AUD and prints the token pair once — have somewhere to put it.
Complete Zero Trust onboarding in the dashboard and add at least one identity provider. One-time PIN over email works and needs no IdP setup; Google, Okta or Entra are better for a real team. You will choose a team name — your team domain becomes .
You should have these 4 values now. Typing them here fills them into every command further down the page, so what you copy is ready to run.
The AUD is copied, the token pair is in your password manager, and the token appears in the Automation policy's include list.
Generate your node's secrets
Two secrets are yours to create, and neither exists until you make it.
The setup tool generates the preview signing key for you. It deliberately does not generate the node keypair — gen:node-key owns that. It has to, because it also writes the public key file Phase 9 reads, and stamps your local database. Both files are gitignored.
npm run gen:node-key # W16 into .dev.vars, W17 into node-public-key.txt openssl rand -base64 32 # W18
It signs your node's federation responses. Regenerating it means re-provisioning your identity.
You should have these 3 values now. Typing them here fills them into every command further down the page, so what you copy is ready to run.
.dev.vars holds a single-line private key, and node-public-key.txt holds an ed25519: line.
Wire bindings, storage and transcode
Everything referenced here now exists. This is roughly forty dashboard interactions by hand, which is exactly why it is worth letting the tool do it.
It also includes asset storage — the R2 public origin, its CORS policy and an S3 token — and video transcode. Both used to sit in the optional phase, after the phase that tells you to publish, while being prerequisites for publishing. Without R2 an uploaded thumbnail has no readable URL; without transcode, finalising a video upload returns 503 and rolls back. Do them here and one redeploy picks everything up.
138 of the upstream catalog’s 204 datasets are video, so transcode is the common case rather than an extra. Skip it only for a node publishing images, tours, metadata, or a mirror of upstream.
npm run setup -- --apply --only=bindings
Writes every entry to both environments in a single call, from the same manifest the audit checks against — so it cannot produce a deploy that Phase 10 then calls broken. Anything it has no value for is listed as skipped, with the reason, rather than written blank.
npm run setup -- --apply --only=r2 does the storage half: it builds the CORS policy from your origins, so the two details that are easy to mistype cannot be, and attaches the public domain. Minting the S3 API token stays manual on purpose — automating it would need a token that can create tokens.
The environment selector is at the top of the page and forgetting it is the most common cutover failure — "works on preview, breaks on production", or the reverse. Phase 10’s audit catches it, but only if you run it.
Access service tokens bypass Access but not Bot Fight Mode. The runner’s final callback gets an interstitial, ffmpeg finishes, the bundle lands, and the job still exits non-zero. You need a WAF skip rule on that path — and on the Free plan, plain Bot Fight Mode has no per-path override, so disabling it zone-wide is the recommended option.
HEAD must be listed explicitly, even though Fetch treats it as a simple method. And Content-Range must be in ExposeHeaders — it is not CORS-safelisted, so the download dialog cannot read a file’s size without it. Let the tool build the policy and neither can be mistyped.
Set these alongside the bindings above, on both environments. Keep the AI binding wired even so — Orbit’s chat will use your model, but dataset embeddings still run through Workers AI.
These three come from the Orbit provider docs rather than from the bindings audit, so nothing verifies them at build time the way the rows above are verified. Confirm them in .dev.vars.example before you rely on them.
Bindings take effect on the next deployment — Deployments → ⋯ → Retry deployment, or push a commit. Then open your node in a private window: the privacy disclosure banner appears on first load, and the network tab shows 204 responses from /api/ingest.
Provision the node identity
It reads your public key file automatically and writes through the publisher API, so it needs only the service token — no wrangler, no direct database access. Idempotent: re-running updates the row in place, preserving the node ID so existing references stay valid.
The migrations create the table but never populate it, and neither db:seed nor gen:node-key touches remote D1 — both only write your local SQLite file. Until you run the command below, the well-known endpoint 503s and every publish fails: dataset inserts stamp their origin node from that table, and the column is NOT NULL.
npm run terraviz -- init-node \ --server "$TERRAVIZ_SERVER" \ --client-id "$CF_ACCESS_CLIENT_ID" \ --client-secret "$CF_ACCESS_CLIENT_SECRET" \ --display-name "Terraviz — Your Org" \ --base-url "https://" \ --contact ops@
curl https:///.well-known/terraviz.json returns 200, with your display name and your real public key. Not 503.
Verify
Run both. They check different layers and neither subsumes the other. One asks whether the dashboard’s binding state matches what the code expects. The other asks whether the deployed node actually answers correctly.
# layer 1 — bindings CLOUDFLARE_API_TOKEN= \ CLOUDFLARE_ACCOUNT_ID= \ CLOUDFLARE_PAGES_PROJECT_NAME= \ npm run check:pages-bindings # layer 2 — does the node answer? TERRAVIZ_ACCESS_CLIENT_ID= \ TERRAVIZ_ACCESS_CLIENT_SECRET= \ npm run terraviz -- verify-deploy --server https://
Bindings audit clean, and five of six deploy checks green.
Sign in and become admin
Open https:///publish in a browser. Access challenges you, you sign in, and the middleware provisions a row for your email.
The first human to sign in on a deploy with no active admin is bootstrapped to admin automatically. Service tokens are excluded — a machine credential never self-elevates. So on a fresh node, you become the admin by signing in.
Everyone after you lands as a pending reviewer, and you approve and promote them from the Users tab. Admins cannot demote themselves or remove the last admin, so a node always keeps one operator.
/publish/me shows your email with role admin, and the sidebar shows the Users tab.
Put content in
Your node works but its catalog is empty. Publish your own from /publish/datasets/new — metadata-only drafts work immediately, asset uploads need Phase 8.5, and video needs 8.6 as well. Or mirror the upstream catalog, which is about 200 datasets.
npx tsx scripts/refresh-sos-snapshot.ts
npm run terraviz -- import-snapshot \
--server "$TERRAVIZ_SERVER" \
--client-id "$CF_ACCESS_CLIENT_ID" \
--client-secret "$CF_ACCESS_CLIENT_SECRET" \
--dry-run # always dry-run first, then drop it
Those rows carry legacy video references that resolve through upstream's proxy, so their playback depends on upstream's uptime unless you mirror the proxy too. Content you publish yourself is transcoded to your own storage and never touches it. Import is idempotent — re-running skips rows already published.
Re-run verify-deploy. catalog-populated now passes and all six checks are green. That is a complete publisher node. Embedding jobs backfill semantic search asynchronously over the next ten minutes or so.
Content-Security-Policy
public/_headers ships no CSP for the app. Its CSPs only stop other sites framing the portal and a few static folders. Upstream enforces an app policy at the Cloudflare edge, and edge rules do not travel with a fork. Every fork is unprotected until its operator adds one. Your node works without it; do it anyway before the node faces the public.
Remember blob: — the app uses it for preview tours and screenshots, and omitting it reproduces the "may not load data from blob:" failure. Test playback, VR and a tour before locking it down.
By default any site may show the node in an iframe, as the WordPress plugin does. To limit that, sign in as an admin and open Settings → Node profile, then the Embedding card. Choose Only sites I list or No other site. It takes effect within a few minutes, with no redeploy.
The card shows the headers a visitor gets on /, and warns about an X-Frame-Options header or another frame-ancestors. Cloudflare's Add security headers transform sends X-Frame-Options: SAMEORIGIN. With Any site can embed it blocks every other site. Cloudflare Access blocks embeds too, and the card cannot see it.
You can use an edge rule instead, with the setting left on Any site can embed. An edge rule that Sets a CSP replaces the node's own; use Add, so the portal keeps its frame-ancestors 'self'.
The standalone HTML build posts to /api/feedback, which takes wildcard CORS and no Origin so it works from file://. It needs a WAF skip rule: the widget runs without cookies and its fallback is a mailto: draft, so an interstitial silently swallows every submission rather than failing loudly.
What it takes: one WAF skip rule on POST /api/feedback, then a curl from a cookie-less client to confirm you get 200 {"ok":true} rather than challenge HTML. The bindings it needs — FEEDBACK_DB, and CATALOG_R2 for screenshots — you already set in Phase 8.
Analytics Engine retains 30–90 days. A daily job drains each completed day into an archive bucket plus rollups — that is the data behind the in-app analytics tab. Run the backfill once while AE still remembers.
What it takes: wrangler r2 bucket create terraviz-analytics, bind it as ANALYTICS_R2, set CF_ACCOUNT_ID plus an ANALYTICS_SQL_TOKEN secret scoped to Account Analytics → Read, redeploy, then enable the daily workflow — forks start with scheduled workflows off. Run the backfill once from the Actions tab while Analytics Engine still remembers. Two commands verify it.
Lets ci.yml apply pending catalog migrations on every push to main, just before deploy. Off unless you set ENABLE_D1_MIGRATE=1 — and only after granting your API token D1 Edit, because the step runs before the deploy and a token without it blocks the whole thing.
What it takes: grant your API token Account → D1 → Edit, then set the repo variable ENABLE_D1_MIGRATE=1. That order matters — the step runs before the deploy, so a token without D1 access blocks the whole deploy rather than just the migration. Editing a token's permissions keeps its value, so nothing needs rotating.
The in-app analytics tab is the primary surface and needs no external service. Grafana is here for ad-hoc SQL against the raw stream; there is no native Analytics Engine plugin, so the shipped dashboards post SQL over HTTP through Infinity.
What it takes: import the four dashboard JSONs from grafana/dashboards/ and point an Infinity datasource at the Analytics Engine SQL endpoint. There is no native plugin, so the dashboards post SQL over HTTP with root_selector: "data".
Orbit voice runs on the AI binding with nothing set. Realtime streaming STT, the wake word and YouTube media suggestions each want their own variables. Each degrades quietly when those are absent rather than erroring, so turning one on is additive and turning it off is safe.
What it takes: nothing for Orbit voice or the events/blog feeds — both already run. Realtime streaming STT wants three variables plus an AI Gateway token, the wake word wants a build-time model URL, and YouTube suggestions want an API key. Each is independent, and each stays off rather than erroring when its variables are absent.
Playback, VR and a tour all still work with the policy live.
Optional features
Everything up to here is work every node does. This is the first phase you can genuinely skip — the node is complete and serving content without any of it. All five are independent — read the trigger, take what you want, and come back for the rest whenever.
Four things used to be filed here and are not, because calling them optional was wrong. R2 asset storage is now 8.5, video transcode 8.6, and Orbit chat providers 8.7 — that last one was never a task at all. The Content-Security-Policy is Phase 13, because every fork needs one.
Each feature you turned on answers: the widget POST returns 200, the export writes an NDJSON object, /publish/analytics shows yesterday.
Desktop app fork
Three upstream-pinned values need changing, and each fails in its own way if you leave it. Skip this phase entirely for a web-only node.
src-tauri/tauri.conf.json hardcodes upstream's release feed and public key. Leave them and your users' apps poll upstream's releases and reject every build you sign — the update path looks fine until the day you ship one.
Generate a key pair, paste the public half into updater.pubkey, point updater.endpoints at your own fork’s latest.json, and set TAURI_SIGNING_PRIVATE_KEY plus its password as repo secrets.
npm run tauri signer generate -- -w "<password>"
Desktop webviews are served from tauri://localhost, so relative /api/ paths do not resolve. The app rewrites them to an absolute origin that defaults to upstream's. Set VITE_API_ORIGIN=https:// at build time or your desktop app talks to somebody else's backend.
The same value drives deep-link host recognition, so setting it is also what makes your node accept its own /dataset/… links.
macOS notarization (15.2) is optional but conspicuous: release.yml signs only when all six APPLE_* secrets are present. Without them the build succeeds and ships unsigned, and macOS users meet the Gatekeeper "damaged" warning.
Weblate (15.4) — sync-weblate.yml targets upstream’s translation project. Disable the workflow unless you run your own, or it fails on every push to main.
A signed desktop build that talks to your API origin, not upstream. Install it and confirm the update check hits your fork’s releases.
The ten symptoms people actually hit
Find your symptom, not your phase. Most of these exist because someone hit the snag and it was worth writing down — if yours is not here, that is worth an issue.
The team domain or the AUD is missing — most often set on Production but not Preview. Confirm with the bindings audit rather than by eye.
Your AUD does not match the application that issued the JWT. Re-copy it from the application's Overview tab. A token minted for a different application in the same team is rejected by design.
The policy uses Emails (exact match against one address) instead of Emails ending in (suffix match). See Phase 6.
The catalog migrations were not applied — usually because migrations apply was given the database name instead of the binding name. Confirm with migrations list CATALOG_DB --remote.
Remote node identity is empty — Phase 9 was not run. The local seed and key-gen paths do not write remote D1. The 503's own error text tells you to run gen:node-key; that hint is wrong. Use init-node.
The product is off on your account until somebody opens it once, and a Function declaring the binding cannot publish without it. Open Workers & Pages → Analytics Engine and create a dataset — name terraviz_events, binding ANALYTICS — then retry the deployment.
The binding is missing in the environment serving traffic — check both Production and Preview. The function silently skips the write when it is undefined. (A 403 instead means the CORS gate rejected it: curl does not send an Origin header unless you pass one.)
Expected before your Access application covers the publish paths. Cloudflare answers with a cross-origin redirect the portal can detect but cannot follow. The Refresh button is the escape hatch — refreshing triggers Access at top-level navigation, you sign in, and the next fetch succeeds.
R2 CORS. No console error means the request succeeded but the length headers were not exposed — add both to ExposeHeaders. An allow-origin error means it was blocked outright, usually because HEAD is not in AllowedMethods. R2 treats HEAD and GET as distinct even though Fetch does not.
Free-tier neuron exhaustion, not a fault. The chat panel shows a reduced-functionality badge and quota resets daily. A docent turn burns roughly 50 neurons, so about 200 turns a day exhausts the free ceiling — sustained use needs Workers Paid.
No service token configured. Re-run with the token env vars, and confirm the token is attached to a Service Auth policy on the publisher app.
Five things worth doing in week one
You have two: a KV key that makes clients back off, and an environment variable. Write down who is allowed to use them.
A silently expired API token is a silently broken dashboard, and it will happen on a day nobody is looking.
A flood of network errors usually means an asset CDN rate-limiting you. Auth errors mean an LLM key issue.
File a report through the in-app form and confirm it arrives in the portal. Do it before a visitor does.
The repo ships none for the app, and upstream's edge policy does not travel with a fork. See Phase 13 — and test playback, VR and a tour before you lock it down.