# Deployment

Production ships from `main`:

```text
push main
├─ Cloudflare Git integration → www
├─ Cloudflare Git integration → docs
└─ GitHub Actions             → backstage on Fly
```

All three deploy from the pushed commit. Database migrations remain manual. To stand up another instance, start at [self-hosting](../../self-hosting.md).

Manual deploys are for targeted releases. Backstage requires a clean checkout matching `origin/main` (or the matching main commit in GitHub Actions), and uploads an archive of that commit so concurrent checkout edits cannot enter its build:

```sh
bunx varlock load   # names every missing secret; fill them in the root .env.local
bun install                                    # also needs `fly auth login`
bun run deploy                                 # backstage → docs → www, verified after each
bun run deploy www docs                        # or a slice; --list shows targets
bun run push-migrations                        # schema — separate and manual
```

[`scripts/deploy.ts`](../../scripts/deploy.ts) owns target order, preflight, deploy, and verification. `bun run deploy` with no targets ships backstage + www + docs only. Before any selected service deploys, each selected Cloudflare Worker must pass a non-interactive `wrangler deployments list --json` access check using its own config. `wrangler whoami` is not sufficient: it can exit successfully while logged out. A failed access check blocks the whole run; sign in with `bunx wrangler login` or supply a token with access to the selected Workers.

## Cloudflare Workers Builds (CI)

`@arbe/www` → worker **`arbe`**, `@arbe/www-docs` → **`arbe-docs`**. Push to `main` deploys these two through Cloudflare's Git integrations; the GitHub build workflow deploys the Fly backstage from the same commit after both bundle checks pass. Manual `bun run deploy` is reserved for hotfixes and targeted deploys.

Builds has a **production** deploy command and a separate **non-production** one (PR previews / version uploads). Config lives under `apps/*` while the root directory stays `/` for workspace deps — the dashboard default `npx wrangler versions upload` fails here (no root `wrangler.jsonc`).

| Worker | Production deploy | Non-production / PR preview |
|---|---|---|
| `arbe` | `bun run --filter '@arbe/www' deploy` | `bun run --filter '@arbe/www' deploy:preview` |
| `arbe-docs` | `bun run --filter '@arbe/www-docs' deploy` | `bun run --filter '@arbe/www-docs' build && bunx wrangler versions upload --config apps/docs/wrangler.jsonc` |

**Two** Builds connections on the same repo / `main`, one per worker — Builds injects `WRANGLER_CI_OVERRIDE_NAME` with the connected worker's name, so a single connection ships docs onto `arbe`. Both use root directory `/`, an **empty** build command (the root `build` script builds the CLI binary; the deploy / preview commands run their own), and no watch paths — a wrong one ships a stale worker silently, and `arbe-docs` renders repo-root `docs/`, not just `apps/docs`.

[`build.yml`](../../.github/workflows/build.yml) builds both production bundles on every push to `main` and every PR, independent of Workers Builds. On pushes to `main`, it then deploys `@arbe/backstage` to Fly. The repository needs a `FLY_API_TOKEN` Actions secret created with `fly tokens create deploy -a arbe-backstage`. `bun run check` does not bundle (svelte-check type-checks only) and the Vite dev server resolves differently, so a broken rollup build — a dependency override the app has outgrown, a missing export — is otherwise invisible until someone deploys. The workflow supplies placeholder values for the six worker build-time secrets; the bundles only need them to link. It also runs `bun run --filter '@arbe/sandbox' check:drift`: the commit carries one generated runtime module, `packages/sandbox/src/daytona/generated/bundles.ts`, and nothing rebuilds it on install (the backstage image regenerates it inside its own build), so a stale copy fails CI rather than shipping. Rebuild it with that package's `build` script. The build image pins bun **1.2.15** unless a connection sets `BUN_VERSION`. Both connections must set it to the bun that wrote `bun.lock` (1.4.2 as of 2026-09-13, lockfile version 2); an older image fails within seconds with `UnknownLockfileVersion` and `lockfile had changes, but lockfile is frozen`, while the GitHub build (`bun-version: latest`) passes. Bumping bun locally means bumping `BUN_VERSION` on both connections in the same change.

Only `arbe` has secrets. They all live in the Builds connection now: `varlock-wrangler` resolves the schema from them at deploy time and uploads the result as worker vars/secrets — [environment-variables](../sandboxes/environment-variables.md) has the details.

Both deploy scripts stamp the commit in `--message`; `arbe` deploys through `varlock-wrangler` and puts `RELEASE_SHA` in the process env so varlock uploads it for analytics stamping (`track.ts`). Both deploy scripts prefer release identity sources in this order: `ARBE_RELEASE_SHA`, then Builds' `WORKERS_CI_COMMIT_SHA`, then Git. Source archives must set `ARBE_RELEASE_SHA`; the manual deploy refuses an unknown identity. The manual docs deploy captures that identity before building, passes it as `ARBE_RELEASE_SHA`, and verifies the exact same deployment message, including when `WORKERS_CI_COMMIT_SHA` differs from the checkout.

### Post-push check: did a build actually run?

The GitHub Action and two Workers Builds run independently. Confirm each deploy before assuming all of `main` is live.

- **Dashboard:** Cloudflare → worker `arbe` → **Build history**. The Deployment history page cannot answer this — every row there says "Wrangler" because CI runs our own `bun run deploy`.
- **CLI:** `gh api repos/oskarrough/arbe/commits/<sha>/check-runs` shows a `Workers Builds: arbe` check. It lags the `arbe-docs` check by a couple of minutes, so do not call it missing too early. `wrangler` has no builds command at all.
- **Deployment message:** both CI and manual deploys stamp the resolved commit SHA; its length does not tell you which path deployed it.
- **Build log:** the dashboard is the only place it exists — `wrangler` has no builds command at all, and the OAuth token `wrangler login` writes carries no Workers Builds scope, so `GET /accounts/<id>/builds/builds/<uuid>/logs` is a 403 and `gh api .../check-runs` reports pass or fail with nothing else. Export the log from Build history when you need to read it.
- Note that a green `build.yml` run is not this check: it proves the rollups link and deploys backstage, but it does not schedule either Workers Build.

On 10–11 Sep both builds failed for three pushes in a row: the root `postinstall` ran the sandbox bundling step, which exited 1 with no output inside the build image (bun 1.2.15, the same binary that passed locally). That hook is gone and the bundle is committed, so the same symptom now points at the image itself — capture the push time and the dashboard log, then raise it with Cloudflare. One failed build on its own is not worth chasing.

CLI binaries ship to Cloudflare R2 at `https://downloads.arbe.0sk.ar/` via the `cli-release` GitHub Action — see [releases](releases.md) for the build/upload/version contract. The `arbe-downloads` bucket is on the same account with a custom domain attached via R2 → Settings → Custom Domains (no worker fronts it). Repo secrets needed: `CLOUDFLARE_ACCOUNT_ID` (the account id above — not sensitive) and `CLOUDFLARE_API_TOKEN` (Object Read & Write scoped to `arbe-downloads` only — rotate if leaked). Set both at GitHub → Settings → Secrets and variables → Actions.

### R2 bucket

| Bucket | Holds | Read by |
|---|---|---|
| `arbe-downloads` | published CLI tarballs + manifests | `cli-release` Action (write), `https://downloads.arbe.0sk.ar/` (public read) |

No application worker has an R2 binding.

Supabase auth is configured in the dashboard, not in code:
1. Auth → URL Configuration: add `http://localhost:5173/auth/callback` (local) and `https://arbe.0sk.ar/auth/callback` (prod).
2. Auth → Providers → GitHub: enable, paste Client ID + Secret from the GitHub OAuth App.
3. GitHub OAuth App callback URL: `https://<project-ref>.supabase.co/auth/v1/callback`.

Code: `scripts/deploy.ts`, `.github/workflows/build.yml`, `apps/*/wrangler.jsonc`, `apps/backstage/fly.toml`, `.github/workflows/cli-release.yml`.<br>
See [system/environment-variables](../sandboxes/environment-variables.md), [system/development](development.md), [releases](releases.md).
