Deploying
One Vercel project, one Supabase project, migrations deployed by CI, domains pointed at tenants.
One Vercel project, one cloud Supabase project, every product domain pointed at the same deployment. You open each vendor account once, here at the start, and every product after that is a tenant inside accounts that already exist: no second Vercel project, no second Supabase project, no new Stripe account and no new signing secret. Adding a product to prod later is a deploy plus a domain row, not a new stack.
Before anything else: edit the seed migration
supabase/migrations/20260803100700_seed_millroom.sql carries the Millroom maintainer's deployment data: his org names, brand rows, apex domains (millroom.dev and friends), and platform admin email. It is the first file you edit, before your first push to a cloud project.
Replace, per the comments at the top of the file:
- the org names (the fixed UUIDs can stay)
- the brand token sets with your palette (theming)
- the apex hosts with your real domains
- the
platform_adminsemail with your own
Dev *.localhost hosts live in supabase/seed.sql and never reach a cloud project, so local dev keeps working either way.
1. Provision the cloud Supabase project (once)
Create a project in the Supabase dashboard, then bootstrap the schema from your repo:
supabase link --project-ref <YOUR_PROJECT_REF>
supabase db push # one-time bootstrap: applies every migration
Then set the exposed schemas. PostgREST only serves schemas it is told about; supabase/config.toml handles local, but the cloud list is separate. In the dashboard: Project settings, API, Exposed schemas. Add:
public, core, cutlist, planer (plus any product you add later)
Missing a schema here means every read of it silently returns null. When you add a product, update both config.toml and this cloud list.
Then set the auth URLs, under Authentication, URL configuration: Site URL is your primary apex, and Redirect URLs must list every product apex as https://yourproduct.com/**. Supabase silently rewrites a redirect it does not recognise back to Site URL, so a missing entry sends one product's confirmation links to another product's site. supabase/config.toml covers local only.
This is the step that gets missed, and it fails quietly: sign-in appears to work, the user just arrives on the wrong product with no error and nothing in the logs. src/lib/auth/actions.ts builds redirectTo from requestOrigin(), so the request always names the host the user was actually on, and Supabase always substitutes Site URL when that host is not listed. Redo this list every time you add a product, and prove it by clicking Google sign-in on the new host in a private window. An HTTP 200 proves nothing about it.
Google sign-in
One Google Cloud project and one consent screen serve every product, because the brand travels in the state parameter rather than in the redirect (see running many products). The setup is three dashboard steps, and none of it lives in this repo, which is why nothing in .env.example reminds you:
- Google Cloud console, APIs and services, OAuth consent screen: add every product apex under Authorized domains.
- Same console, Credentials, your OAuth 2.0 Web application client: add
https://<your-project-ref>.supabase.co/auth/v1/callbackas an Authorized redirect URI. That is the Supabase callback, not your app's/auth/callback, and it is one URI for every product no matter how many you add. - Supabase dashboard, Authentication, Providers, Google: enable it and paste the client id and client secret.
There is no GOOGLE_CLIENT_ID env var. The credential lives in Supabase.
2. Ongoing migrations deploy via CI, not by hand
The db push above is the one-time bootstrap. After that, .github/workflows/deploy-migrations.yml pushes pending migrations to prod on every push to main that touches supabase/migrations/**. It is the single path that mutates prod schema; pnpm supabase:push and pnpm db:push are hard-blocked on purpose.
One-time setup: a GitHub repo secret named SUPABASE_DB_URL containing the SESSION-pooler connection string (port 5432, not the 6543 transaction pooler; migrations need session-level advisory locks).
The compensating local gate: run pnpm db:reset && pnpm test:integration before merging migration work. A green run stamps .integration-green, and the pre-push hook refuses a migration-touching push to main without a matching stamp. Keep migrations backward-compatible (expand/contract): the app and the DB deploy on independent clocks, so never drop or rename a column in the same release as the code that stops using it.
3. Create the Vercel project
vercel link
Set the production env. The required core is three variables (the tenant resolver needs the secret key, so it is not optional):
vercel env add NEXT_PUBLIC_SUPABASE_URL production # https://<ref>.supabase.co
vercel env add NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY production
vercel env add SUPABASE_SECRET_KEY production
Everything else degrades gracefully when unset; add blocks as you turn features on (Stripe, Resend, the AI gateway, PostHog, Upstash, CRON_SECRET for the scheduled tasks). .env.example documents every variable and what turns off without it. Two build-env notes: POSTHOG_PERSONAL_API_KEY plus POSTHOG_PROJECT_ID in the build env enable symbolicated error stacks, and CRON_SECRET must be set for the vercel.json crons to be accepted by the fail-closed cron route.
4. Gate, then deploy
pnpm ci # lint + typecheck + tests + build, same as CI
vercel --prod
The build does not suppress type or lint errors; a break fails the deploy for all tenants. Keep it that way.
5. Point real domains at tenants
For each product apex (already seeded if you edited the seed migration in step 0):
vercel domains add yourproduct.com
vercel domains inspect yourproduct.com # prints the DNS records to set
Set the records at your registrar and wait for propagation plus TLS. A host that is not in core.domains yet needs a row (service-role SQL or a migration):
insert into core.domains (host, org_id, product, brand_id)
values ('yourproduct.com', '<org-uuid>', '<product>', '<brand-uuid>')
on conflict (host) do nothing;
Adding a product domain later is exactly this: no redeploy.
6. Smoke it
HOST=yourproduct.com
curl -sI https://$HOST/ # expect 200
curl -s https://$HOST/ | grep 'data-product' # expect your product slug
Pre-DNS, smoke the deployment by Host header:
curl -s https://<deployment>.vercel.app/ -H "Host: yourproduct.com" -o /dev/null -w '%{http_code}\n'
The synthetic-checks cron then does this continuously for every roster apex: 200, correct data-product, brand tokens injected, sitemap served. Failures email the platform admins.
Rollback is vercel rollback (instant, previous deployment). Tenant data is unaffected; the database deploys on its own clock.
Running the worked products in public
One env dial keeps a public deployment safe without a redeploy. SIGNUPS_CLOSED closes new signups per product (comma-separated slugs, or all). The OAuth arm is enforced server-side in /auth/callback, which deletes the just-created user. The password arm is currently only enforced in the sign-in card, because the card signs up through the browser Supabase client rather than the server action, so treat it as a UI switch plus an OAuth gate. Existing users keep signing in either way.
There is no spend-ceiling env var. Nothing in the chassis reads a budget variable or refuses a call because a month got expensive. logUsage measures spend into core.ai_usage_events; nothing reads that ledger back. Plan accordingly.
What actually bounds AI spend is the per-org quota a product enforces for itself. Planer allows 20 refine passes per org per month, counted and inserted under one advisory lock inside the planer.claim_pass RPC, and it fails closed. Two consequences worth stating plainly:
- An org is provisioned per signup, so total exposure is signups multiplied by the cap.
SIGNUPS_CLOSEDis therefore the real spend control, not a moderation tool. - The quota is not automatic. Every AI call site needs its own gate. Planer's own
suggestTitlespends outside the pass quota, bounded only by theairate-limit preset, and that is the shape of hole to look for in your own products.
If you want a hard ceiling, build it where the money is already measured: sum cost_usd from core.ai_usage_events for the product and the current month, and refuse before you claim the quota. The ledger is written on every call; the check is the part the kit does not ship.