Hossein Shakouri

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 deploy

The 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:check

Its 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.