July 7, 2026 · 6 min read
Push to publish: CD with Workers Builds, CI with GitHub Actions
- cloudflare
- deployment
- meta
This site is fully prerendered. Every page — including the post you're reading — is HTML baked at build time. That's great for speed and it's required by the runtime (more on that below), but it comes with a consequence that's easy to forget: publishing anything means deploying the site. There is no CMS, no "save post" button. A new blog post is a git commit, and a git commit does nothing for readers until a build ships.
This post documents how that pipeline works, the CD setup that now publishes this site, and the CI gate in front of it — partly as a reference for future me, partly because deployment posts helped me more than framework posts when I was setting this up.
What a deploy actually does
The site is Next.js 16 running on Cloudflare Workers via OpenNext. A deploy is two commands, wrapped in one npm script:
npm run deploy
# = opennextjs-cloudflare build && opennextjs-cloudflare deployThe build step runs next build, then adapts the output for the Workers
runtime: server code becomes a single worker.js, prerendered pages and
static files become Workers static assets, and the prerender cache is
copied into the asset bundle so pages are served straight from the edge
without ever re-rendering.
That "without ever re-rendering" is not an optimization — it's a hard
requirement. Workers forbids eval and new Function, and the MDX
runtime needs exactly that to hydrate a compiled post. The first time I
ran this site on the real runtime, every blog post returned a 500 while
the same build worked fine under next start. The fix was to make
prerendering airtight: dynamicParams = false on the blog route, and
OpenNext's static-assets incremental cache so known slugs are served from
the build output and unknown slugs 404 instead of attempting a render
that cannot succeed.
Day to day, wrangler only ever shows up three times:
npx wrangler login # once per machine
npx wrangler secret put RESEND_API_KEY # once — lives in Cloudflare
npm run deploy # every publish (for now)The drift problem
Manual deploys have a well-known failure mode: main and production
quietly stop being the same thing. A fix gets pushed but never deployed,
and days later someone is debugging a bug that was already fixed. On a
team, process covers for this. On a personal site, future me is the
process, and I don't trust him.
So the rule I want: the repo is the site. If it's on main, it's
live. Publishing a post should be git push, nothing else.
CD: Cloudflare Workers Builds
Cloudflare's git integration (Workers Builds) does exactly this, and the setup is five minutes in the dashboard — connect the GitHub repo, then:
- Build command:
npx opennextjs-cloudflare build - Deploy command:
npx opennextjs-cloudflare deploy - Production branch:
main
Every push to main builds and deploys automatically. Pushes to any
other branch get a preview URL — which quietly solves a problem I
didn't know I had: a draft blog post on a PR branch becomes a private,
shareable staging link before it's public.
One real-world gotcha from setting this up: the dashboard auto-detects
npm run build and npx wrangler deploy as defaults, and my first build
failed with "Could not find compiled Open Next config". Plain
next build produces .next/, but nothing compiles the OpenNext worker
bundle that deploy uploads. With OpenNext, both commands must be set
explicitly to the ones above — the defaults get you a green Next.js build
and a dead-on-arrival deploy.
One subtlety worth writing down: environment variables. Locally, the
contact form's Resend key lives in .env.local, which is gitignored — so
CI builds never see it, and that's correct. In production the key comes
from a Cloudflare secret (wrangler secret put), injected at runtime.
The build doesn't need the secret at all; only the running worker does.
Keeping those two channels separate means the key exists in exactly two
places: my machine and Cloudflare. Never in git, never in CI logs.
The gap: CD without CI
Here's the trade-off I'm consciously making. Workers Builds will happily
build and deploy a commit that fails lint, fails typecheck, or breaks a
test I haven't written yet. It's continuous delivery with no continuous
integration in front of it. For a solo project where I run
npm run lint && npm run typecheck && npm run build out of habit before
committing, that's survivable — but "out of habit" is exactly the kind of
process that erodes.
The missing gate is a GitHub Actions workflow, kept deliberately boring — checks only, no deploying:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
# Build first: it generates .content-collections/, which the
# tsconfig alias needs for typecheck to resolve.
- run: npm run build
- run: npm run lint
- run: npm run typecheck
- run: npm run format:checkIts very first run caught a real bug: typecheck failed in CI while passing locally, because the generated content-collections types only exist after a build has run. My machine had them lying around from dev sessions; a fresh checkout did not — hence the build-first ordering in the workflow. One run old, and the gate had already earned its keep.
The division of labor matters to me: GitHub Actions is the quality
gate, Workers Builds is the delivery mechanism. Actions never touches
Cloudflare credentials; Cloudflare never runs my checks. Each side does
one thing, and neither holds secrets it doesn't need. With branch
protection requiring the checks to pass before merge, main stays
deployable by construction — and Workers Builds can keep blindly trusting
it, because nothing untested can reach it.
Where this lands
Publishing a post now: write the MDX, open a PR, check the preview URL, merge. Live in about two minutes, with lint and types verified on the way. No wrangler, no checklist, no relying on my own discipline at 1am.
This very post is the proof: it went live by flipping draft: false in
a commit and pushing it.
The repo is the site. That's the whole pipeline.