Skip to content
Noite
Esc
↑↓navigate↵open⌘Jpreview
On this page

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

  1. A pin in package.json — "packageManager": "[email protected]" or devEngines.packageManager with a version. That exact release runs, Bun included.
  2. No pin, a lockfile — the manager that wrote it: bun.lock/bun.lockb → Bun, pnpm-lock.yaml → pnpm (the bundled release for lockfileVersion: 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).
  3. 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’s release (Release command).
  • cloudflare.config.ts is the cf CLI’s typed config. With no Wrangler config in the tree, the runner converts it after the build into the wrangler.json celld deploys. Add cf to package.json dependencies (it provides @cloudflare/config); a missing cf fails 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:

  1. Void’s own output, dist/ssr/wrangler.json, with the assets in dist/client/. It outranks a dist/wrangler.json beside 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 sandbox containers and skip migrations, identity vars and the sandbox controller.
  2. dist/wrangler.json, a deployable config the build wrote (Oxide writes one, on Vite or Rsbuild).
  3. Wrangler’s deploy redirect, .wrangler/deploy/config.json, which @cloudflare/vite-plugin writes.
  4. Otherwise, the source config: wrangler.jsonc, wrangler.json or wrangler.toml at the root, then the first one found below it (node_modules excluded).
  5. 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/ or out/ that holds an index.html is deployed as static files: Vite, Rsbuild, Astro’s static output, Create React App, a Next.js static export. Unknown paths get 404.html when the build wrote one, and index.html otherwise, so a single-page app’s client-side routes work.
  • A Worker. package.json "main" names a module whose default export has a fetch method: Hono, H3, Elysia, or export 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 through env.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): set main (above) or write a wrangler.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_module preset), 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.

Was this page helpful?