~/nicholaslennox/blog

Part 2 of 3 in Foundations

Building, deploying and CI/CD on Cloudflare Workers

Contents
  1. 1. Build and deploy
  2. 2. Where the site runs
  3. 3. CI/CD with Workers Builds
  4. 4. Getting the first deploy working
  5. 5. Linking the domain
  6. 6. Deploying only from main
  7. 7. Moving the posts into Markdown
  8. 8. Working with an AI assistant
  9. 9. Summary
  10. 10. Sources

This post follows this site from your machine to a live domain. We will see what building and deploying a Next.js app involves, why it needs an adapter to run on Cloudflare Workers, and how a CI/CD pipeline puts every merged change live without anyone uploading anything.

The post also works through each problem that came up during development, by looking at the error message, and finding the fix.

New terms get a short plain-English explanation in brackets.

Goals

  1. Tell building an app apart from deploying it.
  2. Describe how a Cloudflare Worker handles a request, and how that differs from a server.
  3. Explain why a Next.js app needs the OpenNext adapter to run on Workers.
  4. Describe how Workers Builds builds and deploys a GitHub repository whenever main changes.
  5. Work out why a build or deploy failed from its log.
  6. Use an npm pre script to run a step before every build.

1. Build and deploy

1.1 From source code to something that runs

The code you write in a Next.js project isn't what runs on the live site. It's split across many files, written in TypeScript and JSX, which browsers can't run directly, and full of comments and long variable names that make it readable for people.

Before it can go live, a tool processes all of it. For a Next.js app the command is next build, and it:

  • compiles the TypeScript and JSX into plain JavaScript,
  • bundles the code into a small number of files, and minifies it [removes the spaces, comments and long names that only people need],
  • and pre-renders every page it can [builds the page's HTML once, ahead of time, so it doesn't have to be built for each visitor].

The result goes into a folder called .next.

That's called the build [turning source code into files a server can run].

The build output on its own does nothing. It has to be put somewhere the public can reach, on a host [a service that runs your site on its computers and answers visitors' requests].

That's called the deploy [uploading the build output to a host and making it live].

They're separate steps, run by separate commands, and each can fail on its own. A build can succeed and the deploy still fail, which happened more than once while setting up this site.

1.2 What isn't saved in git

Recall from Building in public that the site's code is kept in git [a tool that records every change to a project's files, so the full history is kept and any change can be undone], in a repository on GitHub. A few git terms come up in this post:

  • A commit is one saved change: a snapshot of the changed files, with a message saying what changed.
  • A push sends commits from your computer up to GitHub.
  • A clone is a full copy of a repository, downloaded from GitHub, history included.
  • main is the main branch [the main line of commits in a repository]. On this site, it's the version that goes live.

Git only keeps what it's told to track, and some folders are deliberately left out.

Build output belongs to the machine that made it. It's whatever came out of the last build that ran there. Someone who clones the repository doesn't need a copy of it: they run the build themselves and get their own. Cloudflare does the same when it deploys the site.

The same goes for node_modules [the folder npm install puts a project's dependencies in]. It has nothing to do with deploying. It holds the development tools: Next.js, TypeScript, Wrangler, and everything they depend on. But it's left out of git for exactly the same reason. package.json and package-lock.json [the file that records the exact version of every package installed] list what's needed, and anyone who clones the repository runs npm install to get their own copy. Cloudflare does that too, as one of its deploy steps.

All three folders are also large. On my machine, after installing and building this site, they held:

Folder Made by Files Size
node_modules npm install about 30,000 629 MB
.next next build about 2,000 69 MB
.open-next OpenNext about 1,550 35 MB

node_modules comes from just 11 packages listed in apps/client/package.json. Each of those depends on others, which depend on others again, and npm installs all of them.

The code and posts all of this was made from, in the app, lib, content and scripts folders, are 22 files. Committing the generated folders would bury those 22 files under more than 30,000 others. And installs and builds rewrite many of the generated files, so the history would fill up with changes nobody actually made.

So the project's .gitignore [a list of files and folders git doesn't track] leaves all three out:

node_modules/
.next/
.open-next/

Commit what people write, and leave out what tools generate from it.

2. Where the site runs

2.1 Servers and Workers

A Next.js app is usually run on a server [a computer that's always on, waiting for requests], running Node.js [the program that runs JavaScript outside a browser]. Every request goes to that one machine, and it keeps running whether anyone visits or not.

This site runs on Cloudflare Workers instead. A Worker is your code, run by Cloudflare in the data centre closest to whoever sent the request. Cloudflare doesn't start a server or a virtual machine for it. Its page on how Workers works explains: "Instead of creating a virtual machine for each function, an isolate is created within an existing environment."

An isolate [a small, separate space inside one running program, where one piece of code runs without seeing any other] is much lighter than a server. The same page says that "any given isolate can start around a hundred times faster than a Node process on a container or virtual machine", and that "isolates are not necessarily long-lived". So a Worker starts when a request arrives, answers it, and may be gone a moment later.

A Worker is written as a function that takes a request and returns a response. This is the start of the Worker this site runs, from .open-next/worker.js:

export default {
  async fetch(request, env, ctx) {
    // ...works out the response and returns it
  },
};
  • request is what the browser asked for: the URL, the headers, and so on.
  • env holds the bindings [resources Cloudflare connects to the Worker through its configuration, such as a folder of static files or a storage bucket].
  • The function returns a Response, which is the page or file sent back.

A Worker is a function that takes a request and returns a response.

2.2 OpenNext

Next.js expects to run on a server with Node.js. A Worker isn't a server, and an isolate isn't a full Node.js process, so a Next.js app can't be uploaded to Workers as it is.

OpenNext is the adapter [a tool that converts something built for one platform so it runs on another] that bridges the gap. Its Cloudflare documentation says: "The OpenNext Cloudflare adapter works by taking the Next.js build output and transforming it" so that it can run on Workers.

So the build for this site has two stages, and one command runs both:

npx opennextjs-cloudflare build
  1. next build (the project's build script)  → .next/       for a Node server
  2. OpenNext converts that output            → .open-next/  for a Worker

2.3 What the build produces

Two things in .open-next matter:

  • worker.js is the Worker itself, the fetch function shown above. Every request that needs code to run goes through it.
  • assets/ holds the static files: JavaScript, CSS, fonts and images. Cloudflare serves these straight from its network without starting the Worker at all.

The Worker's configuration file, wrangler.jsonc, points at both. This is part of it:

{
  "name": "portfolio",
  "main": ".open-next/worker.js",
  "compatibility_date": "2025-11-01",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  }
}
  • name is the Worker's name in Cloudflare.
  • main is the entry point, the file every request starts in.
  • assets is the folder of static files, available to the Worker through a binding called ASSETS.
  • nodejs_compat switches on the Node.js features Next.js depends on.
  • compatibility_date fixes the runtime's behaviour to how it was on that date, so later changes on Cloudflare's side don't change how the site behaves.

wrangler is Cloudflare's command-line tool, and wrangler deploy uploads what this file describes.

3. CI/CD with Workers Builds

Building and deploying by hand works, but it means remembering to do it, and doing it from a machine with the right tools installed. The alternative is to let a service do both every time the code changes.

That's called CI/CD [continuous integration and continuous deployment: every change to the main branch is built and deployed automatically].

Cloudflare's version is Workers Builds. Its documentation says it lets you "connect a new or existing Worker to a repository hosted by Artifacts, GitHub, or GitLab", "enabling automated builds and deployments for your Worker on push".

The repository is connected in the Cloudflare dashboard, under the Worker's Settings, then Build. Three settings decide what happens:

Setting Value for this site What it does
Root directory /apps/client The repository holds more than one app, so this says which folder to work in. Cloudflare's build configuration page says it "defines where the build command will be run".
Build command npx opennextjs-cloudflare build Runs both stages of the build and produces .open-next.
Deploy command npx wrangler deploy Uploads .open-next and makes it live. The same page says it "will default to npx wrangler deploy".

With those in place, every push to main goes through the same steps on one of Cloudflare's machines:

1. Clone the repository        a fresh copy of the source code
2. Install dependencies        npm install
3. Build                       npx opennextjs-cloudflare build  → .open-next/
4. Deploy                      npx wrangler deploy               → live

Cloudflare runs its own build from the source code, so the .open-next folder on my laptop is never used. That's why it doesn't need to be in git.

4. Getting the first deploy working

The first deploy took several attempts. Each problem below starts with what the error looked like, then explains the cause and the fix.

4.1 A misspelled command

I ran the build command in my terminal to try it locally, typing it in by hand, and got this:

npm error 404  The requested resource 'opennextjs-cloudfare@*' could not be found
  or you do not have permission to access it.

A 404 [the standard "not found" response] made it look as if the tool didn't exist. The real problem was the spelling: cloudfare instead of cloudflare.

The error was a 404, rather than "command not found", because of how npx works. It first looks for a command with that name among the packages already installed in the project. If it doesn't find one, it asks the npm registry [the online library of JavaScript packages] for a package with that exact name, to download and run. No package is called opennextjs-cloudfare, so the registry answered with a 404.

When an npx command returns a 404, check the spelling before anything else.

4.2 A build command that skipped OpenNext

The first deploy on Cloudflare failed, with a message asking whether the build had been run. It had. The build step had finished without an error.

The cause was the build command. When I connected the repository, I left Cloudflare's suggested build command, npm run build, in place. That runs the project's build script, which is next build. It makes .next, and stops there. It never runs the OpenNext stage, so .open-next was never made.

The deploy step then ran wrangler deploy, which reads wrangler.jsonc, looks for main at .open-next/worker.js, and found nothing there to upload.

The fix was changing the build command to npx opennextjs-cloudflare build, which runs both stages.

A build that passes has finished. That doesn't mean it made what the deploy needs.

When a deploy says the build is missing, check that the build command and the deploy command come from the same tool, and agree on where the output goes.

4.3 A Worker name that didn't match

With the right build command, the build worked, and the deploy failed again. The log showed a warning, and then an error:

▲ [WARNING] Failed to match Worker name. Your config file is using the Worker name
  "portfolio-client", but the CI system expected "portfolio". Overriding using the
  CI provided Worker name.

✘ [ERROR] A request to the Cloudflare API
  (/accounts/.../workers/scripts/portfolio/versions) failed.
  Service binding 'WORKER_SELF_REFERENCE' references Worker 'portfolio-client' which
  was not found. [code: 10143]

When I created the Worker in the dashboard, Cloudflare filled in the name portfolio, from the repository's name, and I accepted it. wrangler.jsonc called it portfolio-client. The warning explains the first half: Cloudflare noticed the names didn't match, and used the dashboard's name.

The error is the second half. OpenNext's configuration includes a service binding [a binding that lets one Worker call another Worker directly] called WORKER_SELF_REFERENCE, which lets the Worker call itself. OpenNext uses it for caching. That binding names its Worker separately:

"services": [
  {
    "binding": "WORKER_SELF_REFERENCE",
    "service": "portfolio-client"
  }
]

Cloudflare had overridden the top-level name, but not this one, so the binding pointed at a Worker called portfolio-client, which didn't exist. The fix was renaming both entries in wrangler.jsonc to portfolio.

Two things are worth taking from this. A setting that exists in both the dashboard and a config file is one setting stored in two places, and the two have to agree. And the warning above an error often explains it: here, the warning said exactly which two names didn't match.

5. Linking the domain

Once the deploy worked, the site was live, but at a workers.dev address, a free subdomain every Worker gets, and not on my domain.

My domain was already managed by Cloudflare, which means Cloudflare runs its DNS [the system that turns a domain name into the address of the computer that serves it]. But that doesn't tell Cloudflare which Worker to send the domain's visitors to. That link is a Custom Domain. Cloudflare's Custom Domains page says they "allow you to connect your Worker to a domain or subdomain", and that "Cloudflare will create DNS records and issue necessary certificates on your behalf".

The certificate is what HTTPS needs [a file that proves the site is who it says it is, and lets the browser encrypt the connection], so Cloudflare handles both the DNS and HTTPS.

A Custom Domain can be added in the dashboard, or in wrangler.jsonc. This site uses the config file:

"routes": [
  { "pattern": "nicholaslennox.fyi", "custom_domain": true },
  { "pattern": "www.nicholaslennox.fyi", "custom_domain": true }
]

There are two entries because nicholaslennox.fyi, the apex domain [the bare domain, with nothing in front of it], and www.nicholaslennox.fyi are different names as far as DNS is concerned. Without the second entry, anyone typing www would get nothing.

Keeping it in the config file rather than the dashboard means it's saved in git, reviewed in pull requests like any other change, and applied on every deploy. If the Worker ever had to be set up again, the repository alone would describe it.

6. Deploying only from main

6.1 Branches and pull requests

With every push to main going live, pushing straight to main means every change goes live untested. So changes are made on a branch [a separate line of changes that doesn't affect main until it's merged] and merged through a pull request [a request to merge a branch into main, with a page on GitHub to review every changed line first].

1. Make the change on a branch       git checkout -b feat/blog-skeleton
2. Push the branch, open a PR        reviewed on GitHub
3. Merge the PR into main            the merge is a new commit on main
4. Workers Builds sees the commit    builds and deploys it

The blog you're reading was the first change to go through this loop. Merging a pull request is now the same as deploying it.

6.2 Turning off preview builds

As soon as the branch for the blog was pushed, Cloudflare started building it, and the build failed. Every later push to the branch did the same, and each commit in the pull request on GitHub showed a red cross from a check called Workers Builds: portfolio.

These were preview builds. Cloudflare's Workers Builds page says that "preview builds run for branches that are not your production branch". A preview is deployed differently from production, without the Custom Domain, and this site's configuration isn't set up for that. For a one-person site, checking changes locally before merging is enough, so the goal was for only main to build.

The build settings have two tabs, and each has a toggle that looks like the answer:

Tab Toggle What it does
Production Disable builds "Pushes to the production branch don't start builds. Manual builds and deploy hooks still work." This would stop main from deploying.
Previews Base Builds for Preview branches Turns builds for every other branch on or off.

Claude's first instructions named the wrong toggle. They described a "Disable builds" toggle on the Previews Base tab, which doesn't exist. The one with that name is on the Production tab, and turning it on would have stopped the live site from updating. The right one was Builds for Preview branches, on the Previews Base tab, switched off.

The test was one more push to the branch. Checking the commit on GitHub afterwards showed no checks at all: Cloudflare hadn't started a build.

Read what a setting does before you change it, and don't assume instructions match the screen.

Dashboards change, and instructions written about them, including ones from an AI assistant, don't always keep up.

7. Moving the posts into Markdown

7.1 Reading files on a Worker

The first posts on this blog were written as text inside a TypeScript file, which was awkward to write in. So they were moved into Markdown files [plain text, with symbols such as ## for headings and - for list items], one file per post, with a small header at the top for the title and date. The first version of the code read those files whenever a page was requested:

const POSTS_DIR = path.join(process.cwd(), "content", "blog");

const files = fs.readdirSync(POSTS_DIR); // every file in content/blog

next build passed. Then the site was run with wrangler dev [a command that runs the Worker on your own machine, in the same runtime Cloudflare uses], and every blog page returned a 500 error [the standard "something went wrong on the server" response]:

Error: no such file or directory, readdir '/content/blog'

On a laptop, content/blog is a folder on the disk. A Worker doesn't have the project's files. Cloudflare's page on the node:fs module describes what it has instead: "a memory-based file system that allows you to read modules included in your Worker bundle as read-only files". Only the code bundled into the Worker exists there. The Markdown files weren't part of the bundle, so as far as the Worker was concerned, the folder didn't exist.

The blog pages had been pre-rendered during the build, but in this setup OpenNext rendered them again when they were requested, so the code that read the folder ran on the Worker.

On a Worker, anything the code reads has to be part of the bundle.

7.2 Reading them during the build instead

The fix was to read the Markdown files during the build, where the project's files do exist, and pass the result into the bundle.

A small script, scripts/build-content.mjs, runs before the build starts. It reads every Markdown file, converts each one to HTML, checks the headers, and writes everything into one file, lib/generated/posts.json. The site then imports that file:

import generated from "@/lib/generated/posts.json";

Importing a JSON file makes it part of the code, so the bundler [the tool that packs all the code into a few files] copies the posts into the Worker. When someone visits, the posts are already in memory, and nothing has to be read from a disk.

This was only found because the site was tested with wrangler dev, in the same runtime it's deployed to. next build on a laptop showed that the code was valid. It couldn't show that the code would fail on a Worker. The problem was found and fixed before it went live.

7.3 npm pre scripts

The content script has to run before every build: before npm run dev locally, and before the build Cloudflare runs. That needed no change to Cloudflare's settings, because of a feature of npm called pre scripts. I hadn't come across them before this project.

npm's scripts documentation says: "Pre and post commands with matching names will be run for those as well (e.g. premyscript, myscript, postmyscript)." So when you run npm run build, npm first looks for a script called prebuild and runs it, then runs build, then postbuild if there is one. The names are the only link between them.

These are the scripts in this site's package.json:

"scripts": {
  "content": "node scripts/build-content.mjs",
  "predev": "npm run content",
  "dev": "next dev",
  "prebuild": "npm run content",
  "build": "next build"
}

npx opennextjs-cloudflare build runs the project's build script as its first stage, so prebuild runs on Cloudflare's machine as well, without any change to the build command.

Two things to be aware of:

  • It's npm-only. pnpm and newer versions of Yarn don't run pre and post scripts by default.
  • It's hidden. Someone reading "dev": "next dev" has no way to tell that anything runs first, unless they know the naming rule. The explicit alternative is "build": "npm run content && next build", where && means "then, if that worked".

8. Working with an AI assistant

Claude wrote almost all of the code and configuration for this, and most of it worked the first time. The problems in this post came from a few places:

  • The Cloudflare dashboard. The default build command, the Worker's name and the preview builds setting were all in the dashboard, where Claude couldn't see them.
  • Instructions about the dashboard. Claude's description of the preview builds setting was wrong, because the dashboard didn't look the way it expected.
  • My typo, in the command I typed by hand.
  • Claude's own code, which read files at runtime. Claude found that one itself, by running the Worker with wrangler dev before anything was merged.

An AI assistant can read, check and fix what's in the repository. It can't see a dashboard. That's a practical reason to keep as much of the setup as possible in config files, such as the Custom Domain in wrangler.jsonc. When something does go wrong outside the code, the assistant only knows what you give it: the full log, a screenshot, the exact error.

9. Summary

Building and deploying are separate steps. The build turns source code into files that can run, and the deploy puts them live. Either can fail, and a build that passes hasn't necessarily made what the deploy needs.

A Worker is a function, not a server. Cloudflare runs it in an isolate when a request arrives, close to whoever sent it. Next.js expects a server, so OpenNext converts its build output into a Worker and a folder of static files.

CI/CD means the repository is the source of truth. Workers Builds clones the repository, builds it and deploys it on every push to main. Nothing on a laptop is uploaded, which is why build output stays out of git.

Logs explain more than the last line. The 404 was a spelling mistake, and the failed deploy was explained by the warning above the error. Read the whole log, from the top.

Test in the runtime you deploy to. Reading files worked on a laptop and failed on a Worker. Running the Worker locally with wrangler dev found the problem before it went live.

Config in files is visible, dashboard settings aren't. Settings in wrangler.jsonc are tracked in git, reviewed and applied on every deploy, and an AI assistant can read them. Settings in a dashboard are none of those things.

10. Sources

  1. Cloudflare Docs, How Workers works
  2. Cloudflare Docs, Builds
  3. Cloudflare Docs, Build configuration
  4. Cloudflare Docs, Custom Domains
  5. Cloudflare Docs, fs, Node.js compatibility
  6. OpenNext, Cloudflare
  7. npm Docs, scripts