Self-hosting a Terraviz node

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.

≈2–3 h for a publisher node A domain already on Cloudflare DNS Free plan works — $5/mo buys back analytics

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.

Step one · pick your node type

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.

What it costs

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.

Who runs Orbit's language model
You will need one extra thing

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.

Free plan · what changed on this page

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.

Storage · the same on both plans

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.

What
Free every month
Past that
R2 — video, images, tours
10 GB stored
$0.015/GB per month
R2 — serving it to visitors
unmetered
egress is free — no per-GB charge, ever
R2 — requests
10M reads, 1M writes
$0.36/M reads
D1 — catalog metadata
5 GB
$0.75/GB per month (paid plan only)

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.

Compute · not on your Cloudflare bill

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.

Before you start · only you can do these
11 things, about 20 minutes

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.

1. Install Node.js and npm setup will catch this
Every command in this install starts with npm run, including the setup tool itself. Without Node you cannot run step one. It is listed here rather than in the prose above the checklist because someone working through numbered steps reads the numbered steps.
How to do this ▸
Install the LTS build from nodejs.org — this repo needs Node.js 22+. It carries npm with it.
Check what you have:
node --version

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.

2. Install git and Git LFS on you
The next step clones your fork, and Cloudflare Pages builds from that remote. Downloading the repo as a zip gets you the code and no remote, which does not surface until Phase 5 has nothing to connect to. LFS belongs here for the opposite reason: a clone without it fails so quietly that nothing surfaces at all.
How to do this ▸
Install git if you do not have it. macOS and most Linux ship with it.
git --version
Turn on Git LFS in the same sitting. Git for Windows bundles it. On macOS and Linux it is a separate package — get it from git-lfs.com, or use brew install git-lfs or apt install git-lfs.
Either way, run this once per machine:
git lfs install

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.

3. Fork the repository on you
Everything here assumes your own copy. Phase 3 rewrites wrangler.toml with your resource IDs, Phase 5 points Pages at your remote, and the transcode workflow runs in your repo — none of which works against a repo you cannot push to. Cloning upstream directly fails late rather than early: it clones, it runs locally, and nothing complains until you have IDs to push and nowhere to push them.
How to do this ▸
Press Create fork. Nothing on that page needs changing: the owner is you, the repository name stays terraviz, and "Copy the main branch only" stays ticked.

main is the only branch a node needs, so leaving that box ticked is correct.

Open the Actions tab of your new fork and enable workflows.

GitHub creates every fork with Actions turned off. Leave them off and the transcode and deploy workflows never run.

Write the result down as owner/repo. That is the W3 the interview asks you for.
Clone your fork onto the machine you will install from. Everything after this runs inside that checkout.
git clone https://github.com/.git cd terraviz npm install

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.

4. Enable Workers Paid ($5/month) on you
Optional — every product this node binds has a Workers Free allocation, so a free-plan account installs and runs. What you buy is headroom. Workers AI stops at 10,000 Neurons a day, roughly 200 Orbit turns, and that ceiling cannot be raised without upgrading. It fails soft, which is worse than failing loudly: Orbit quietly degrades to its keyword engine mid-demo. Pages Functions are capped too, at 100,000 requests a day for the account: every page view, API call and map tile counts, and Paid removes the limit.
How to do this ▸
Open Workers & Pages, go to Plans, and subscribe to Workers Paid.

Billing is per account, not per project. One subscription covers every node you run on this account.

4. Workers Paid ($5/month) — you chose the free plan your choice
Nothing to do. Every resource in this install still gets created — you just have a smaller daily allowance of each. Orbit throttles after roughly 200 conversations a day, and that is the ceiling you cannot buy past. Change plan
5. Put your domain on Cloudflare DNS setup will catch this
Pages provisions the certificate and CNAME for your custom domain, and R2 can only serve a public bucket from a zone Cloudflare controls. Neither works on a domain hosted elsewhere.
How to do this ▸
Add your domain to Cloudflare, then change its nameservers at your registrar to the two Cloudflare gives you.

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.

6. Mint a Cloudflare API token setup will catch this
Everything this tool does runs through it.
How to do this ▸
Go to My Profile, then API Tokens, then Create Token, and choose Custom token.
Add these five first. Every node needs them, because Phase 2 creates all five resources.
Account → Cloudflare Pages Edit the project and its bindings Account → D1 Edit creates the database, runs migrations Account → Workers KV Storage Edit creates both KV namespaces Account → Workers R2 Storage Edit creates the assets bucket Account → Vectorize Edit creates the search index
Add these three for a publisher node. A viewer node never calls Access, so skip them.
Account → Access: Apps and Policies Edit the publisher application Account → Access: Service Tokens Edit the CLI credential Account → Access: Organizations Read discovers your team domain
Add these two only if you intend to run the step named beside each one.
Zone → Zone Read --only=r2 and --only=waf Zone → Zone WAF Edit --only=waf

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.

Copy the token when it is shown and put it in your shell, where the setup tool will find it.
export CLOUDFLARE_API_TOKEN=...

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.

7. Turn on Analytics Engine on you
It is off until someone opens it once, and the Pages deploy then fails outright rather than degrading: "Failed to publish your Function. You need to enable Analytics Engine." Nothing earlier in the install touches it. So the first sign is a deploy that will not publish, long after the binding was set, reading like a fault in the code rather than an account setting.
How to do this ▸
Open Workers & Pages, then Analytics Engine, and create a dataset.
Give it these two values. They are the ones Phase 8 binds, and the names have to match.
Dataset Name terraviz_events Dataset Binding ANALYTICS

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.

8. Complete Zero Trust onboarding setup will catch this
The Access application that gates the publisher API cannot exist until the account has a Zero Trust organization and at least one identity provider. Without it every /api/v1/publish/** route 503s.
How to do this ▸
Pick a team name when Cloudflare asks for one. It becomes your team domain, and you will need that later as W12.
<team>.cloudflareaccess.com
Go to Settings, then Authentication, and add at least one login method.

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.

9. Generate the node keypair setup will catch this
Signs your node federation responses and is advertised at /.well-known/terraviz.json. The generator also writes node-public-key.txt, which terraviz init-node reads in Phase 9.
How to do this ▸
Run the generator from your checkout.
npm run gen:node-key

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.

10. Connect the Pages project to your Git remote (or skip it) setup will catch this
The Cloudflare↔GitHub handshake is an OAuth flow with no API, so this tool creates a Direct Upload project. Cloudflare will not run your build until you connect a remote, which means the VITE_* build variables have to be set wherever the build actually happens.
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.

Option A — let Cloudflare build. In Workers & Pages, open your project, then Settings, then Builds, then Connect to Git.

Set the VITE_* variables in the dashboard afterwards, under the same Settings tab. Cloudflare rebuilds on every push to your default branch.

Option B — keep Direct Upload and build in CI. Set the VITE_* variables in the CI job instead, then deploy the finished directory.
wrangler pages deploy dist/ --project-name <your-project>

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.

11. Mint the R2 S3 API token on you
Server-side presigned uploads and digest verification need S3-API credentials. Automating this would require a token that can create tokens — a credential that could grant itself more authority — so it stays a deliberate manual step.
How to do this ▸
Go to R2, then Manage R2 API Tokens, then Create API token.
Give it Object Read & Write, scoped to your assets bucket rather than the whole account.
Copy all three values before you leave the page and put them in your shell.
export R2_S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com export R2_ACCESS_KEY_ID=... export R2_SECRET_ACCESS_KEY=...

The secret is shown once. If you lose it, delete the token and make another.

Re-run setup. The bindings step reads all three from your shell.
The install itself · what finished looks like
tick as you go

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.

00
You are logged in to the right Cloudflare account.
01
The globe loads on your laptop and all four test URLs answer.
02
You have written down the six IDs Cloudflare just gave you.
03
Your commands now point at your database, not the original project’s.
04
Your database has its tables, with nothing left to apply.
05
The site loads at your own domain, over HTTPS.
06
Staff sign-in works, and the token pair is saved somewhere safe.
07
Your node has its own signing key, and you have backed it up.
08
Your node can reach its database, storage and AI. Check in a private window.
09
Your node can say who it is when another node asks.
10
Both checks pass, except the empty-catalog one — that is expected here.
11
You are signed in as the admin and can add the rest of your team.
12
All six checks pass. Your node is finished.
13
A CSP is in place and nothing broke.
14
The optional features you chose are working.
15
Your desktop app talks to your node, not the original one.

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.

Value
00010203040506070809101112131415
ORG Your org domain
W1 Cloudflare account ID
W2 Node hostname
W3 Git remote
TRUST Auto-approve domains
W4 D1 database ID
W5 KV — TELEMETRY_KILL_SWITCH
W6 KV — CATALOG_KV
W7 R2 bucket name
W8 Vectorize index
W9 Analytics Engine dataset
W10 Pages project name
W11 CLOUDFLARE_API_TOKEN
W12 Access team domain
W13 Access AUD (publisher app)
W14 CF_ACCESS_CLIENT_ID
W15 CF_ACCESS_CLIENT_SECRET
W16 NODE_ID_PRIVATE_KEY_PEM
W17 Node public key
W18 PREVIEW_SIGNING_KEY
W19 R2 public origin
W20 R2_ACCESS_KEY_ID
W20b R2_SECRET_ACCESS_KEY
W21 R2 S3 endpoint
W22 GITHUB_DISPATCH_TOKEN
produced here needed here not filled in yet
The long bars are the expensive ones

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.

Phase 2 produces nothing anyone needs yet

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.

Phase 13 has no bars at all now

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.

Start here

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.

Plan by default
A bare run prints what it would do and exits. No network calls.
Idempotent
Re-running adopts what exists rather than making a second one.
Resumable
IDs land in .terraviz-setup.json as they are found. Never secrets.
PHASE 00 ≈20 min · all tiers · nothing here is automatable

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.

Produces — tap one to record it ORG your org domain TRUST auto-approve domains W1 cloudflare account id W2 node hostname W3 git remote
Trap · Fork the repository first — {{W3}} is your fork, not upstream

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
Write these down before you move on

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.

Your org domain you supply
The Access policy matches "emails ending in" this. A domain, not an address.
Auto-approve domains you supply
Optional, and reversible from the Users tab later. Sign-ins from these domains skip the approval queue but land READ-ONLY (role reviewer), able to publish nothing. It grants nobody admin — the first sign-in does that. Blank means every sign-in waits for you, your own team included.
Cloudflare account ID you supply
Dashboard sidebar, and in every dashboard URL.
Node hostname you supply
Hostname only. No https://, no trailing path.
Git remote you supply
Where Pages watches for builds.
Gate

wrangler whoami prints the account matching .

Full detail in SELF_HOSTING.md ↗

PHASE 01 ≈15 min · all tiers

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
Trap · Order matters

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
Worth knowing · No Cloudflare account needed yet

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
Gate

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.

Full detail in SELF_HOSTING.md ↗

PHASE 02 ≈10 min · all tiers

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.

Produces — tap one to record it W4 d1 database id W5 kv — telemetry_kill_switch W6 kv — catalog_kv W7 r2 bucket name W8 vectorize index W9 analytics engine dataset
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.

Write these down before you move on

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.

D1 database ID Cloudflare assigns
The one value you cannot recover from a later error.
KV — TELEMETRY_KILL_SWITCH Cloudflare assigns
KV — CATALOG_KV Cloudflare assigns
R2 bucket name has a default
Yours to rename; keep the bindings in sync if you do.
Vectorize index has a default
Analytics Engine dataset has a default
Nothing to create — it appears on first write.
Gate

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.

Full detail in SELF_HOSTING.md ↗

PHASE 03 ≈5 min · all tiers

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.

Worth knowing · Why this file matters when Pages ignores it

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.

Gate

wrangler d1 info CATALOG_DB shows your database, 0 tables.

Full detail in SELF_HOSTING.md ↗

PHASE 04 ≈10 min · all tiers · the one that bites

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.

Trap · Select by binding name, never by database name

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/.

Gate

Both migration lists report nothing pending. Re-run both after every git pull that brings new migration files — they are idempotent.

Full detail in SELF_HOSTING.md ↗

PHASE 05 ≈25 min · all tiers · Tier 1 finishes here

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.

Produces — tap one to record it W10 pages project name W11 cloudflare_api_token
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.

Worth knowing · Pick exactly one deploy path

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.

Write these down before you move on

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.

Pages project name you supply
CLOUDFLARE_API_TOKEN you generate secret
Minimum scope: Account → Cloudflare Pages → Edit.
Gate

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.

Full detail in SELF_HOSTING.md ↗

PHASE 06 ≈25 min · Tier 2+ · everything after this depends on it

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.

Produces — tap one to record it W12 access team domain W13 access aud (publisher app) W14 cf_access_client_id W15 cf_access_client_secret
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.

Worth knowing · Do this part yourself first

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 .

Write these down before you move on

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.

Access team domain Cloudflare assigns
Team domain only, no https://. The tool discovers this for you.
Access AUD (publisher app) Cloudflare assigns
Application → Overview → Application Audience Tag.
CF_ACCESS_CLIENT_ID shown once secret
CF_ACCESS_CLIENT_SECRET shown once secret
Cloudflare shows this exactly once. Save it before closing the dialog.
Gate

The AUD is copied, the token pair is in your password manager, and the token appears in the Automation policy's include list.

Full detail, plus the two optional Access apps ↗

PHASE 07 ≈5 min · Tier 2+

Generate your node's secrets

Two secrets are yours to create, and neither exists until you make it.

Produces — tap one to record it W16 node_id_private_key_pem W17 node public key W18 preview_signing_key

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
Trap · Back up the private key somewhere durable

It signs your node's federation responses. Regenerating it means re-provisioning your identity.

Write these down before you move on

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.

NODE_ID_PRIVATE_KEY_PEM you generate secret
Back this up. Regenerating it means re-provisioning your identity.
Node public key you generate
Written to node-public-key.txt by gen:node-key.
PREVIEW_SIGNING_KEY you generate secret
Gate

.dev.vars holds a single-line private key, and node-public-key.txt holds an ed25519: line.

Full detail in SELF_HOSTING.md ↗

PHASE 08 ≈35 min by hand, mostly one command · all tiers

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.

Trap · Set every entry on BOTH Production and Preview

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.

Trap · There is a WAF trap in video transcode

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.

Trap · R2's CORS is strict in two ways

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.

Name
Type
Envs
What the audit says when it is missing
ACCESS_TEAM_DOMAIN
plaintext
both
Without this the publisher middleware 503s with access_unconfigured. Set to the team domain (no protocol).
ACCESS_AUD
plaintext
both
The AUD tag from the Access application. Mismatch surfaces as 401 "Invalid or expired Access assertion".
NODE_ID_PRIVATE_KEY_PEM
secret
both
Generate with npm run gen:node-key, then wrangler pages secret put NODE_ID_PRIVATE_KEY_PEM.
PREVIEW_SIGNING_KEY
secret
both
HMAC-SHA-256 secret for preview tokens. Without it the preview endpoints fail closed (503 preview_unconfigured).
CATALOG_DB
d1
both
D1 database carrying the catalog schema (datasets, tours, publishers).
CATALOG_KV
kv
both
KV namespace for the public catalog snapshot. Without it /api/v1/catalog burns ~5 D1 reads per browse-page load.
CATALOG_R2
r2
both
R2 bucket for sphere thumbnails, image data refs, and tour JSON.
AI
ai
both
Workers AI binding. Without it /api/v1/search returns 200 with { degraded: 'unconfigured' } and a Warning header (the route never 5xxs for missing bindings); the docent's [RELEVANT DATASETS] block stays empty and chip rendering relies on the local-engine fallback (1f/O).
CATALOG_VECTORIZE
vectorize
both
Vectorize index terraviz-datasets. Provision via wrangler vectorize create terraviz-datasets --dimensions=768 --metric=cosine plus the metadata-index commands in CATALOG_BACKEND_DEVELOPMENT.md.
FEEDBACK_DB
d1
both
Sphere feedback D1 — same physical database as CATALOG_DB on the reference deploy, separate binding for migration scoping.
ANALYTICS
analytics_engine
both
Analytics Engine dataset terraviz_events — backs the Grafana dashboards.
TELEMETRY_KILL_SWITCH
kv
both
KV namespace for the telemetry runtime kill switch. Read on every ingest request. The endpoint deliberately fails OPEN when this binding is missing or its read throws (functions/api/ingest.ts isKillSwitchOn) — telemetry continues to ingest. So a missing binding is operator-actionable (you lose the emergency lever) but does not stop ingest.
R2_PUBLIC_BASE
plaintext
both
Public origin for the R2 bucket — set to your custom domain (e.g. https://video.zyra-project.org). Bind the domain in Cloudflare dashboard → R2 → bucket → Settings → Connect Domain first. The manifest endpoint uses this base to construct HLS master playlist URLs for Phase 3 r2:videos/ data_refs, the publisher API + SPA use it for Phase 3b r2:datasets/<id>/<asset> auxiliary-asset references, and the SPA tour engine uses it for Phase 3c r2:tours/<id>/tour.json + sibling references. Note: R2_S3_ENDPOINT is NOT a fallback for the HLS branch — that endpoint is for signed S3-API access, not public reads, and falling through to it would yield an hls URL that 403s at play time on a typical (non-public-bucket) production setup.
R2_S3_ENDPOINT
secret
both
R2 S3-API endpoint URL (e.g. https://<acct>.r2.cloudflarestorage.com). Shown alongside the access key when the R2 API token is minted. Read by the migrate-r2-hls / rollback-r2-hls (Phase 3), migrate-r2-assets / rollback-r2-assets (Phase 3b), and migrate-r2-tours / rollback-r2-tours (Phase 3c) CLIs from the operator's shell as well — same value goes in all three places.
R2_ACCESS_KEY_ID
secret
both
R2 S3-API access key id. Mint via R2 dashboard → Manage R2 API Tokens → Create token with Read+Write on the catalog bucket.
R2_SECRET_ACCESS_KEY
secret
both
R2 S3-API secret access key. Paired with R2_ACCESS_KEY_ID; shown once at token mint time.
GITHUB_OWNER
plaintext
both
Repo owner that hosts the transcode-hls workflow (e.g. zyra-project). Paired with GITHUB_REPO + GITHUB_DISPATCH_TOKEN to build the repository_dispatch URL.
GITHUB_REPO
plaintext
both
Repo name that hosts the transcode-hls workflow (e.g. terraviz). See GITHUB_OWNER.
GITHUB_DISPATCH_TOKEN
secret
both
GitHub fine-grained PAT (Contents: write on the workflow repo) or classic PAT with repo scope. Used by /asset/.../complete to fire the transcode-hls repository_dispatch on a video upload.
Your own model — three more variables

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.

Name
Kind
Value
ORBIT_LLM_BASE_URL
plaintext
Your endpoint, reachable from the public internet. Ollama’s default path ends /v1.
\n
ORBIT_LLM_MODEL
plaintext
The model name as your server reports it.
\n
ORBIT_LLM_API_KEY
secret
Whatever your server expects. Set something even if it ignores it, so the endpoint is not open to anyone who finds it.
Check the names against the repo

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.

Gate

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.

Full detail in SELF_HOSTING.md ↗

PHASE 09 ≈5 min · Tier 2+

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.

Trap · Your production identity table is empty right now

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@
Gate

curl https:///.well-known/terraviz.json returns 200, with your display name and your real public key. Not 503.

Full detail, plus the wrangler fallback ↗

PHASE 10 ≈5 min · all tiers

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://
Gate

Bindings audit clean, and five of six deploy checks green.

Full detail in SELF_HOSTING.md ↗

PHASE 11 ≈5 min · Tier 2+ · no SQL required

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.

How it works · How the first admin happens

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.

Gate

/publish/me shows your email with role admin, and the sidebar shows the Users tab.

Full detail, plus the no-admin escape hatch ↗

PHASE 12 ≈15 min · Tier 2+ · the last required phase

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
Worth knowing · Know the tradeoff before you mirror

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.

Gate

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.

Full detail in SELF_HOSTING.md ↗

PHASE 13 ≈20 min · all tiers · before you go public

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.

Worth knowing · If other sites embed the node

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'.

14.1 · Standalone feedback widget
if you ship it

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.

14.2 · Analytics long-term export
recommended

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.

14.3 · CI-applied migrations
opt-in

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.

14.4 · Grafana
probably skip

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".

14.5 · Voice, events, blog, YouTube
per-feature

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.

Gate

Playback, VR and a tour all still work with the policy live.

Full detail in SELF_HOSTING.md ↗

PHASE 14 optional · Tier 2+ · take what you want

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.

Gate

Each feature you turned on answers: the widget POST returns 200, the export writes an NDJSON object, /publish/analytics shows yesterday.

All five, in full ↗

PHASE 15 ≈1 h · Tier 3 only

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.

Trap · The updater endpoint and signing key (15.1)

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>"
Trap · `VITE_API_ORIGIN` (15.3)

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.

Worth knowing · Two more that only bite later

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.

Gate

A signed desktop build that talks to your API origin, not upstream. Install it and confirm the update check hits your fork’s releases.

All four steps, in full ↗

If you are stuck right now

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.

503 access_unconfigured on the publisher API

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.

401 "Invalid or expired Access assertion"

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.

Access blocks your own staff account

The policy uses Emails (exact match against one address) instead of Emails ending in (suffix match). See Phase 6.

Save draft 500s: "table datasets has no column named bbox_n"

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.

well-known 503s, or publishing fails on origin_node

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.

Deploy fails: "You need to enable Analytics Engine"

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.

Ingest returns 204 but nothing lands in Analytics Engine

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.)

Portal shows "Your session has expired"

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.

Zip download shows "size unknown"

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.

Orbit stops showing dataset chips after working briefly

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.

verify-deploy SKIPs the publisher checks

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.

All sixteen symptoms in SELF_HOSTING.md ↗

After a successful launch

Five things worth doing in week one

Document who can flip the kill switches

You have two: a KV key that makes clients back off, and an environment variable. Write down who is allowed to use them.

Put token expiry in a calendar

A silently expired API token is a silently broken dashboard, and it will happen on a day nobody is looking.

Watch errors by category

A flood of network errors usually means an asset CDN rate-limiting you. Auth errors mean an LLM key issue.

Test your own feedback loop

File a report through the in-app form and confirm it arrives in the portal. Do it before a visitor does.

Add a Content-Security-Policy

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.

Your values
Fill these in once. Every command on the page updates.
Of these, 7 are things the setup interview actually asks you for. The rest Cloudflare assigns, a local command generates, or a dialog shows once — you are recording them here, not inventing them.
Plain values are kept in this browser only. Anything marked SECRET is held for this session only and never written to storage. Nothing leaves your machine either way.
Your org domain you supply
The Access policy matches "emails ending in" this. A domain, not an address.
Cloudflare account ID you supply
Dashboard sidebar, and in every dashboard URL.
Node hostname you supply
Hostname only. No https://, no trailing path.
Git remote you supply
Where Pages watches for builds.
Auto-approve domains you supply
Optional, and reversible from the Users tab later. Sign-ins from these domains skip the approval queue but land READ-ONLY (role reviewer), able to publish nothing. It grants nobody admin — the first sign-in does that. Blank means every sign-in waits for you, your own team included.
D1 database ID Cloudflare assigns
The one value you cannot recover from a later error.
KV — TELEMETRY_KILL_SWITCH Cloudflare assigns
KV — CATALOG_KV Cloudflare assigns
R2 bucket name has a default
Yours to rename; keep the bindings in sync if you do.
Vectorize index has a default
Analytics Engine dataset has a default
Nothing to create — it appears on first write.
Pages project name you supply
CLOUDFLARE_API_TOKEN you generate secret
Minimum scope: Account → Cloudflare Pages → Edit.
Access team domain Cloudflare assigns
Team domain only, no https://. The tool discovers this for you.
Access AUD (publisher app) Cloudflare assigns
Application → Overview → Application Audience Tag.
CF_ACCESS_CLIENT_ID shown once secret
CF_ACCESS_CLIENT_SECRET shown once secret
Cloudflare shows this exactly once. Save it before closing the dialog.
NODE_ID_PRIVATE_KEY_PEM you generate secret
Back this up. Regenerating it means re-provisioning your identity.
Node public key you generate
Written to node-public-key.txt by gen:node-key.
PREVIEW_SIGNING_KEY you generate secret
R2 public origin you supply
Phase 8.5 — needed for asset uploads.
R2_ACCESS_KEY_ID shown once secret
Phase 8.5.
R2_SECRET_ACCESS_KEY shown once secret
Phase 8.5. Paired with the access key id.
R2 S3 endpoint shown once
Phase 8.5.
GITHUB_DISPATCH_TOKEN you generate secret
Phase 8.6 — needed for video uploads.