Control plane
How the hosted SupaNet offering works — provisioning, tenant management, and scaling.
The control plane is the infrastructure that powers hosted SupaNet (supanet.io). When a customer signs up and pays, the control plane automatically provisions a complete SupaNet workspace for them without any manual work.
Architecture overview
The control plane is a separate npm workspace in control-plane/, just like workers/. It runs independently from the main SupaNet app and consists of three pieces:
- Engine — a resumable provisioning pipeline that creates and configures tenant infrastructure
- Control-plane Supabase — a dedicated project that tracks tenants, provisioning jobs, and subscriptions
- Signup UX — a small web app and backend for the signup flow and status page
Per-tenant infrastructure
Each customer gets a complete, isolated SupaNet deployment:
- Supabase project — a dedicated PostgreSQL database created in the Sundance Solutions org via the Management API
- Railway service — a per-tenant deployment of the SupaNet frontend and edge functions, built from the stable
releasebranch - Custom domain —
yourcompanyname.supanet.iovia Cloudflare DNS - OpenRouter key — a per-tenant API key with a spend limit, so runaway automations can't exceed their subscription cost
This isolation means:
- Total data separation — one tenant's database never touches another's
- Easy to upgrade — merge a change on
releaseand every tenant auto-updates - Easy to cancel — pause or delete the Railway service and Supabase project
- Easy to eject — transfer the tenant's Supabase project to their own account, hand them the repo, and they're running self-hosted
The release branch
Development continues on main (trunk-based, auto-deploys the main SupaNet instance). Shipping to customers is done by merging vetted commits into a long-lived release branch:
- A change lands on
main, gets tested and deployed to the main instance. - When ready to ship, you fast-forward
releaseto that commit. - Every tenant Railway service tracks
release, so merging auto-redeploys them all at once — fleet upgrade with one push. - If something breaks, roll back by force-pushing
releaseto the prior commit; all tenants roll back together.
This means database migrations must stay backward-compatible one release back (migrations in SupaNet are already additive and never destructive, so this is automatic).
Backend fan-out for tenants
The frontend auto-updates from release (each tenant's Railway service rebuilds), but database migrations and edge functions require explicit deployment. A push to release that adds a new table but the tenant database never receives it causes frontend-calling-missing-schema errors.
The release fan-out workflow (.github/workflows/release-tenants.yml) closes this gap: when you push to release, a GitHub Action automatically applies pending migrations and redeploys edge functions to every live tenant. The tenant list comes directly from the control plane's tenants table (the system of record), so no hand-kept registry is needed.
How it works:
- Tenant discovery —
scripts/list-tenants.tsqueries the control plane'stenantstable (via the Management API) for live projects (status='active'or'past_due') - Migrations —
scripts/apply-migrations.tsapplies pending migrations via the Management API SQL endpoint (no DB password needed, just the org PAT) - Functions —
supabase functions deploy --project-ref <ref>redeploys edge functions to each tenant
All three steps run in parallel per tenant (max 4 at once), and a failure in one tenant never blocks the others (fail-fast:false).
Setup:
Add one repository secret (SUPABASE_ACCESS_TOKEN — the same org PAT used for the origin) and one variable (CONTROL_PLANE_REF — the control-plane project ref). The workflow triggers automatically on pushes to release that touch migrations, functions, or the tenant list.
Optional canary override:
Edit infra/tenants.json to pin the rollout to a subset of tenants (e.g. test one tenant first before rolling out to all). Set refs to a list of project refs; the workflow uses those instead of the live list. Leave refs empty or the file absent to roll out to all live tenants.
Dry-run:
Trigger the workflow manually via Actions → Release to tenants → Run workflow, check the dry_run box. It lists pending migrations per tenant without applying them.
The provisioning engine
The engine is the core: a pipeline of idempotent, resumable steps. Each step is recorded in a progress jsonb checklist on the provisioning job row, so a failed run re-enters at the failed step and never double-applies.
The steps are:
- createProject — Supabase Management API: create a new project in the org
- waitForHealthy — poll
/auth/v1/healthuntil the project responds - applyMigrations — run all
supabase/migrations/*.sqlagainst the new project - deployFunctions — bundle and deploy all edge functions from the
releasecheckout - setSecrets — set
OPENROUTER_API_KEYand other edge-function secrets - configureAuth — add allowed email domains and JWT secrets
- mintOpenRouterKey — OpenRouter Provisioning API: create a per-tenant key with a spend limit
- createRailwayService — Railway GraphQL API: create a service, set env vars, connect to the
releasebranch - wireDns — Cloudflare API: create a CNAME and ownership TXT records
- verify — health-check the new workspace (Supabase + Railway + custom domain)
Each step can fail and be retried from the control-plane admin dashboard without re-running the prior steps.
Tenant state machine
A tenant moves through states driven by Stripe webhooks and the control plane's lifecycle-tick function:
pending_payment → provisioning → active
↓
(payment failure)
↓
past_due
↓ (unpaid for grace period)
paused (Railway/Supabase paused, costs nothing)
↓ (30 days later)
deleted (workspace removed, export offered)A tenant can also be manually canceled (moves to paused immediately at period end).
Subscription and billing
- Signup → Stripe Checkout — customer pays $49/month, never per seat.
- Payment confirmed → provisioning starts (no free tier).
- During the month —
lifecycle-tickruns daily, checking for canceled or past-due subscriptions. - Cancel → pauses the workspace at period end (Stripe + Supabase + Railway paused state means near-zero cost).
- Overage — if the per-tenant OpenRouter key usage exceeds the included allowance, overage is metered and billed on the next invoice.
Running the control plane locally
For development and testing, the engine can be run directly from a laptop:
cd control-plane
npm install
cp .env.example .env # fill in credentials for Supabase Management API, Railway, Cloudflare, OpenRouter, Stripe
npm run tenant-zero -- --slug acme --name "Acme Co" --email you@example.comProgress persists to tenant-zero.state.json, so a failed run can be resumed by re-running the same command.
Prerequisites
- A
releasebranch on the repo (created once:git branch release && git push -u origin release). - A Railway project and environment to hold tenant services (IDs in
.env), with the GitHub repo connected to your Railway account. - API credentials:
- Supabase org PAT (Dashboard → Account → Access Tokens)
- Railway API token
- Cloudflare API token (scoped to Zone:DNS:Edit on the
supanet.iozone) - OpenRouter Provisioning API credentials
- Stripe test keys
Field notes from the first production run
The first tenant ("tenant-zero") was provisioned on 2026-07-19. Some gotchas discovered and fixed:
- Management API is transactional per request — a failed migration leaves no partial state, but the version record must commit atomically with the migration itself.
- Management API throttles (~60 req/min) — the client backs off and retries internally.
- Fresh projects gate
/rest/v1/to secret keys — use/auth/v1/healthfor health checks instead. - Railway custom domains require DNS verification — set both the CNAME and the TXT record for the ownership check.
- Let's Encrypt allows ~50 certs/week per domain — per-tenant certs on
*.supanet.iocap onboarding at ~50 tenants/week. Use Cloudflare for SaaS or a wildcard cert before scaling.
Admin dashboard (internal)
The control plane has a small Supabase edge function and React app for admins to:
- View the list of tenants and their state (active, paused, etc.)
- Trigger a provisioning retry if a step failed
- Manually pause or delete a tenant
- View provisioning logs and progress
This runs on the control plane's own Supabase project (not tenant-visible).
Non-goals (v1)
- Multi-tenant (many companies per Supabase project) — still rejected.
- Custom domains (
ai.acme.com) — stay on*.supanet.io. - SSO/SAML, per-seat pricing, or other enterprise features.
- A literal OpenRouter proxy service — provisioned keys with limits do the job.
- Migrating existing self-hosted workspaces into the control plane.
See also
- Deploy — how to run SupaNet self-hosted
- Full task spec — detailed design and build order