# One Worker, One Deploy: Running Roadbook on Cloudflare

A single Cloudflare Worker server-renders our Astro app, queries SQLite at the edge, runs durable background jobs, and fires cron. Here is what that bought us, what we gave up, and the migration that cascade-wiped nine tables before we learned to guard it.

- **Author:** Patrick Heneise
- **Published:** 2026-07-13
- **Updated:** 2026-08-13
- **Tags:** cloudflare, astro, d1, workers, serverless

---

## The backend we didn't want to run

[Roadbook](https://roadbook.us/?utm_source=zentered.co&utm_medium=blog&utm_campaign=roadbook-on-cloudflare)
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](/articles/universal-app-on-google-cloud-run/)
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](https://astro.build/) in SSR mode, compiled to a Cloudflare
Worker by the
[`@astrojs/cloudflare`](https://docs.astro.build/en/guides/integrations-guide/cloudflare/)
adapter. The Worker reads and writes [D1](https://developers.cloudflare.com/d1/)
(Cloudflare's SQLite), keeps rate-limit counters in
[KV](https://developers.cloudflare.com/kv/), and hands slow work off to
[Cloudflare Workflows](https://developers.cloudflare.com/workflows/).
Interactive UI is [Solid.js](https://www.solidjs.com/) 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:

```ts
// 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:

1. **`fetch`** is the SSR web app.
2. **`scheduled`** is the
   [cron](https://developers.cloudflare.com/workers/configuration/cron-triggers/)
   handler. We register five schedules in `wrangler.jsonc` and 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.
3. **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](https://developers.cloudflare.com/workers/ci-cd/builds/),
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:

```ts
// 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:

```ts
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-plugin` 1.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 import `cloudflare:workers` directly, and we are retiring them
  getter by getter. A lint rule bans the case we care about most, reaching for
  the raw `DB` binding instead of `env.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](https://www.prisma.io/), 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 COLUMN` wherever possible.
- A CI check scans every migration for an unguarded `DROP TABLE` and 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](https://atproto.com/) (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](https://github.com/zentered/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](https://github.com/zentered/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](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/),
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](https://developers.cloudflare.com/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](https://developers.cloudflare.com/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 to `main` and 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 `scheduled` handler 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](https://bsky.app/profile/zentered.co) or
[start a discussion on GitHub](https://github.com/zentered/zentered/discussions/new?category=ideas-feedback).

**Further reading**

- [Cloudflare D1](https://developers.cloudflare.com/d1/) - SQLite as a Worker
  binding, including the migrations workflow
- [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) -
  durable, individually-retried multi-step jobs
- [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) -
  per-branch preview deployments and production builds on merge
- [`@astrojs/cloudflare`](https://docs.astro.build/en/guides/integrations-guide/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](/articles/preview-builds-with-cloud-run/) -
  what per-branch preview environments cost to build yourself
- [Universal/Isomorphic Web Apps on Google Cloud Run](/articles/universal-app-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](/articles/deploying-swift-api-on-google-cloud/) -
  the container-and-build-pipeline shape of the same job
