Skip to content

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.

HostWhatWhere
novaterra.world (+ www redirect)apps/web, a static React SPAVercel
docs.novaterra.worldapps/docs, static Astro StarlightVercel
api.novaterra.worldapps/api — Fastify, WebSockets, SQLiteThe 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.world baked in at build time (apps/web/src/lib/api.ts), so every fetch and the WebSocket connect straight to the API host — without it, the SPA would call /api/... on Vercel and get index.html back.
  • The session cookie is set with Domain=.novaterra.world; SameSite=Lax; Secure; HttpOnly (COOKIE_DOMAIN in .env.production; see Security & privacy), and CORS allows credentials for WEB_ORIGIN plus TRUSTED_ORIGINS. novaterra.worldapi.novaterra.world is cross-origin but same-site — exactly what SameSite=Lax permits on a credentialed fetch and 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

  1. Vercel: the web project. Root directory apps/web, Node 22, build command pnpm --filter @novaterra/web build (from apps/web/vercel.json), output dist. 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.
  2. Vercel: the docs project. Same repo, root directory apps/docs, PUBLIC_API_BASE optional. Subdomain, not a subpath: docs.novaterra.world, which is why astro.config.mjs needs base: '/'.
  3. Cloudflare DNS. An A record at the apex and a CNAME for www/docs pointing at Vercel (DNS-only, grey cloud), and a CNAME for api pointing 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).
  4. 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.
  5. .env.production on the API host — copied from deploy/vercel-cloudflare/.env.production.example, at minimum SESSION_SECRET, ENCRYPTION_KEY, OWNER_ACCESS_CODE, OPENROUTER_API_KEY, plus exactly:
    NODE_ENV=production
    WEB_ORIGIN=https://novaterra.world
    TRUSTED_ORIGINS=https://www.novaterra.world,https://docs.novaterra.world
    COOKIE_DOMAIN=.novaterra.world
    GOOGLE_REDIRECT_URI=https://api.novaterra.world/api/connections/gmail/callback
  6. cloudflared, installed and pointed at the tunnel (deploy/vercel-cloudflare/install-tunnel.ps1 / .sh): logs into the zone, creates the tunnel, routes the DNS record, renders cloudflared/config.yml (api.novaterra.world → http://localhost:4000), and installs itself as a service that reconnects automatically after a network drop.

Smoke test

Terminal window
curl -s https://api.novaterra.world/api/health
curl -si -X OPTIONS https://api.novaterra.world/api/auth/me \
-H "Origin: https://novaterra.world" -H "Access-Control-Request-Method: GET" | grep -i access-control

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