Loomis
Keeping it running

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.

WayWhat it meansFits
Cloudflare in front of a hostDNS, TLS and caching in front of the app on a server you runAny installation, today, no code changes
Workers, through the OpenNext adapterThe app itself runs on Cloudflare's runtimeInstallations 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

FileWhat it decides
package.json@opennextjs/cloudflare and wrangler, plus the cf:preview, cf:deploy, cf:upload and cf:typegen scripts
wrangler.jsoncThe Worker's name, its compatibility date and flags, where the built assets are, the image binding
open-next.config.tsAdapter 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 it

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

WhereWhich namesWhy
Build variables and secretsNEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_APP_URLBoth are inlined into the pages while next build runs
Worker secrets and variablesthe same two, plus SETTINGS_SECRET and AI_GATEWAY_API_KEYThe 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 pair

ALLOW_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:

SettingValue
Build commandnpx opennextjs-cloudflare build
Deploy commandnpx 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:

WasNow
Skills read from skills/<id>/SKILL.md with readdirSyncThe 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 renderedThe 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-cache

Then 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

ThingWhat it does for this installation
TLSThe certificate people see, without touching your server
CachingStatic assets and the documentation's pages, which are files
DDoS protectionThe layer that absorbs a flood before it reaches your function limit
AccessOptional: 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.