Cloudflare
Two ways to put this behind Cloudflare, and the one that keeps the app on Node.
Cloudflare has two ways to serve this installation, and they are different enough that the choice matters more than the steps.
| Way | What it means | Fits |
|---|---|---|
| Cloudflare in front of a host | DNS, TLS and caching in front of the app on a server you run | Any installation, today, no code changes |
| Workers, through the OpenNext adapter | The app itself runs on Cloudflare's runtime | Installations that want no server at all |
The first is a proxy and always works. The second is a runtime change, and the honest version of it is below.
Cloudflare in front of your own host
This is the same deployment as your own server, with Cloudflare holding the domain.
The app, running somewhere
Follow Hosting: the folder on a machine with Node 22.6 or newer, pnpm build,
pnpm start behind a proxy, and a TLS certificate. Note the address it answers on.
The domain, moved to Cloudflare
Add the domain to Cloudflare and point its nameservers at Cloudflare's. Keep the certificate on your own server as well: Cloudflare's proxy and your server's certificate are two different legs of the same journey, and the second one is what makes the connection from Cloudflare to you trustworthy.
One DNS record, and one setting
An A record for the address people visit, pointing at your server, proxied. Then the setting that
matters: SSL/TLS mode must be Full (strict). "Flexible" makes Cloudflare talk to your server over
plain HTTP while telling the browser it is secure, which breaks sessions and is not what the padlock
claims.
Let streaming through
Long-lived responses are what a turn is, and a proxy that buffers them turns a streamed answer into an answer that arrives at the end or not at all. Cloudflare does not buffer a response that is being streamed, so nothing has to be turned off, but if an answer arrives all at once, the layer to look at is Cloudflare rather than the app.
The addresses
NEXT_PUBLIC_APP_URL, SITE_URL and the provider callbacks all name the Cloudflare address, because
that is what people type. Your server's own address never appears in a link again.
Workers, through the OpenNext adapter
Here the app itself runs on Cloudflare's runtime instead of a server you keep alive. This installation is already set up for it: the adapter and the command line tool are dependencies, the configuration files are in the repository, and the parts of the app that assumed a disk were changed so that the same code runs in both places. What is left is a deploy.
Why this adapter and not the newest one
Cloudflare now recommends vinext, a Vite plugin that reimplements the
Next.js API, and it is still in beta. This installation keeps Next.js and its
own build, so it uses @opennextjs/cloudflare, which takes the output of the
build it already runs. Moving to vinext is a change of toolchain, not a
flag, and it is a different decision from this one.
What is in the repository
| File | What it decides |
|---|---|
package.json | @opennextjs/cloudflare and wrangler, plus the cf:preview, cf:deploy, cf:upload and cf:typegen scripts |
wrangler.jsonc | The Worker's name, its compatibility date and flags, where the built assets are, the image binding |
open-next.config.ts | Adapter options. Deliberately empty, with the R2 cache written out in a comment for when you want it |
public/_headers | /_next/static/* cached for a year, which is safe because those filenames are hashed |
The compatibility date in wrangler.jsonc is set to the date this was configured, and nodejs_compat is
on because the app is Node code: it imports node:path, node:fs and friends. Without that flag those
imports do not exist at all and the Worker fails on the first request.
Three commands
pnpm cf:preview # build, then serve it in Cloudflare's local runtime on port 8787
pnpm cf:deploy # build, then deploy to your account
pnpm cf:upload # build, then upload a version without promoting itThe cf: prefix matters. pnpm deploy is pnpm's own command, for deploying a workspace package to a
directory, and it answers before this project's script is ever read: it fails with
ERR_PNPM_INVALID_DEPLOY_TARGET and nothing is built. The prefixed name cannot collide with anything
pnpm ships.
`pnpm deploy` is not this
pnpm has a deploy command of its own, for monorepos, and it requires an
argument. It wins the argument every time, which is why these scripts are
spelled cf:. The same collision is why the installer is configure rather
than setup.
pnpm dev is unchanged and still the fast loop: plain Next.js in Node. The Worker build is slower and it is
for checking what you are about to ship, not for writing it.
pnpm cf:preview gets its values the same way the build does: from .env.production, which is the app's own
file and is never committed. There is no separate file for the Worker's local runtime, because one place to
look is one place to be wrong.
Log in
npx wrangler login once per machine, or set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID where
there is no browser to log in with.
The name
name in wrangler.jsonc is what the app is called on Cloudflare, and it is the workers.dev address
until a domain is attached. The WORKER_SELF_REFERENCE service beside it has to say the same thing: it
is how the Worker calls itself to render a route on demand.
The environment
The app's own names go here, the seven from App names. The
deployment's names stay where they belong, on Convex: BETTER_AUTH_SECRET, SITE_URL and the mail
pair are read by the functions, not by this app, so putting them on the Worker does nothing.
There are two environments to fill in, not one, and the build is the one people forget:
| Where | Which names | Why |
|---|---|---|
| Build variables and secrets | NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_APP_URL | Both are inlined into the pages while next build runs |
| Worker secrets and variables | the same two, plus SETTINGS_SECRET and AI_GATEWAY_API_KEY | The pages are baked, but the routes still read these on every request |
On Workers Builds the first row is Settings → Build variables and secrets. Leave it empty and
the build stops with Failed to collect page data for /api/providers, which is a sentence about
the app rather than about the configuration:
# the build's own environment, and then the Worker's
NEXT_PUBLIC_CONVEX_URL=https://your-production.convex.cloud
NEXT_PUBLIC_APP_URL=https://your-app-address
npx wrangler secret put SETTINGS_SECRET
npx wrangler secret put AI_GATEWAY_API_KEY
npx wrangler secret put GITHUB_CONNECT_CLIENT_ID # only if people may push to GitHub
npx wrangler secret put GITHUB_CONNECT_CLIENT_SECRET # the other half of that pairALLOW_SELF_UPDATE stays unset, because a Worker has no folder for the update commands to run in.
Deploying from Cloudflare's own builder
If you connect the repository to Workers Builds instead of running pnpm cf:deploy, three settings
decide whether it works:
| Setting | Value |
|---|---|
| Build command | npx opennextjs-cloudflare build |
| Deploy command | npx opennextjs-cloudflare deploy |
| Root directory | / |
pnpm run build is not the build command, and this is the difference from Vercel that catches
everybody. It runs next build, which writes output for a Node server into .next. A Worker cannot
read that, and wrangler deploy is pointed at .open-next/worker.js, which nothing has written. So
the build can be green and the deploy still fails on a missing entry point.
npx opennextjs-cloudflare build runs the same Next build and then converts the result into the
Worker.
The build variables are a different list from the runtime ones. Vercel gives one set of
environment variables to both the build and the running app, which is why a project moved from there
feels finished and then fails here. Cloudflare has two lists: Build variables and secrets, read
while the build runs, and Runtime variables and secrets, read by the Worker afterwards. A name in
the second list does nothing for the first. NEXT_PUBLIC_CONVEX_URL and NEXT_PUBLIC_APP_URL are
both read during the build, because they are compiled into the pages, so they have to be in both
lists rather than only in the runtime one.
Check it as a stranger would
System → Status & health first, then a real sign-in, then a real turn. A runtime that is not Node
fails in specific places rather than everywhere, and the health checks are the fastest way to see which
of them failed.
What a Worker cannot do, and what changed
A Worker has no file system. Two things in this product used one while answering a request, and both were changed so the same code runs on Node and on a Worker:
| Was | Now |
|---|---|
Skills read from skills/<id>/SKILL.md with readdirSync | The folder is read at build time into lib/ai/skills-data.ts, and the runtime still reads the folder first where there is one, so editing a skill during development is unchanged |
The favicon read public/mini-logo-light.png while it rendered | The bytes are inlined into lib/assets.ts at build time, so no request depends on a folder being beside it |
The self-update in System → Updates runs git and pnpm inside the app's own folder, and a Worker has no
folder. It stays switched off (ALLOW_SELF_UPDATE unset, which is the default) and the panel shows the
commands for you to run instead.
When the build fails
A shared cache, when you want one
Next caches rendered pages and unstable_cache entries between requests. With no bucket bound, each Worker
instance keeps its own copy in memory: correct, but forgetful, so a revalidation reaches whichever instances
happen to be awake. Every page the marketing site and the documentation serve is prerendered at build time,
so most of the site does not need this at all. Turn it on when you start relying on revalidation:
npx wrangler r2 bucket create loomis-next-cacheThen uncomment the r2_buckets block in wrangler.jsonc and set incrementalCache in open-next.config.ts
to r2IncrementalCache. The comment in each file names the other, because half of that pair is worse than
neither: a binding nothing reads, or an adapter pointed at a bucket that is not there.
Be honest with yourself about which road this is
If the goal is a domain behind Cloudflare, the first way is fifteen minutes and cannot go wrong. The Workers road is worth taking when the reason is a runtime you do not want to run. It is configured here and it runs, and it is still a runtime with limits, so read the caching note above before you point a paying customer at it.
What Cloudflare gives you either way
| Thing | What it does for this installation |
|---|---|
| TLS | The certificate people see, without touching your server |
| Caching | Static assets and the documentation's pages, which are files |
| DDoS protection | The layer that absorbs a flood before it reaches your function limit |
| Access | Optional: put the whole installation behind a login for a private beta |
Do not cache the dynamic routes
Caching is a good idea for /_next/static and the documentation and a bad
idea for everything behind a session: the API routes, the dashboard and the
panel. A cached page served to the wrong person is the one failure a cache can
cause that a session cannot undo.

