---
title: Deploy
description: Create an app and ship it — push over Git, deploy from CI, and what each push triggers.
---

## Create the app

Open the control UI and go to **Apps → New app**. Enter a name and slug. Creating the app provisions its `git` remote; deploys follow each push.

Slug rules: 1–48 chars, lowercase letters/digits/hyphens, starting and ending with a letter or digit. Reserved: `_control`, `app`, `api`, `git`.

## Push with git

Every app has a Git remote at `https://git.<domain>/<slug>`. Authenticate as user `git` with an account API key (**Account → API keys**) as the password; the push needs the `push` role on the app. Each push to `main` deploys.

The smallest app is a Wrangler config plus a module Worker:

```jsonc title="wrangler.jsonc"
{
  "name": "hello",
  "main": "index.js",
  "compatibility_date": "2026-09-01",
}
```

```js title="index.js"
export default {
  fetch: () => new Response("Hello from Noite"),
};
```

```bash
git init -b main
git add -A && git commit -m "first deploy"
git remote add origin "https://git:<api-key>@git.<domain>/<slug>"
git push -u origin main
```

Open the result at `https://<slug>.<domain>`. With a `package.json`, the runner installs and builds it first with the project's package manager (npm, pnpm, Yarn or Bun), so Vite and Rsbuild apps need no CI ([Build](/apps/build)); without one it deploys the tree as is. The Wrangler config is optional: a static site (a build that writes `dist/index.html`) or a `package.json` `main` that exports `fetch` deploys without one ([No config at all](/apps/build#no-config-at-all)). Pushes must fast-forward unless you hold the `admin` role.

## Your first app, in order

1. **Create the app** and an API key (above).
2. **Describe the Worker** with a `wrangler.jsonc` or, if you use the `cf` CLI, a `cloudflare.config.ts` ([which one](/apps/build#wranglerjsonc-or-cloudflareconfigts)). Or neither, for a static site or a `fetch` module ([no config at all](/apps/build#no-config-at-all)).
3. **Add a build** if you have one: a `build` script or `build.command` ([Build](/apps/build#steps)).
4. **Set env vars** in the app's Settings if the build or release command needs them. They do not reach the running Worker; use `vars` in the config for that ([Configure](/apps/configure#environment-variables)).
5. **Push to `main`**, watch the Deployments tab, open `https://<slug>.<domain>`.
6. **Check what does not run**: Node HTTP servers (Express, Fastify, `app.listen()`) are refused at deploy ([Frameworks and Node servers](/apps/build#frameworks-and-node-servers)). Quotas, rate limits and memory bounds are on [Limits](/reference/limits).

## Release command

A `release` string in the Wrangler config runs once per deploy, after the build and before `celld deploy`: the place for database migrations.

```jsonc title="wrangler.jsonc"
{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-01",
  "release": "bun run db:migrate",
}
```

It runs with `sh -c` in the project with your app's env vars, and is killed after 10 minutes. The command is capped at 500 characters. A non-zero exit fails the deploy and **the previous deployment keeps serving**. `release` is removed from the config before `celld deploy`.

:::warning
In multi-tenant mode (`NOITE_TENANCY=multi`, the default off `localhost`) release commands are **skipped**, with a line in the deploy log: running them would need the install's root storage keys. Migrate from the app itself at startup, or ask the operator to run `single`. See [Tenancy](/self-hosting/tenancy#what-multi-does-and-does-not-promise-alpha).
:::

## Deploy from CI

`@noitenow/cli` pushes a prebuilt `dist/` plus `wrangler.jsonc` as a synthetic fast-forward commit — no server build, `push` role suffices, never force-pushes.

```bash
bunx @noitenow/cli deploy --slug <slug> --url https://git.<domain> --token "$NOITE_API_KEY"
```

```yaml
- run: bun run build
- run: bunx @noitenow/cli deploy --comment update
  env:
    NOITE_API_KEY: ${{ secrets.NOITE_API_KEY }}
    NOITE_BASE: https://git.<domain>
```

| Flag | Env | Default |
| --- | --- | --- |
| `--dist` | — | `dist` |
| `--slug` | `NOITE_SLUG` | repo name, slugified |
| `--url` | `GIT_PUBLIC_BASE` / `NOITE_BASE` | `http://git.localhost:9080` |
| `--token` | `NOITE_API_KEY` / `NOITE_GIT_TOKEN` | — |
| `--wrangler` | — | `wrangler.jsonc` |
| `--message` | — | `deploy <sha12>` |
| `--comment` | — | `update` (`create` / `off`) |

In Actions it writes `url=` + `sha=` to `$GITHUB_OUTPUT` and posts (or updates, via marker) a PR comment. A `Worker` may be declared in `cloudflare.config.ts` instead of `wrangler.json(c)` — the runner converts it after the build when no Wrangler config exists.

## What happens on push

1. Tip `.bundle` lands; the runner updates its bare mirror and worktree.
2. The sandboxed install and build (`build.command` or the `build` script) run when the tree declares them, with the project's package manager.
3. The runner picks the config to deploy: the build's output when it produced one, else the source config, else one it generates ([Which config deploys](/apps/build#which-config-deploys)).
4. Optional `release` command (e.g. migrations) runs. A failed release keeps the old deployment serving.
5. The config is stripped of runner-only keys (`release`, `build`), then `celld deploy` ships the fleet; a new fleet spawns or the existing one gets `POST /reload`.

The **Deployments** tab shows status per push with a build-log pane: one `▸ step: command` line per step and its output tail, and on failure the step that failed. Deploy logs keep a 64 KB tail.

## Rollback

`POST /v1/apps/<id>/rollback {sha}` redeploys a past successful SHA — bundles are immutable per SHA. The UI offers rollback on success rows.
