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/cloudflareand deploys viawrangler.jsonc. - The production branch is bound to your custom domain.
- Every other branch gets an ephemeral
*.workers.devpreview 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 uploadcf: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.productionis loaded by every production build — custom-domain deploy and everyworkers.devpreview 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 whatnext buildalready 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">andDisallow: /inrobots.txt) and may show draftwriting/review/readypages so reviewers can see work before it’s published. - Prod should be indexable and
published-only — the samerenderableStatuses("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.comThen 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 typesRelated
- Publish status & environments —
renderableStatuses(env), and why the status→environment policy lives in config, not the SSG - Build a website —
siteBaseUrl(env), canonical URLs, and sitemaps - Content-types package — the full
site.tsAPI