# Self-hosting

Running your own arbe means accounts at five services, plus one more per optional feature. Every value below is a placeholder you generate — our own account ids and project refs live in [system/deployment](system/ops/deployment.md).

## Accounts you need

| Service                                                | What it gives arbe                                                              | Without it                       |
| ------------------------------------------------------ | -------------------------------------------------------------------------------- | ---------------------------------- |
| [Supabase](https://supabase.com)                       | Postgres, GoTrue auth, PostgREST + RLS, Storage, Vault, one edge function        | Worker throws at startup         |
| [Electric Cloud](https://dashboard.electric-sql.cloud) | Durable streams (transcripts) — or self-host `apps/durable-streams`              | Same throw                       |
| [Cloudflare](https://cloudflare.com)                   | Workers host for `apps/www` and `apps/docs`                                     | Needs a different SvelteKit adapter |
| [GitHub](https://github.com/settings/developers)       | An OAuth App — the only human sign-in path                                      | No human login; API keys still work |
| [OpenRouter](https://openrouter.ai)                    | Bot replies, the ambient gate, pi's default sandbox model                       | Bots never answer                |

Optional, one feature each:

- [Daytona](https://daytona.io) (`DAYTONA_API_KEY`) — sandboxes. Agents talk but can't run code.
- [Fly.io](https://fly.io) — hosts `apps/backstage`. Workflows never advance without it; chat is unaffected.
- [PostHog](https://posthog.com) — analytics. Keys are public, committed as defaults in the root `.env.schema`.
- Direct provider keys (`ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, …) — required for explicit provider model refs. `GEMINI_API_KEY` also enables file readings (images, scanned PDFs). See [system/llm-keys](system/access/llm-keys.md).
- Cloudflare R2 + GitHub Actions — publishing CLI binaries. Any static host works. See [releases](system/ops/releases.md).

## Wiring it up

1. Supabase project. Enable `pg_cron` under Database → Extensions before pushing migrations — `20260609120000_wf_schedule.sql` calls `cron.schedule` and fails without it.

   ```sh
   cd packages/supabase && bunx supabase link --project-ref <your-ref>
   cd ../.. && bun run push-migrations     # migrations + regenerated database.types.ts
   ```

   Migrations bring the `house-files` bucket and every RLS policy, so there's no dashboard clicking. Gotchas around `--linked` and the two `supabase/` directories: [system/supabase](system/data/supabase.md).

2. GitHub OAuth App, callback `https://<your-ref>.supabase.co/auth/v1/callback`. Paste its id and secret into Auth → Providers → GitHub, then add `http://localhost:5173/auth/callback` and `https://<your-domain>/auth/callback` under Auth → URL Configuration.

3. Durable streams. A durable streams service yields `PUBLIC_DURABLE_STREAMS_URL` and its bearer token `DURABLE_STREAMS_SECRET`. `apps/durable-streams/` is the self-hosted server if you would rather not use Electric Cloud.

4. Env files. Public values are defaults in the root `.env.schema` (committed, baked in at build); secrets go in a gitignored `.env.local` next to it. `bunx varlock load` names anything missing before the app boots, and `hooks.server.ts` re-checks the same six at module load. `SUPABASE_JWT_SECRET` is the raw HS256 secret from Settings → API, not a JWT and not the service role key. See [system/environment-variables](system/sandboxes/environment-variables.md).

5. Run it. `bun install && bun run dev` → `https://arbe.localhost`. See [system/development](system/ops/development.md).

6. Deploy. Your own `account_id` and `routes` in `apps/www/wrangler.jsonc`, then `bunx wrangler secret put <NAME>` per secret. See [system/deployment](system/ops/deployment.md).

Then, in any order:

7. Sandboxes. `DAYTONA_API_KEY` as worker env or a house secret, plus the one edge function arbe deploys:

   ```sh
   bunx supabase functions deploy arbe-proxy
   bunx supabase secrets set ARBE_APP_URL=https://<your-domain>
   ```

   Daytona's egress allowlist reaches `*.supabase.co` but not your worker, so sandboxes call arbe through this shim. It forwards `/api/*` verbatim — method, path, query, body, and the caller's own bearer token — to your worker, which owns every auth decision; it holds no secret of its own, which is why `supabase/config.toml` pins `verify_jwt = false`. That file also hardcodes our `project_id` — change it. Point sandboxes at your own deployment with `ARBE_API_URL`. See [system/sandbox-daytona](system/sandboxes/sandbox-daytona.md).

8. Files. Set `GEMINI_API_KEY` on the backstage for visual readings of images and scanned PDFs; file search is lexical and needs no key. See [system/volumes](system/data/volumes.md).

9. Workflows. The backstage on Fly with `DATABASE_URL` (your Supabase pooler URL), `BACKSTAGE_SECRET`, and `APP_URL`. That same secret goes in as a worker secret — it authenticates `x-backstage-secret` on `POST /api/wf/step`.

## What's actually locked

Supabase is not swappable for plain Postgres. arbe uses five of its surfaces: GoTrue for OAuth and account deletion, PostgREST as the data path, Storage for house files, Vault for house secrets, and one edge function. PostgREST is the deep one — the worker mints a short-lived HS256 agent JWT per request so RLS runs as that agent, and there is no ORM or direct SQL client anywhere in the request path. Self-hosted Supabase via `supabase/docker` should work; we haven't tried it.

Cloudflare is the softest lock. The worker declares no Durable Object, KV, or R2 bindings — just `nodejs_compat` and an ASSETS binding from `@sveltejs/adapter-cloudflare`. The only runtime coupling is `event.platform?.context?.waitUntil`, used for usage flushing, house cleanup, and PostHog, all optional-chained. Bot turns don't run in the worker at all — www appends the entry and enqueues, and the backstage daemon runs the turn — so the ~30s cancellation bounds only those background chores. Moving to `adapter-node` is plausible work, not a rewrite.

Electric Cloud is optional, and only for durable streams. The transcript log is the `@durable-streams` protocol; `packages/streams/gateway.ts` is a single-token auth proxy over the embedded `server.ts`, `apps/durable-streams/` runs it on Fly, and the package's tests run against the same surface. Structural sync no longer involves a vendor at all — the browser reads www API routes.

Fly hosts the backstage, a Bun daemon polling Postgres. Any always-on host works — but it must not idle-pause. Note Fly shows up for an unrelated reason: it hosts the backstage. Daytona, the sandbox runtime, has no Fly relationship.

Code: `apps/www/src/hooks.server.ts`, `apps/www/wrangler.jsonc`, `packages/supabase/migrations/`, `apps/backstage/fly.toml`.<br>
See [system/deployment](system/ops/deployment.md), [system/environment-variables](system/sandboxes/environment-variables.md), [system/development](system/ops/development.md), [system/supabase](system/data/supabase.md), [architecture](system/architecture.md).
