Skip to Content
DocumentationBuild a websiteDeploying on Cloudflare Workers

Deploying on Cloudflare Workers

A recipe for deploying a gtmesh SSG to Cloudflare Workers with @opennextjs/cloudflare: how to wire up Workers Builds from GitHub, pick the render environment so production and preview branches behave correctly, and cut over to your real domain. It’s the Cloudflare-specific layer on top of the Build a website basics.

None of this is engine behaviour — renderableStatuses(env) and siteBaseUrl(env) don’t know or care which host runs the build. Everything here is about how the SSG decides which env string to pass them on Cloudflare specifically.

The deploy stack

  • The SSG builds with @opennextjs/cloudflare and deploys via wrangler.jsonc.
  • The production branch is bound to your custom domain.
  • Every other branch gets an ephemeral *.workers.dev preview URL, built by the same Workers Builds pipeline.

That last point is the trap: “production build” and “the production deploy” are not the same thing. A preview branch build is still a production build in Cloudflare’s terms — it just doesn’t serve your real domain.

Wiring up Workers Builds from GitHub

In the Cloudflare dashboard (Workers & Pages → your Worker → Settings → Builds), the build/deploy/version commands run relative to whatever root directory you configure — and that has to stay the repo root, not the SSG app’s own subdirectory, so pnpm --filter can still see the rest of the workspace (lockfile, packages/content-types/, etc.):

Root directory: / Build command: pnpm --filter @your-project/website run cf:build Deploy command: pnpm --filter @your-project/website exec opennextjs-cloudflare deploy Version command: pnpm --filter @your-project/website exec opennextjs-cloudflare upload

cf:build is a workspace script in the SSG app’s own package.json that runs the normal prebuild (content-types sync, then the render-env step from the branch-gated recipe below) before handing off to opennextjs-cloudflare build:

// apps/website/package.json (wherever your SSG app lives in the workspace) { "scripts": { "cf:build": "pnpm prep && node scripts/render-env.mjs && opennextjs-cloudflare build" } }

Build-time vs runtime

The generated site.ts keys three things off the environment string you pass to renderableStatuses(env) / siteBaseUrl(env): indexability (robots.txt, <meta name="robots">), the canonical base URL, and which statuses render (generateStaticParams’s status filter). In a Next.js SSG, all four are baked in at next build — they’re SSG/prerender output, decided once and frozen.

That rules out the fixes that look obvious at first:

  • A committed .env.production is loaded by every production build — custom-domain deploy and every workers.dev preview alike. It can’t distinguish them, so it makes previews indexable and canonical to prod.
  • Wrangler [vars] / [env.preview.vars] (Cloudflare docs ) are runtime worker bindings. They’re only visible to code running after the static output already exists — too late to change what next build already baked into the HTML.
  • A Workers Builds dashboard build variable looks like the fix, but Workers Builds build variables are global across branches (unlike Cloudflare Pages, which scopes them per production/preview). Setting it re-introduces the same leak one level up.

So the render-env has to be decided at build time, from something that actually varies by branch.

The branch-gated render-env recipe

Cloudflare documents WORKERS_CI_BRANCH as a system env var for exactly this — “customize your build based on the branch.” Read it in a prebuild step, and fail safe: no signal means staging/noindex, and only the production branch opts into prod.

// prebuild.mjs — run before `next build` import { writeFileSync, rmSync } from "node:fs"; const branch = process.env.WORKERS_CI_BRANCH ?? gitBranch(); const isProd = branch === "main"; // your production branch name if (isProd) { // git-ignored — only ever written on the production branch writeFileSync( ".env.production.local", "NEXT_PUBLIC_RENDER_ENV=prod\n", ); } else { rmSync(".env.production.local", { force: true }); // fall back to the staging default }
// wherever the SSG picks an env for renderableStatuses()/siteBaseUrl() const env = process.env.NEXT_PUBLIC_RENDER_ENV === "prod" ? "prod" : "dev";

.env.production.local is loaded by Next.js and gitignored by default , so it never leaks into version control and never becomes the same every-branch trap as a committed .env.production.

Fail-safe means the absence of a signal must resolve to staging/noindex, not prod. If WORKERS_CI_BRANCH is ever missing or renamed, you want a noindex preview by default, not an accidentally-public, accidentally-canonical build.

Preview hygiene

Once the render-env is branch-gated, wire it the same way the rest of the mesh already expects a env string (Publish status & environments):

const env = pickRenderEnv(); // "prod" | "dev", from the recipe above const statuses = renderableStatuses(env); // dev renders draft (writing/review) pages too const origin = siteBaseUrl(env); // dev's configured staging base_url const view = viewForEnv(env); // "live" for prod, "preview" for dev — pass to loadPages({ view })
  • Previews should render noindex (<meta name="robots" content="noindex"> and Disallow: / in robots.txt) and may show draft writing/review/ready pages so reviewers can see work before it’s published.
  • Prod should be indexable and published-only — the same renderableStatuses("prod") set as any other environment.

Domain cutover

Before a site launches on its real domain, point environments.prod.base_url at a staging/test domain so canonical tags and the sitemap are internally consistent (self-canonical, not pointing at a domain that doesn’t serve yet):

gtmesh config set environments.prod.base_url https://staging.example.com

Then swap it to the apex domain once you cut over, and regenerate the package so site.ts picks it up:

gtmesh config set environments.prod.base_url https://example.com gtmesh types
Last updated on