Build
How the runner builds a push — Vite, Rsbuild, static sites, frameworks, custom build commands — and which config it deploys.
Every push to main builds on the Noite host, in the build sandbox. You do not need CI for Vite or Rsbuild apps; deploy from CI when a build needs more than the host allows.
Steps
| Step | Runs when | Command |
|---|---|---|
| install | the tree has a package.json |
<manager> install (devDependencies included) |
| build | the Wrangler config has build.command |
sh -c "<command>" in build.cwd |
| build | otherwise, package.json has a scripts.build |
<manager> run build |
| convert | cloudflare.config.ts and no Wrangler config |
cloudflare.config.ts → wrangler.json |
| generate | no Wrangler config anywhere | wrangler.json from the build output or package.json main (below) |
| release | the Wrangler config has release |
sh -c "<release>" |
| deploy | always | celld deploy |
Tools whose launcher asks for Node (vite, rsbuild) get the image’s Node 24. The build sees your app’s environment variables plus CI=1, NODE_ENV=production and NO_COLOR=1. Each install and build step is bounded by RUNNER_BUILD_TIMEOUT_S (300 s by default) and the worktree by RUNNER_BUILD_MAX_MB.
Package managers
npm, pnpm, Yarn (1 and 2+) and Bun all work. The runner picks one per push:
- A pin in
package.json—"packageManager": "pnpm@10.18.0"ordevEngines.packageManagerwith aversion. That exact release runs, Bun included. - No pin, a lockfile — the manager that wrote it:
bun.lock/bun.lockb→ Bun,pnpm-lock.yaml→ pnpm (the major itslockfileVersionneeds),yarn.lock→ Yarn 1 for a v1 lockfile, else the latest Yarn,package-lock.json/npm-shrinkwrap.json→ npm. - Neither — Bun.
Bun ships in the image. Every other manager, and a pinned Bun, runs through jup, which downloads the release on first use, verifies it against the registry signature, and keeps it in your app’s build cache (RUNNER_BUILD_CACHE_MB), so later pushes do not download it again. npm, pnpm and yarn are also on PATH, so package scripts and the release command can call them; unpinned, they follow the same choice as the install step.
With CI=1, pnpm installs with --frozen-lockfile and Yarn 2+ with --immutable, so a lockfile that no longer matches package.json fails the install. pnpm 10 and later also fail on dependency build scripts you have not approved (ERR_PNPM_IGNORED_BUILDS): decide them in pnpm-workspace.yaml, for example allowBuilds: { esbuild: false, workerd: false } for a Cloudflare Vite app, which needs neither. Pin a version to make builds repeatable; the runner never edits your package.json. The deploy log names the manager and why: ▸ install: pnpm install (pnpm-lock.yaml).
The Deployments tab logs each step as ▸ step: command, followed by its output tail. A failed deploy names the step (ERROR: build failed), and the app panel shows the step and the first error line.
wrangler.jsonc or cloudflare.config.ts
Either declares the Worker; pick one per app.
wrangler.jsonc(or.json,.toml) is the format celld deploys, so it is what the runner uses as is. Choose it for a plain Worker, a static site, or@cloudflare/vite-plugin. Besides Wrangler’s own keys it accepts Noite’srelease(Release command).cloudflare.config.tsis thecfCLI’s typed config. With no Wrangler config in the tree, the runner converts it after the build into thewrangler.jsoncelld deploys. Addcftopackage.jsondependencies(it provides@cloudflare/config); a missingcffails the convert step with a line saying so.
import { bindings, defineConfig, defineWorker } from "cf/config";
const worker = defineWorker({
compatibilityDate: "2026-09-01",
entrypoint: "./src/worker.ts",
env: { DB: bindings.d1({ name: "my-app" }) },
name: "my-app",
});
export default defineConfig({ worker });
A Wrangler config in the tree wins over cloudflare.config.ts, and a build that writes dist/wrangler.json wins over both (Which config deploys). Durable Object exports are limited to creating classes; renames, deletes and transfers fail the convert step.
Which config deploys
After a build, the runner deploys what the build produced, in this order:
dist/wrangler.json, a deployable config the build wrote (Oxide writes one, on Vite and Rsbuild alike).- Wrangler’s deploy redirect,
.wrangler/deploy/config.json, which@cloudflare/vite-pluginwrites. The runner turns the config it points to intodist/wrangler.json: it keeps only the keys celld accepts, rewritesmainandassets.directoryrelative todist/, and applies.assetsignore. - Otherwise, the source config:
wrangler.jsonc,wrangler.jsonorwrangler.tomlat the root, then the first one found below it (node_modulesexcluded). - With no config at all, one the runner generates (below).
Without a build step, 1 and 2 do not apply. The deploy log names the config it used (config: dist/wrangler.json (from …), config: generated: static site from dist/ …).
No config at all
An app does not need a Wrangler config. When there is none, the runner writes one from what the build left:
- A static site. The first of
dist/,build/orout/that holds anindex.htmlis deployed as static files: Vite, Rsbuild, Astro’s static output, Create React App, a Next.js static export. Unknown paths get404.htmlwhen the build wrote one, andindex.htmlotherwise, so a single-page app’s client-side routes work. - A Worker.
package.json"main"names a module whose default export has afetchmethod: Hono, H3, Elysia, orexport default { fetch(request) { … } }. The module may be TypeScript; celld bundles it. A static output from the list above is served first, and requests with no matching file reach the Worker, which can also read the files throughenv.ASSETS. The server entry must not sit inside that output, or its source would be public.
{
"type": "module",
"main": "src/index.ts",
"scripts": { "build": "vite build" },
"dependencies": { "hono": "^4.9.0" }
}
The generated config has the app’s slug as name, a fixed compatibility_date and, for a Worker, nodejs_compat. Commit your own wrangler.jsonc as soon as you need more: bindings (D1, R2, Durable Objects), vars, or a different compatibility date.
Vite with the Cloudflare plugin
Use @cloudflare/vite-plugin exactly as you would for Cloudflare: the Worker, its bindings and the client build come from the same wrangler.jsonc.
{
"name": "my-app",
"main": "./src/index.ts",
"compatibility_date": "2026-09-01",
"assets": { "binding": "ASSETS", "not_found_handling": "single-page-application" },
}
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [cloudflare()] });
{
"type": "module",
"scripts": { "build": "vite build" },
"devDependencies": { "@cloudflare/vite-plugin": "^1.62.3", "vite": "^8.3.1" }
}
Imports only Vite understands (?raw, ?url, virtual modules) work, because the runner deploys Vite’s bundle, not your source. Keep vite build’s output inside the project (the default dist/). One Worker per app, so auxiliaryWorkers are refused.
Static sites with a config
A static site deploys without a config (above). Write one to pick the output directory or the not-found behaviour yourself:
{
"name": "my-site",
"compatibility_date": "2026-09-01",
"assets": { "directory": "./dist", "not_found_handling": "single-page-application" },
}
With "scripts": { "build": "rsbuild build" } (or vite build), the runner builds dist/ and celld serves it.
Rsbuild with a Worker
Add main and an ASSETS binding to the same config. The Worker is bundled from source by celld’s esbuild, and the client by Rsbuild:
{
"name": "my-app",
"main": "./worker.ts",
"compatibility_date": "2026-09-01",
"assets": { "directory": "./dist", "binding": "ASSETS", "not_found_handling": "single-page-application" },
}
For a Worker that itself needs the bundler (Oxide, framework plugins), have the build write dist/wrangler.json.
Frameworks and Node servers
Apps run on celld, which runs Cloudflare Workers. A framework deploys when it builds for Workers:
- Built on
fetch(Hono, H3, Elysia): setmain(above) or write awrangler.jsonc. - With a Cloudflare target: use the framework’s own Workers build, which writes a Wrangler config or works through
@cloudflare/vite-plugin. Examples: Nuxt and Nitro (cloudflare_modulepreset), SvelteKit (@sveltejs/adapter-cloudflare), Astro (@astrojs/cloudflare), React Router and TanStack Start (the Cloudflare Vite plugin). These targets are Cloudflare’s; Noite has not tested each one, and a feature celld lacks fails at deploy or on the first request.
A Node HTTP server does not run: Express, Fastify, Koa, http.createServer, or anything that calls app.listen(). celld implements no node:http server and no IncomingMessage, so wrapping the app in a fetch adapter does not help either. When main looks like such a server, the deploy fails with a message saying so. Move the routes to a fetch-based framework (Hono’s API is close to Express), or use your framework’s Workers target.
_headers and _redirects
Static assets honour Cloudflare’s _headers and _redirects files, with Cloudflare’s syntax, placed at the root of the assets directory (dist/ for a Vite build: put them in public/ so the build copies them there).
/assets/*
Cache-Control: public, max-age=31536000, immutable
celld serves assets with Cache-Control: max-age=0, must-revalidate, so mark content-hashed files immutable as above. The full syntax and limits are in celld’s assets documentation.
Custom build command
Wrangler’s build block replaces the build script, as it does for wrangler deploy:
{
"build": { "command": "bun run build:web && bun run build:worker", "cwd": "." },
}
cwd must stay inside the project. The runner strips build and release before celld deploy, which refuses keys it does not know.