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.

Package managers

npm, pnpm, Yarn (1 and 2+) and Bun all work. The runner picks one per push:

  1. A pin in package.json — "packageManager": "pnpm@10.18.0" 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 major its lockfileVersion needs), yarn.lock → Yarn 1 for a v1 lockfile, else the latest Yarn, package-lock.json/npm-shrinkwrap.json → npm.
  3. 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’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 (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. dist/wrangler.json, a deployable config the build wrote (Oxide writes one, on Vite and Rsbuild alike).
  2. Wrangler’s deploy redirect, .wrangler/deploy/config.json, which @cloudflare/vite-plugin writes. The runner turns the config it points to into dist/wrangler.json: it keeps only the keys celld accepts, rewrites main and assets.directory relative to dist/, and applies .assetsignore.
  3. Otherwise, the source config: wrangler.jsonc, wrangler.json or wrangler.toml at the root, then the first one found below it (node_modules excluded).
  4. 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/ 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.

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?