Vercel + Cloudflare production
This is the topology confirmed by the owner (PLAN.md §8, decision 2) and built out in
deploy/vercel-cloudflare/: three hosts, one API that never touches a serverless platform.
| Host | What | Where |
|---|---|---|
novaterra.world (+ www redirect) | apps/web, a static React SPA | Vercel |
docs.novaterra.world | apps/docs, static Astro Starlight | Vercel |
api.novaterra.world | apps/api — Fastify, WebSockets, SQLite | The owner’s own machine, reached through a Cloudflare Tunnel |
Why the API can’t live on Vercel: it’s a long-lived process with SQLite, WebSocket connections, the Docker sandbox, file watchers, and Telegram long-polling — none of which fit a serverless function’s lifecycle.
How the pieces actually talk
- The web app is built with
VITE_API_BASE=https://api.novaterra.worldbaked in at build time (apps/web/src/lib/api.ts), so everyfetchand the WebSocket connect straight to the API host — without it, the SPA would call/api/...on Vercel and getindex.htmlback. - The session cookie is set with
Domain=.novaterra.world; SameSite=Lax; Secure; HttpOnly(COOKIE_DOMAINin.env.production; see Security & privacy), and CORS allows credentials forWEB_ORIGINplusTRUSTED_ORIGINS.novaterra.world→api.novaterra.worldis cross-origin but same-site — exactly whatSameSite=Laxpermits on a credentialedfetchand on the WebSocket upgrade. - Cloudflare proxies only
api.*(through the tunnel). The two Vercel hosts stay DNS-only (grey cloud) so Vercel can issue and renew its own Let’s Encrypt certificates without Cloudflare’s proxy getting in the way of the HTTP-01 challenge.
The pieces, in order
- Vercel: the web project. Root directory
apps/web, Node 22, build commandpnpm --filter @novaterra/web build(fromapps/web/vercel.json), outputdist. The one environment variable that matters:VITE_API_BASE=https://api.novaterra.world, set for both Production and Preview — a redeploy is required after adding it, since Vite bakes env vars in at build time, not read at runtime. - Vercel: the docs project. Same repo, root directory
apps/docs,PUBLIC_API_BASEoptional. Subdomain, not a subpath:docs.novaterra.world, which is whyastro.config.mjsneedsbase: '/'. - Cloudflare DNS. An
Arecord at the apex and aCNAMEforwww/docspointing at Vercel (DNS-only, grey cloud), and aCNAMEforapipointing at the tunnel’s<UUID>.cfargotunnel.com(proxied, orange cloud — this one is mandatory). SSL/TLS mode Full (strict), minimum TLS 1.2, HSTS left off at the Cloudflare layer (Vercel already sends it for the static hosts; the API gets it from a transform rule). - Two Cloudflare rules for the API host specifically: a cache-bypass rule (Studio outputs under
/api/files/...—.html,.png,.zip— match Cloudflare’s default cacheable extensions and must never be shared between users), and a configuration rule turning off Rocket Loader / Auto Minify / Mirage / Polish for that hostname, since they rewrite HTML bodies and would corrupt Studio previews. .env.productionon the API host — copied fromdeploy/vercel-cloudflare/.env.production.example, at minimumSESSION_SECRET,ENCRYPTION_KEY,OWNER_ACCESS_CODE,OPENROUTER_API_KEY, plus exactly:NODE_ENV=productionWEB_ORIGIN=https://novaterra.worldTRUSTED_ORIGINS=https://www.novaterra.world,https://docs.novaterra.worldCOOKIE_DOMAIN=.novaterra.worldGOOGLE_REDIRECT_URI=https://api.novaterra.world/api/connections/gmail/callbackcloudflared, installed and pointed at the tunnel (deploy/vercel-cloudflare/install-tunnel.ps1/.sh): logs into the zone, creates the tunnel, routes the DNS record, renderscloudflared/config.yml(api.novaterra.world → http://localhost:4000), and installs itself as a service that reconnects automatically after a network drop.
Smoke test
curl -s https://api.novaterra.world/api/healthcurl -si -X OPTIONS https://api.novaterra.world/api/auth/me \ -H "Origin: https://novaterra.world" -H "Access-Control-Request-Method: GET" | grep -i access-controlYou want access-control-allow-origin: https://novaterra.world and
access-control-allow-credentials: true. Then, in a real browser: /enter with the production
OWNER_ACCESS_CODE, confirm the set-cookie header carries Domain=.novaterra.world; Secure; HttpOnly; SameSite=Lax, and watch DevTools → Network → WS show wss://api.novaterra.world/api/ws
at status 101 with a hello frame. deploy/vercel-cloudflare/checklist.md has the complete
pre-flight and post-launch list; rollback.md covers undoing each layer independently;
backup.md covers nightly SQLite + workspace backup to Cloudflare R2 via rclone.
Moving the API to a different machine later
Copy .env.production, apps/api/data/, workspace/, and the tunnel’s own credential file
(~/.cloudflared/*.json) to the new machine; re-run install-tunnel.ps1/.sh there (it reuses the
existing tunnel by name); stop the service on the old machine first — otherwise both machines
answer the same hostname and sessions split across two separate databases. This is exactly the path
from “tonight, the build PC” to “the owner’s home 3090 box” described in
Local-first inference.