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.
Install, build and release steps also run in their own cgroup with a memory cap (RUNNER_BUILD_MEMORY_MB; unset derives the detected memory minus a 1 GiB resident floor, clamped 512–4096 MiB), a bounded swap allowance (RUNNER_BUILD_SWAP_MB; unset derives the host’s swap across the build slots) and a low CPU share, so a build that outgrows the box cannot starve the runner or your apps. A Vite 8/Rolldown build of a Void app peaks around 1.3–1.5 GiB: the RAM cap keeps the resident services safe and the leaf swaps the overflow, so the build finishes on a small box instead of dying at the cap. A build that fills RAM and swap is killed on its own, and the deploy log names both budgets.
Package managers
- A pin in
package.json—"packageManager": "[email protected]"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 bundled release forlockfileVersion: 9.0, the matching major for older formats),yarn.lock→ Yarn 1 for a v1 lockfile, else the latest Yarn,package-lock.json/npm-shrinkwrap.json→ npm (the bundled release). - Neither — Bun.
Bun, npm and pnpm ship in the image. A pinned manager, a pinned Bun, and Yarn run 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. An unpinned npm or pnpm project runs the bundled release straight from the image, with no registry request for the manager itself; bumping a bundled release is an image release. 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 — unless it also wrote Void’s dist/ssr/wrangler.json, which then wins (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:
- Void’s own output,
dist/ssr/wrangler.json, with the assets indist/client/. It outranks adist/wrangler.jsonbeside it: Void apps may derive that file for celld, and the derivation keeps only the keys it knows, so reading it would drop Void’s sandboxcontainersand skip migrations, identity vars and the sandbox controller. dist/wrangler.json, a deployable config the build wrote (Oxide writes one, on Vite or Rsbuild).- Wrangler’s deploy redirect,
.wrangler/deploy/config.json, which@cloudflare/vite-pluginwrites. - 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).
For 1 and 3 the runner writes dist/wrangler.json from the config they point to: it keeps only the keys celld accepts, rewrites main and assets.directory relative to dist/, absolutizes container dockerfile/build_context paths (Void’s default sandbox Dockerfile lives in node_modules), and applies .assetsignore (plain file names only: a glob, a path or a ! negation fails the deploy). Without a build step, 1 to 3 do not apply. The deploy log names the config it used (config: dist/wrangler.json (from dist/ssr/wrangler.json, Void), 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; a stock Void app needs nothing extra to deploy. Apps using void/sandbox need nothing extra either: their sandboxes run through the platform’s cohosted controller (Void sandboxes).
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.