Skip to main content

Cloudflare topology

The platform is two deployables plus one database:

ComponentWorkerWhatHosted on
API / Worker (api/)protovoc-apiHono Worker — /v1 API, public survey + webhook endpoints, Cron + Queue handlersCloudflare Workers
Frontend (frontend/)protovocVite SPA (admin console) + respondent survey runtimeCloudflare Workers (Static Assets)
DatabaseNeon Postgres (business/authorisation schema + RLS; hosted identity/sessions live in Clerk)Neon, reached via Hyperdrive in prod

This page is the topology overview. The full step-by-step runbook is DEPLOYMENT.md at the repo root; the credential reference is SECRETS.md. Deployment is parked pending explicit approval. The repo is prepared for Cloudflare Workers Builds (a separate project per deployable, by Root directory frontend/ vs api/); production-connected projects must remain disabled or disconnected while deployment is parked. Do not push or deploy as an incidental validation step. Once approved, follow the runbook sequence and provision the database, secrets, and declared bindings in order (see Contributing).

The Worker

api/src/index.ts is the single Worker entrypoint exposing all three Cloudflare handlers:

export default {
fetch: app.fetch, // the Hono app (API + public + webhooks)
scheduled: (event, env) => handleScheduled(event, env), // Cron jobs
queue: (batch, env) => handleQueueBatch(batch, env), // ingestion queue consumer
};

So one deploy ships the API, the Cron jobs, and the queue consumer together. wrangler deploy from api/ publishes the protovoc-api Worker; after explicit enablement, the connected Workers Builds project can do the same on a push to main.

Frontend (Workers Static Assets)

npm run build --workspace frontend (Vite → dist/) emits the SPA assets. The committed frontend/wrangler.jsonc deploys them through Workers Static Assets with SPA fallback, while frontend/worker/index.ts proxies API/public paths through the API service binding. After explicit enablement, the protovoc Workers Builds project can deploy it on push (npx wrangler deploy from frontend/). Build-time VITE_* vars are public — they ship in the bundle — so only public values go there (see Configuration). The frontend origin must be added to the API Worker's CORS_ORIGINS. Cloudflare Pages is not part of the fixed deployment topology.

Hyperdrive → Neon

In production the Worker never holds a raw Neon connection string on the request path. It reaches Postgres through a Cloudflare Hyperdrive binding (connection pooling + edge caching):

wrangler hyperdrive create voc-db --connection-string="postgresql://USER:PASS@HOST.neon.tech/voc?sslmode=require"

The returned id goes into api/wrangler.toml under [[hyperdrive]] (binding = "HYPERDRIVE"). The composition root prefers env.HYPERDRIVE and falls back to DATABASE_URL only in local dev, so once the binding exists the Worker uses the pool automatically. The raw DATABASE_URL is then needed only by the migration step, not the request path.

Hosted users, sessions, and organisations live in Clerk. The Worker verifies the Clerk session JWT with @clerk/backend against Clerk's JWKS (cached by the SDK, or fully networkless when CLERK_JWT_KEY is set); Postgres retains tenant membership, role/permission mappings, organisation linkage, and first-party API keys. This split keeps hosted identity behind its port without pretending authorisation state is absent from the database.

Other bindings

api/wrangler.toml declares the production Hyperdrive, R2, Queue/DLQ, rate-limiter and Analytics bindings. The matching resources must exist before deployment; local/test environments may omit their bindings:

  • [[r2_buckets]] (R2_BUCKET) — file-drop uploads and export artefacts. Large media goes to R2 and is processed by reference (the 128 KB queue cap means you never carry blobs in a message).
  • [[queues.producers]] / [[queues.consumers]] (QUEUE_INGESTION, DLQ voc-ingestion-dlq) — six typed wake-ups share the Queue: response enrichment, file drops, erasure, retention, notifications and durable exports. Without a local binding response enrichment is skipped; accepted durable work remains authoritative in Postgres/R2 for Cron recovery.

Migrations & RLS gate

Pending SQL is rehearsed in one rollback-only transaction first (npm run db:migrate:rehearse --workspace db). The database is then migrated before the Worker goes live (npm run db:migrate --workspace db), and tenant isolation is proved afterwards (npm run db:verify-rls --workspace db) — every tenant-owned table must have RLS enabled + forced + a tenant_isolation policy, and an unset tenant context must be a hard error. Migrations are forward-only and hand-authored; there is no down-migration. See DEPLOYMENT.md §2 for the full sequence.