The backend we didn’t want to run
Roadbook is an EV road-trip planner. You search for places, string them into a trip, and share the result. It is read-heavy, almost entirely server-rendered, and has a handful of genuinely slow background jobs hanging off the edges.
Two obvious ways to build that, and neither is a bad one. Supabase hands you
managed Postgres, pg_cron for the scheduled work, and edge functions for
everything else - most of the shape of this application on day one. Or Google
Cloud: Cloud Run for the app, Cloud SQL for the data, Cloud Scheduler for the
cron. We have
shipped server-rendered apps on Cloud Run before
and it works fine.
What put us off Supabase is where it leads, not where it starts: a vendor SDK between us and our own data, access control as a row-level-security DSL instead of code we can read, and eventually “the platform supports this pattern but not that one” on a feature you already promised someone. Google Cloud has the opposite problem. Nothing stands between us and the code, but now there are four managed services to wire together, each with its own IAM, its own deploy, and its own line on the bill, for an application that mostly reads rows and renders HTML. We wanted what stays out of the way in month eight, not what is quickest in week one.
So we put all of it on Cloudflare: one Worker that server-renders the pages, queries a SQLite database at the edge, runs durable background enrichment, and fires scheduled jobs. No origin server, no container, no separate API service. One deployment artifact, not four.
The stack
The app is Astro 7 in SSR mode, compiled to a Cloudflare
Worker by the
@astrojs/cloudflare
adapter. The Worker reads and writes D1
(Cloudflare’s SQLite), keeps rate-limit counters in
KV, and hands slow work off to
Cloudflare Workflows.
Interactive UI is Solid.js mounted into Astro pages;
everything else is server-rendered HTML. Styling is Tailwind CSS v4.
The decision that shapes everything else is that all of it ships as one
Worker. astro.config.mjs sets output: 'server' with the Cloudflare
adapter, and wrangler.jsonc points main at a single entry file.
One Worker, three entry points
Astro’s adapter gives us a request handler, but we wrap it in a small
hand-written Worker entry so it can export more than a fetch handler:
// src/worker.ts
import { handle } from '@astrojs/cloudflare/handler'
import { dispatchCron } from './lib/cron-dispatch.js'
import type { AppEnv } from './lib/env.js'
export { PlaceEnrichmentWorkflow } from './workflows/place-enrichment'
export default {
async fetch(request, env, ctx) {
return handle(request, env, ctx)
},
async scheduled(controller, env, ctx) {
await dispatchCron(controller.cron, env)
}
} satisfies ExportedHandler<AppEnv>
That one module exposes three runtime surfaces:
fetchis the SSR web app.scheduledis the cron handler. We register five schedules inwrangler.jsoncand route each cron expression to its own job - Bluesky report counts hourly, travel stats recomputed nightly, trip reminders, and two weekly spotlight posts. An unrecognized expression throws rather than falling through to a default.- A re-exported Workflow class for durable background jobs, which the app reaches through a binding rather than an HTTP route.
Deployment is a git push
There is no deploy script and no manual wrangler deploy in our day-to-day. We
connected the repository through
Cloudflare’s GitHub integration,
and from then on Cloudflare builds on every push. A push to main builds
and deploys to production. A push to any other branch, or an open pull request,
builds and deploys a preview on its own URL, running the real Worker against
real bindings. We click through a change on a live edge deployment before it
ever reaches main.
The one thing that is deliberately not automatic is the database. Schema changes
ride a separate GitHub Actions workflow that runs wrangler d1 migrations apply
on merge, so a code deploy and a migration never become one atomic action. That
separation is the point: it keeps a runtime deploy from quietly reshaping the
database underneath itself.
Bindings instead of connection strings
There is no DATABASE_URL, no secrets file loaded at boot, no connection pool.
Every external resource is a binding the runtime injects: DB for D1,
RATE_LIMIT_KV for the KV namespace, AI for Workers AI,
PLACE_ENRICHMENT_WORKFLOW for the workflow, plus tokens for the various
upstream APIs. They are all declared in wrangler.jsonc.
We funnel them through a single module so binding validation lives in one place instead of scattered across every call site:
// src/lib/env.ts - the canonical, validated accessor for every binding
import { env as cfEnv } from 'cloudflare:workers'
import invariant from 'tiny-invariant'
const raw = cfEnv as unknown as RawEnv
function requireDB(): D1Database {
invariant(raw.DB, 'DB binding is required')
return raw.DB
}
export const env = {
get DB() {
return requireDB()
},
get PUBLIC_URL() {
return requirePublicUrl()
}
// ...more validated getters
}
Everywhere else just imports that:
import { env } from '@/lib/env'
// Validated on access: a missing binding throws here, not at module load.
const db = env.DB
Two details took us a while to get right:
- Validation has to be lazy. We assert a required binding on first property
access, never at module scope. The versions-API deploy path (Wrangler 4.98 and
up,
@cloudflare/vite-plugin1.39 and up) runs a startup check that evaluates the Worker’s global scope with an empty env - no bindings, no vars, no secrets. Anything that throws at module load fails the deploy with error 10021. Asserting inside a getter defers the check to request time, where env is fully populated. - The accessor is a policy, and policies leak. A handful of
src/lib/*modules still importcloudflare:workersdirectly, and we are retiring them getter by getter. A lint rule bans the case we care about most, reaching for the rawDBbinding instead ofenv.DB. This kind of boundary holds because something enforces it, not because it was declared once.
D1 is SQLite, and it behaves like SQLite
D1 is SQLite exposed as a binding. You write SQL, you get rows back, and it runs close to the user. For a read-heavy product like ours, browsing places and trips, it is a good fit.
We own the schema with Prisma, which generates the model types and enums the rest of the app is written against. The part worth showing here is how a schema change reaches production.
prisma migrate diff generates plain SQL migration files, and Wrangler applies
them directly (migrations_dir and migrations_pattern in wrangler.jsonc,
tracked in a d1_migrations table). On merge to main, the GitHub Actions
workflow runs wrangler d1 migrations apply roadbook --remote against
production. So the migration SQL is generated rather than hand-written, and
nobody runs execute --remote by hand - which is exactly the arrangement that
produced the incident below.
The migration that wiped nine tables
Prisma’s default strategy for certain column changes is RedefineTables: create
a new table, copy the rows, drop the old one, rename. On a normal SQLite
database you wrap that in PRAGMA foreign_keys=OFF and it is safe. D1’s batch
executor ignores that pragma. The DROP TABLE fires every ON DELETE CASCADE
pointing at the table.
One such migration cascade-wiped nine of our tables. Recovery was undramatic: we rolled the D1 database back, fixed the migration, and re-ran it. We were still in MVP with no real user data on the line, so the cost was lost time. That is the part that does not survive contact with a live product, though. The same migration against a database with real traffic is five to ten minutes of downtime. We got the warning shot at the only stage where it was cheap.
The fix was procedural rather than clever:
- Rewrite the migration as
ALTER TABLE ADD COLUMNwherever possible. - A CI check scans every migration for an unguarded
DROP TABLEand fails the build. - A schema change cannot merge without the generated SQL file alongside it.
If you run D1 behind any ORM that autogenerates migrations, read what those migrations actually emit before they touch production data. The table-rebuild pattern is common, and D1 does not honor the safety valve the pattern assumes.
Sessions in D1, counters in KV
Auth is AT Protocol (Bluesky) OAuth with DPoP-bound
tokens. Sessions live in D1 keyed by sessionId, so one account can hold many
concurrent device sessions, and the session cookie is HMAC-signed. Middleware
runs on every request, validates the signature, and refreshes access tokens that
are close to expiring. Enough of that work is reusable that we pulled it out
into atproto-skills, a set of
Claude skills covering AT Protocol identity and handle resolution, lexicons, and
the OAuth flow with DPoP - the parts that are easy to get subtly wrong.
Short-lived OAuth CSRF and PKCE state goes in a single D1 table that is nothing
but key, stateData, and expiresAt, so we are not adding a table per flavor
of signed state.
KV takes the workloads it is genuinely good at: ephemeral, read-mostly, TTL-friendly state. Per-account rate-limit counters for endpoints that hit paid upstreams - charger lookups, weather, place search - are KV entries with a TTL rather than hand-rolled counters. If you need to write KV keys from CI, we maintain cloudflare-kv-action for exactly that.
Where KV stopped working is more instructive than where it worked. KV documents a hard limit of one write per second to the same key, which is fine for a per-user bucket and useless as a global ceiling on anonymous traffic. Those guest limits moved to Cloudflare’s native Rate Limiting binding, which is atomic at the edge, at the cost of a window you do not get to pick: the period has to be 10 or 60 seconds. The authenticated per-account buckets stayed on KV.
The rule of thumb that settled out: D1 for anything relational or that you need to query, KV for counters, flags, and blobs you look up by key, and the native binding when you need a limit that actually holds under concurrency.
Long work belongs in a Workflow
When a new place is created it needs enrichment - fetch external data, pull images, generate a summary and a set of structured facts, verify the output, persist. That is a multi-second, multi-step, failure-prone pipeline. Running it inside the request that created the place would block the user and lose all progress on any transient upstream error.
Workflows are built for exactly this. The pipeline is a class with discrete, individually-retried steps. Ours has grown to thirteen; this is the spine of it:
load-place → fetch-external → fetch-images → generate-summary
→ generate-facts → verify-generation → persist
The rest are the ones you only find out you need once real data shows up: resolving a timezone, pulling charger coverage, campground facts, derived interest tags.
Each step is durable. If image fetching fails on a flaky upstream, the runtime retries that step without re-running the ones before it. The request that created the place starts the workflow through its binding and returns immediately. There is no queue for us to run and no worker pool to babysit; the platform owns the durability.
Summaries run on Workers AI
The generate-summary and generate-facts steps run on
Workers AI, not an external
API. We call the @cf/google/gemma-4-26b-a4b-it model through the AI binding
to turn raw place data into a readable summary and a structured set of facts,
and the same model produces trip-level summaries.
Running the model as a binding keeps it in the same request lifecycle as everything else: no separate API key to rotate, no extra hop off the edge, no separate billing surface.
Caching is where the interesting decision is. Inference is the expensive part and place data rarely changes, so summaries are cached indefinitely and read without an expiry check - the opposite of the weather and campground caches, which are TTL’d in the same table. Age is not what makes a summary stale; an edit is. If someone marks a stop private, a summary written before that edit may still name it. So the cache is invalidated explicitly and synchronously on those events, before any regeneration is queued: fail closed, then regenerate. A best-effort regen that silently fails must never leave the pre-edit text in place.
What it buys
- Deploy is
git push. Merge tomainand it is in production. No release step, no deploy dashboard. - Every pull request is a real environment. Cloudflare builds a preview deployment for each branch and pull request, running the actual Worker against real bindings. Our issue-to-branch-to-PR-to-review flow doubles as a staging pipeline, with no environments to provision or tear down.
- We own the whole stack. This is the big one, and the clearest contrast
with the Supabase route we passed on. No vendor SDK between us and our data,
no row-level-security DSL to learn, no unsupported patterns. It is our Worker
code, our SQL, our routes. When we need a background job, a cron, or a model
call, we add a
scheduledhandler or a binding - we are not waiting on a platform to expose a feature. - One deploy. App, background jobs, and cron are a single Worker. One thing to version, one thing to roll back - not four managed services with four IAM policies and four ways to drift out of sync.
What it costs
- SQLite’s ceilings are real. D1 is excellent for read-heavy relational data and it is not a drop-in for a large write-heavy Postgres workload. Know your access pattern before you commit.
- The migration story needs guardrails you build yourself. The cascade above cost us an afternoon in MVP and would have cost real downtime later. An autogenerating migration tool plus D1’s batch semantics means the CI check is your job, not the platform’s.
- Bindings are a mental-model shift. Everything is injected, not connected. It is cleaner once it clicks, but it is not how most teams reason about “the database” and “the API” on day one.
For Roadbook - server-rendered, read-heavy, with a few pieces of genuinely slow background work - it is the right trade. We would make the same call again, with the CI check in place from day one.
None of which makes Google Cloud the wrong answer. We have written up that side of the fence several times, and the comparison is sharper read next to this one - particularly the preview-deployment piece, because per-branch preview environments took real Cloud Build wiring there and arrive for free on Workers. That difference, more than any single feature, is what one deployment artifact actually buys.
If you have any questions or comments, please reach out on Bluesky or start a discussion on GitHub.
Further reading
- Cloudflare D1 - SQLite as a Worker binding, including the migrations workflow
- Cloudflare Workflows - durable, individually-retried multi-step jobs
- Workers Builds - per-branch preview deployments and production builds on merge
@astrojs/cloudflare- the adapter that compiles an SSR Astro app to a Worker
The same problems on Google Cloud
- GitHub (Preview) Deployments with Google Cloud Platform - what per-branch preview environments cost to build yourself
- Universal/Isomorphic Web Apps on Google Cloud Run - server-side rendering behind a CDN on Cloud Run
- Deploying a Swift API on Google Cloud Run with Google Cloud Build - the container-and-build-pipeline shape of the same job
