Cloudflare topology
The platform is two deployables plus one database:
| Component | Worker | What | Hosted on |
|---|---|---|---|
API / Worker (api/) | protovoc-api | Hono Worker — /v1 API, public survey + webhook endpoints, Cron + Queue handlers | Cloudflare Workers |
Frontend (frontend/) | protovoc | Vite SPA (admin console) + respondent survey runtime | Cloudflare Workers (Static Assets) |
| Database | — | Neon 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, DLQvoc-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.