---
title: Build
description: 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](/apps/deploy#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](#no-config-at-all)) |
| 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](/apps/deploy#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.

```ts title="cloudflare.config.ts"
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](#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](#no-config-at-all)).

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.

```json title="package.json (Hono)"
{
  "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`.

```jsonc title="wrangler.jsonc"
{
  "name": "my-app",
  "main": "./src/index.ts",
  "compatibility_date": "2026-09-01",
  "assets": { "binding": "ASSETS", "not_found_handling": "single-page-application" },
}
```

```ts title="vite.config.ts"
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [cloudflare()] });
```

```json title="package.json"
{
  "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](#no-config-at-all)). Write one to pick the output directory or the not-found behaviour yourself:

```jsonc title="wrangler.jsonc"
{
  "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:

```jsonc title="wrangler.jsonc"
{
  "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](#no-config-at-all)) 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).

```text title="public/_headers"
/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`:

```jsonc title="wrangler.jsonc"
{
  "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.
