Forms and the data tier on Cloudflare (D1 + Worker)
- Status: Accepted
- Date: 2026-06-09
- Deciders: Engineering, Platform, Security
Supersedes ADR 0005. The original 2026-06-06 decision put the data tier on DigitalOcean App Platform (a Hono Node service) backed by Managed Postgres. The lead store is a tiny, low-write, append-only, read-rarely workload; a managed Postgres on a second cloud was over-built for it. D1 keeps everything on one platform, makes the form endpoint same-origin (no cross-origin CORS), has Time Travel point-in-time backups, and costs ~nothing at this volume. Crucially, the privacy argument that rejected third-party form SaaS does not apply to D1: the data stays in infrastructure we own (Cloudflare), not a third-party processor. The
apps/apipackage was deleted in this change.
Context
Section titled “Context”The site is static, but two flows are inherently dynamic and carry personal data: the contact / qualified-inquiry form (our primary conversion) and the careers application (our secondary conversion). These produce leads we must capture reliably, store durably, protect, and operate against (review, export, retention).
The workload itself is small and well-bounded: a handful of writes a day, append-only, read rarely (review/export). We want a durable, owned store and a minimal server surface — not a pile of serverless glue, but also not a managed relational database on a second cloud for what is effectively an append-only lead log.
Decision
Section titled “Decision”The data tier lives entirely on Cloudflare: the form endpoints are same-origin Astro API routes served by the site’s Cloudflare Worker, and leads persist in Cloudflare D1 (SQLite at the edge).
- The Astro site uses the @astrojs/cloudflare adapter and deploys as a Cloudflare Worker with Static Assets (ADR 0006). Content pages stay prerendered to static HTML; only the form endpoints are on-demand.
- The two endpoints are same-origin API routes —
apps/web/src/pages/api/contact.tsandapps/web/src/pages/api/careers.ts(bothexport const prerender = false). The browser POSTs to/api/contactand/api/careerson the same origin — no separateapi.quarry.teamsubdomain, no cross-origin CORS, and the CSP no longer needs anapi.quarry.teamallowance. - Shared lead logic lives in
apps/web/src/lib/leads.ts(Zod validation, honeypot check, Turnstile verification, D1 insert, Resend email + optional webhook notify), unit-tested inleads.test.ts. - Leads persist in Cloudflare D1, table
form_submissions(schema inapps/web/migrations/0001_form_submissions.sql), bound to the Worker asDBand accessed viaAstro.locals.runtime.env.DB. This is the single durable store for personal data, with Time Travel backups and a defined retention policy (seedocs/brd/non-functional-requirements.md). - Spam protection is layered: Cloudflare Turnstile (privacy-respecting CAPTCHA; token verified server-side), a honeypot field, and WAF rate-limiting on the form endpoints. (Rate-limiting belongs at the WAF — a per-isolate in-memory limiter is a no-op across Worker isolates.)
- Notifications are Resend email + an optional webhook, env-gated and non-blocking.
- Secrets are injected as Worker/Cloudflare secrets, never committed (ADR 0008).
Consequences
Section titled “Consequences”Positive
- Personal data lives in one owned, backed-up store (D1, Time Travel) with clear ownership and retention — and on the same platform as the rest of the stack.
- The form endpoint is same-origin: no CORS, no
api.quarry.teamsubdomain, and a simpler CSP. - One platform, one provider, one deploy model — no second cloud to provision, secure, or monitor.
- Clean separation is preserved: content pages stay fully static and edge-cached; only form POSTs hit the Worker and D1.
- Turnstile gives spam protection without cookies or a third-party tracking surface, consistent with our privacy posture (ADR 0010).
- Cost is effectively zero at our volume.
Negative / costs
- D1 is SQLite-at-edge: fine for a low-write append-only lead log, but not the tool to reach for if this store ever grows into a high-write or heavily-relational workload (revisit then).
- Rate-limiting is no longer in app code — it must be configured deliberately at the WAF (ADR 0008).
- The Worker now has an on-demand surface (the two endpoints) alongside static assets, versus a purely static deploy.
Alternatives considered
Section titled “Alternatives considered”- DigitalOcean App Platform (Hono service) + Managed Postgres. The original decision (ADR 0005). A mature, fully managed relational database is real, but it is over-built for a tiny, low-volume, append-only lead store: it adds a second cloud to provision and monitor, forces the form endpoint cross-origin (CORS + CSP allowances for
api.quarry.team), and costs more than the workload warrants. Superseded by Cloudflare D1 + a same-origin Worker. - Third-party form SaaS (Formspree, Basin, etc.). Sends leads — including PII — through an external processor, weakening our data-control story for the exact flow that is our primary conversion. Rejected. (Note this objection does not apply to D1, where data stays in infrastructure we own.)
- Email-only submission (mailto / SMTP relay). No durable, queryable store; lossy; poor for the careers pipeline. Rejected.