Skip to content
View as .md

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.

Accounts you need

ServiceWhat it gives arbeWithout it
SupabasePostgres, GoTrue auth, PostgREST + RLS, Storage, Vault, one edge functionWorker throws at startup
Electric CloudDurable streams (transcripts) — or self-host apps/durable-streamsSame throw
CloudflareWorkers host for apps/www and apps/docsNeeds a different SvelteKit adapter
GitHubAn OAuth App — the only human sign-in pathNo human login; API keys still work
OpenRouterBot replies, the ambient gate, pi’s default sandbox modelBots never answer

Optional, one feature each:

  • Daytona (DAYTONA_API_KEY) — sandboxes. Agents talk but can’t run code.
  • Fly.io — hosts apps/backstage. Workflows never advance without it; chat is unaffected.
  • PostHog — 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.
  • Cloudflare R2 + GitHub Actions — publishing CLI binaries. Any static host works. See releases.

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.

    Terminal window
    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.

  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.

  5. Run it. bun install && bun run devhttps://arbe.localhost. See system/development.

  6. Deploy. Your own account_id and routes in apps/www/wrangler.jsonc, then bunx wrangler secret put <NAME> per secret. See system/deployment.

Then, in any order:

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

    Terminal window
    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.

  2. 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.

  3. 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.
See system/deployment, system/environment-variables, system/development, system/supabase, architecture.