·4 min read·by Sergey Z.

Moving this Next.js site from a VPS to Cloudflare Workers

We migrated wedece.com off a Hetzner + Coolify VPS onto Cloudflare Workers with OpenNext. Two constraints made it interesting: a 3 MiB bundle and no filesystem at request time.

Cloudflare
Next.js
OpenNext
infrastructure
DevOps

This site used to run on a Hetzner CX32 with Coolify and Docker. It worked, but it was infrastructure we had to babysit: journald rotation, docker system prune crons, a health check, a VPS bill. For a marketing site that is almost entirely static, that is a lot of moving parts. We moved it to Cloudflare Workers via @opennextjs/cloudflare. Here is what actually mattered.

Why the adapter, not a rewrite

OpenNext takes the standard next build output and repackages it into a single Worker bundle plus a static assets/ directory. We did not rewrite a line of application code. Through the cutover we kept the old Hetzner Dockerfile alive as a genuine fallback — next.config.ts carried output: 'standalone' alongside the Worker output, so both build targets coexisted and a rollback was one DNS change away. That is exactly what you want mid-migration: a fallback that is real, not theoretical. Once Workers had served production cleanly for a few weeks we deleted the Dockerfile and dropped the standalone flag — a fallback you have stopped exercising is just dead weight.

The Worker config is small. nodejs_compat is the flag that makes the Node-shaped Next.js runtime work on Workers:

{
  "name": "wedece-main",
  "main": ".open-next/worker.js",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": { "directory": ".open-next/assets", "binding": "ASSETS" }
}

Every push to main triggers a Cloudflare Workers Build that runs opennextjs-cloudflare build and deploys. No GitHub Actions runner, no registry push.

Constraint 1: the 3 MiB bundle limit

Workers Free caps the compressed handler at 3 MiB. Our bundle lands at ~1.86 MiB gzip — comfortable, but only because we went looking for the heavy dependency first. The culprit on a Next.js site is almost always @vercel/og: the file-based metadata convention (icon.tsx, apple-icon.tsx, opengraph-image.tsx) pulls in a whole SVG-to-PNG renderer.

We deleted those route files and replaced them with pre-rendered static PNGs in public/ (regenerated by a build script). Same favicons and OG image, none of the bundle weight. If you ever need to check where you stand:

npm run cf:build
gzip -c .open-next/server-functions/default/handler.mjs | wc -c

Constraint 2: no filesystem at request time

Our blog posts are MDX files under content/blog/<locale>/. On the VPS, the loader did a plain readdirSync(process.cwd() + '/content/blog/...') at request time. On a Worker isolate that returns nothing — process.cwd() does not point at the bundled content/ directory.

The fix is to move the read to build time. A prebuild script walks every MDX file, parses the frontmatter, and writes src/generated/blog-index.json. The runtime loader just imports that JSON and serves everything from memory. The nice side effect: the same code path ran identically on the VPS, so the fallback stayed byte-for-byte consistent while we still kept one. Any new content source has to follow the same rule — parse at build, serve from memory.

The env var gotcha that will bite you

Cloudflare separates build-time and runtime variables, and they live in two different panels. NEXT_PUBLIC_* values are inlined into the client bundle during next build, so they belong in Settings → Build → Build variables. Server-only secrets (RECAPTCHA_SECRET_KEY, TELEGRAM_BOT_TOKEN) reach process.env inside the isolate at request time, so they go in Settings → Variables and Secrets.

Put a NEXT_PUBLIC_* var in the runtime panel and it silently reads back undefined in the browser — the build already finished before the isolate ever saw it. And changing a build variable does not trigger a rebuild; you push an empty commit or hit "Re-deploy".

The outcome

Warm TTFB is 130–180ms, served from the nearest of 300+ Cloudflare POPs. There is no server to patch, no Docker daemon, no bill on the free tier. The rate limiter — which used to be an in-process Map<ipHash, …> that could never survive isolate boundaries — moved to a Cloudflare WAF rule at the edge, which is where it should have been anyway. We kept the Hetzner pipeline one DNS revert away through the cutover window, then decommissioned it once Workers had earned the trust; a rollback today is a wrangler rollback to the previous Worker version. That is the whole point of migrating carefully — keep the safety net until the new platform has proven itself, then take it down.

Liked this? We write one every few weeks.

Join the newsletter for studio playbooks, AI tools we rely on and fresh case studies.