Build
How the runner builds a push — Vite, Rsbuild, 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 |
| 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.
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.
Without a build step, only the third applies. The deploy log names the config it used (config: dist/wrangler.json (from …)).
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 (Vite, Rsbuild, anything)
A site with no Worker needs only a Wrangler config that points at the build output:
{
"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.
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.