Skip to content

Client app deploy runbook (per-client branded instance)

Client app deploy runbook (per-client branded instance)

Section titled “Client app deploy runbook (per-client branded instance)”

Status: Rewritten to the ratified ID-95 per-client topology (tenant_config row + branding Storage bucket in the client’s OWN Supabase project; build-time fetch via scripts/fetch-client-branding.ts). The earlier docs-site branding overlay and the Postgres-GUC signup design (app.allowed_signup_domain) are DEAD — neither was ever built; both are removed from this runbook. Owner: platform (Liam). Audience: whoever stands up a new client instance. Pair with: the specs/id-95-per-client-topology/ PRODUCT.md + TECH.md (the ratified model — PI-10/PI-11/PI-12/PI-17/PI-20), the id-69-client-corpus-onboarding spec (corpus onboarding), the per-client-release-pins.md runbook (release version pinning + per-client upgrade gating), and /runbooks/github-environments/ (Vercel/Supabase secrets).

Status legend in tables: [live] in use · [manual] operator action each deploy.

Each client runs its own isolated product app: a dedicated managed Supabase project plus a dedicated Vercel project (canonical-<client>) serving a client-branded build at the client’s domain. Both are built off the single public application repo — adding a client adds a Vercel project + a Supabase project + per-project config, never a per-client code path or branch (ID-95 PI-1). There is no cross-client data path by construction: isolation is physical (separate Supabase instances), not logical (PI-6). The platform retains operational ownership; the per-client topology + ownership boundary is the subject of specs/id-95-per-client-topology/ — treat this runbook as the operational companion to that architecture.

The first client is Phew (kh.phew.org.uk; its Vercel project is canonical-phew). The platform runs its OWN dev/demo deploy (canonical-platform, interim domain kh.aisolutionhub.co.uk, NEXT_PUBLIC_CLIENT_ID=default, its own dedicated Supabase project) which never co-tenants with client data (PI-3).

2. Where per-client config lives (the canonical model)

Section titled “2. Where per-client config lives (the canonical model)”

Everything client-instance-specific lives in exactly two places — per-project env (build-time) and data IN the client’s own Supabase project. NOTHING client-identifying is committed to any repo (PI-7/PI-10/PI-12).

KnobWhere setStatusNotes
NEXT_PUBLIC_CLIENT_IDVercel project env (build-time)[live]Selects the branding map entry; REQUIRED — a missing/default value yields neutral branding. phew for Phew.
Supabase URL + keys (incl. SUPABASE_SERVICE_ROLE_KEY)Vercel project env[manual]The service-role key already present for runtime is what the build-time branding fetch uses (step 4) — no new credential surface. Per /runbooks/github-environments/.
NEXT_PUBLIC_APP_URL / APP_URLVercel project env[manual]The client’s own domain. Self-URLs derive from this — no hardcoded deploy-target host (PI-13).
Branding (JSON document + assets)client Supabase: public.tenant_config row (singleton) + private branding Storage bucket[live]Seeded via the operator CLI (step 2-seed) and pulled at build by fetch-client-branding.ts (step 4). NOT a docs-site overlay, NOT a build-time GUC.
Sign-up domainclient Supabase: public.signup_policy.allowed_domain row (singleton)[manual]The auth hook reads it at call time, fail-closed when no row/empty. Seeded alongside branding by the operator CLI (step 2-seed).
Auth hook wiringSupabase dashboard → Auth → Hooks → Before User Created[manual]Point at hook_restrict_signup_to_allowed_domain (generic fn from the migration set; dashboard wiring is per-instance).
Custom domainVercel project → Domains[manual]e.g. kh.<client>.<tld>.

Why data, not schema or repo files. tenant_config and signup_policy are GENERIC tables in the canonical migration set (no client literal); the per-client values are data rows set out-of-band per instance. This is the “per-client value as data, not schema, not repo” template — see specs/id-95-per-client-topology/PRODUCT.md PI-7/PI-10 and TECH.md §T-A. The old design (a) committed branding files to the public repo and copied them in via a docs-site overlay at build, and (b) set the signup domain via ALTER DATABASE postgres SET app.allowed_signup_domain — both are DEAD: the overlay was never built, and managed Supabase rejects the GUC with ERROR: 42501: permission denied to set parameter (no superuser in the managed SQL editor).

3a. Provision the client Supabase project [manual]

Section titled “3a. Provision the client Supabase project [manual]”

Create a dedicated managed Supabase project for the client, in the contract-agreed region (UK default eu-west-2 London; region is IMMUTABLE after creation — PI-5).

Apply the full canonical migration set to the new project via per-client supabase db push:

Terminal window
supabase link --project-ref <new-ref>
cat supabase/.temp/project-ref # CONFIRM you are linked to the new project
supabase db push # full canonical migration set

This lands the schema, the generic sign-up hook hook_restrict_signup_to_allowed_domain, the public.signup_policy config table, and the public.tenant_config config table the branding fetch reads (all generic, no client literal — PI-7/PI-9). The migration set is identical for every client and the platform; there is one canonical set, never a per-client split (RESEARCH §4).

.temp/project-ref drift is the classic hazard. Always cat supabase/.temp/project-ref and confirm the ref before db push — pushing the canonical set to the wrong project is the drift failure mode flagged in CLAUDE.md and ID-95 TECH §Risks.

3b.5. Restrict the Data API to the api schema [manual]

Section titled “3b.5. Restrict the Data API to the api schema [manual]”

The canonical migration set creates the api schema (PostgREST schema isolation, ID-115: security_invoker views + INVOKER RPC entrypoints), but a freshly-created managed project still exposes public (+ graphql_public) on its Data API by default. Narrow the exposed schemas to api only — the fail-closed structural boundary (public unreachable → hard PGRST106, not per-grant vigilance) — with the idempotent operator CLI, so there is no manual dashboard step:

Terminal window
# DRY-RUN first — prints current vs target db_schema, writes nothing:
SUPABASE_ACCESS_TOKEN=<mgmt-api-token> \
bun run scripts/set-data-api-exposure.ts --ref=<new-ref>
# APPLY once the printed ref is correct:
SUPABASE_ACCESS_TOKEN=<mgmt-api-token> \
bun run scripts/set-data-api-exposure.ts --ref=<new-ref> --apply

Run this after db push (3b) so the api objects exist before exposure is narrowed to them (narrowing first would 404 every read). The app already routes every supabase-js client to api at runtime (lib/supabase/schema.ts DB_OPTION, unconditional) — no app change needed. Supabase preview branches inherit this automatically from config.toml [api] schemas=["api"]; this CLI is only needed for standalone managed projects. Idempotent + reversible (re-expose by PATCHing db_schema back to public,graphql_public,api).

On the client’s Vercel project (canonical-<client>): set NEXT_PUBLIC_CLIENT_ID=<id>, the client Supabase URL + anon key + SUPABASE_SERVICE_ROLE_KEY, and NEXT_PUBLIC_APP_URL/APP_URL (the client domain). See /runbooks/github-environments/. The service-role key set here for runtime is the same key the build-time branding fetch (3e) uses — no extra credential.

3d. Seed the tenant config + signup policy + branding bucket [manual]

Section titled “3d. Seed the tenant config + signup policy + branding bucket [manual]”

Seed the per-instance data — the tenant_config row, the signup_policy row, and the branding Storage bucket assets — in ONE idempotent operation using the operator CLI scripts/seed-tenant-from-bundle.ts. It discovers the single non-default client id by globbing lib/branding/clients/*.json (minus default.json), assembles the manifest from that tracked bundle (the <id>.json config document + public/clients/<id>/* assets), and applies it to the project selected by SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY.

It is dry-run by default — it prints the resolved target project ref, client id, signup domain, and asset list, and writes nothing until you pass --apply:

Terminal window
# 1) DRY-RUN first — confirm the resolved target ref is the client project:
SUPABASE_URL=<client-url> SUPABASE_SERVICE_ROLE_KEY=<client-svc-key> \
bun run scripts/seed-tenant-from-bundle.ts
# 2) APPLY once the printed ref is correct:
SUPABASE_URL=<client-url> SUPABASE_SERVICE_ROLE_KEY=<client-svc-key> \
bun run scripts/seed-tenant-from-bundle.ts --apply

The CLI preserves an existing signup_policy.allowed_domain (it reads the target first and re-applies it verbatim; it refuses to guess if no row exists). For a brand-new project with no signup row yet, set the domain explicitly:

Terminal window
SUPABASE_URL=<client-url> SUPABASE_SERVICE_ROLE_KEY=<client-svc-key> \
bun run scripts/seed-tenant-from-bundle.ts --apply --allowed-domain=<client-domain>
# e.g. --allowed-domain=phew.org.uk

This CLI was proven on staging + prod this session. It is the operator replay half of the per-instance re-seed manifest (PI-20): the same three artefacts (signup_policy row + tenant_config row + branding bucket objects) must be re-seeded after ANY DB recut/cutover that produces a fresh data state — not just the signup row.

No client literal in the command. The client id is discovered from the branding bundle, never passed on the command line, so the invocation is denylist-safe (PI-7). The bundle (lib/branding/clients/<id>.json + public/clients/<id>/*) is the current durable master; post-untrack ({68.22}) it moves to the external operator archive with the same manifest shape (the {95.14} restore path).

3d.5. Seed the Storage buckets + corpus RLS [manual] (ID-138, revised S493)

Section titled “3d.5. Seed the Storage buckets + corpus RLS [manual] (ID-138, revised S493)”

CHANGED S493 — the previous instruction here no longer works, and its scoping was the root cause of a real gap. This step used to say “each client project needs the private corpus bucket provisioned via bun scripts/provision-corpus-bucket.ts”. That script has been deleted (canonical c5c90a5d) — it was wired into nothing and had never once been run, so the command would fail if you tried it. Worse, scoping the bucket to client projects is why Platform staging and prod never got it, while the RLS half rode the migration chain and DID reach them: both databases spent months with three live storage.objects policies guarding a bucket that did not exist.

Buckets are now declared, not scripted — [storage.buckets.*] in supabase/config.toml. Seed them on every project (Platform and client alike), not just client ones:

Terminal window
supabase link --project-ref <ref>
supabase seed buckets --linked

supabase seed buckets is the Storage-API command that reads [storage.buckets.*]. Note that supabase config push does NOT create buckets — its storage leg is v1UpdateStorageConfig (PATCH /v1/projects/{ref}/config/storage), which carries only project-level fileSizeLimit/features/external. Do not substitute one for the other.

Declared today: corpus, documents, templates, tender-documents, onprem-backups. NOT declared: branding, which is still created by scripts/reseed-tenant-instance.ts on client projects only.

The storage.objects RLS policies gating admin/editor writes to that bucket arrive automatically with the canonical migration chain in 3b (20260705110000_id138_corpus_bucket_rls.sql) — no separate step, EXCEPT that CREATE POLICY on storage.objects can be rejected with must be owner of table objects on hosted Supabase; if db push rejects that migration, apply its CREATE POLICY statements manually via the Dashboard SQL Editor. (Applied cleanly via db push on Platform staging + prod, S449, so the fallback is precautionary.)

Verification probe (before first live write-back or upload on the project): pg_policies lists the three corpus policies (INSERT/UPDATE/SELECT, roles {authenticated}, admin/editor-scoped); an authed editor PUT to the bucket succeeds; an anon PUT is rejected.

3e. Wire the Before-User-Created auth hook [manual]

Section titled “3e. Wire the Before-User-Created auth hook [manual]”

In the client’s Supabase dashboard → Auth → Hooks → Before User Created, point the hook at the generic function hook_restrict_signup_to_allowed_domain (shipped by the migration set; dashboard wiring is per-instance and not captured in migrations). The hook is fail-closed: until the signup_policy row exists (seeded in 3d) every sign-up is rejected.

3f. Add the custom domain + deploy [manual]

Section titled “3f. Add the custom domain + deploy [manual]”

Add the client domain in the Vercel project, then deploy. At build, the prebuild chain runs generate:client-brandingscripts/fetch-client-branding.ts BEFORE generate:branding (package.json prebuild and build:vercel). That fetch:

  • Client build (NEXT_PUBLIC_CLIENT_ID != default, URL + service-role key present): reads the tenant_config row and downloads branding/<id>/* via the service-role key, writing lib/branding/clients/<id>.json + public/clients/<id>/* — which the existing generate:branding glob, loader, and contrast/schema validation then consume unchanged. FAIL-CLOSED: a client build whose fetch finds no tenant_config branding document FAILS the build (never silently ships a default-branded client deploy).
  • Control build (NEXT_PUBLIC_CLIENT_ID=default, or creds absent): the fetch no-ops and the build resolves default branding and stays green.

Then run the verification checklist below.

3A. Client STAGING tier (S408 / ID-127.15)

Section titled “3A. Client STAGING tier (S408 / ID-127.15)”

§3 stands up a client’s production instance. Per the staging+prod model ratified in S408 (specs/id-127-platform-pipeline/AMENDMENT-staging-prod-two-server.md), each client also gets a staging tier that proves a release before the production repoint (/runbooks/per-client-release-pins/ §4A). The staging tier mirrors the prod tier’s isolation: a client’s staging instance NEVER shares data with the platform/shared staging app, and NEVER with the client’s own prod.

SurfaceValueNotes
Branchshared staging branch (continuous, deploy-only)Same staging branch the platform CI gates; never pinned. The client’s staging Vercel env tracks it.
Supabasethe client’s OWN staging Supabase project (or persistent staging branch)Physically separate from the client’s prod Supabase AND from the platform staging branch (rbwqewalexrzgxtvcqrh). Push the canonical migration set + narrow the Data API to api exactly as §3b/§3b.5 (against the staging ref).
Vercelthe client’s OWN Vercel preview env on the client Vercel projectURL shape canonical-<client>-git-staging-<team>.vercel.app; env wired to the client staging Supabase URL + keys (NOT prod).

Isolation restated. The client staging instance = staging branch build × client staging Supabase. It is NOT the platform/shared staging app (canonical-git-staging-<team>.vercel.app, platform DB rbwqewalexrzgxtvcqrh), which is the platform’s own staging surface. Two different builds of the same staging branch, each pointed at its own DB.

3A.2. Per-client pipeline-run webhook model (the load-bearing wiring)

Section titled “3A.2. Per-client pipeline-run webhook model (the load-bearing wiring)”

A client runs its own ingestion pipeline per tier (Server A in the two-server split — see the amendment), and each tier’s pipeline reports its run records to that tier’s own app, so the content it ingests and the run-record that describes the ingestion land in the SAME DB (no split-brain between content and run history):

Pipeline tierPIPELINE_RUN_WEBHOOK_URL targetAuthLands run-record in
Client staging pipelinethe client’s OWN staging Vercel preview: https://canonical-<client>-git-staging-<team>.vercel.app/api/internal/pipeline-runs/recordthe route’s Bearer ${PIPELINE_TRIGGER_SECRET} ({127.18} — verifyPipelineTriggerAuth, lib/cron-auth.ts) PLUS the Vercel protection-bypass token (preview deployments sit behind Deployment Protection; the pipeline presents the bypass token to reach the route at all). Two layers, not two methods.client staging Supabase (the same DB the staging content lands in)
Client prod pipelinethe client’s OWN production domain: https://<client-domain>/api/internal/pipeline-runs/recordthe route’s Bearer ${PIPELINE_TRIGGER_SECRET} — same bearer as staging; no bypass token (no preview protection on the production domain)client prod Supabase (the same DB the prod content lands in)

NOT the shared/platform staging app. A client staging pipeline must report to the client’s OWN staging preview — NOT to canonical-git-staging-<team>.vercel.app (that is the platform’s staging app, backed by the platform DB, and is the target for the platform staging pipeline only per the amendment OQ-3). Pointing a client staging pipeline at the platform staging app would split-brain: client content in the client staging DB, run-record in the platform DB. The webhook target and the content destination MUST resolve to the same client tier.

Why a protection-bypass token (staging only). Vercel preview deployments are protected by default (auth wall). The ingestion pipeline is a server-side caller with no Vercel session, so it presents a Vercel protection-bypass token to reach the preview’s /api/internal/pipeline-runs/record route — IN ADDITION to the route’s own Bearer ${PIPELINE_TRIGGER_SECRET}, which is enforced in BOTH tiers ({127.18} — the legacy CRON_SECRET no longer authenticates on this route; Vercel-cron /api/cron/* routes stay on CRON_SECRET). Production uses the client’s real domain (no preview protection), so the bearer alone suffices — staging has one extra lock, not a different one (reference/deployment-architecture.md §4).

The staging tier reuses §3 with these substitutions:

  1. Provision / select the client’s staging Supabase project; db push the canonical set + set-data-api-exposure.ts --apply against the staging ref (§3b / §3b.5).
  2. On the client Vercel project, the preview env (Git → staging branch) carries the client staging Supabase URL + keys + NEXT_PUBLIC_APP_URL set to the staging preview URL (§3c, preview scope).
  3. Seed tenant_config + signup_policy + branding against the staging ref (§3d) and wire the auth hook on the staging project (§3e).
  4. Configure the client staging pipeline’s PIPELINE_RUN_WEBHOOK_URL + protection-bypass token per the table in §3A.2.
  5. Confirm parity against /runbooks/staging-refresh/ §4.3 (application_types = 6; api anon-exposure = 1 of 179) on the client staging Supabase.
  • Client site loads at its domain with client branding (logo, favicon, colours hydrated from tenant_config) — not the neutral default. (PI-11)
  • Sign-up with an allowed-domain address succeeds; a different domain is rejected with the 403 message. (PI-20)
  • Sign-up with no signup_policy row is rejected (fail-closed) — then seed the row (3d) and re-test.
  • A client build with no tenant_config branding document FAILS the build (fail-closed branding fetch — confirms a default-branded client deploy can never ship silently). (PI-11)
  • No other client’s assets/data are reachable (separate Supabase + Vercel project — physical isolation). (PI-6)
  • corpus bucket exists and its three storage.objects RLS policies are live (pg_policies); authed editor PUT succeeds, anon PUT rejected (3d.5, ID-138.18).
  • Branding: re-run the operator CLI (3d) to re-seed/correct the tenant_config row + bucket assets, then redeploy; or redeploy a prior build. The fetch is fail-closed, so a misconfigured tenant_config fails the build rather than shipping wrong branding.
  • Sign-up hook: repoint the dashboard Before-User-Created hook to the previous function, or clear/correct the public.signup_policy row (UPDATE public.signup_policy SET allowed_domain = NULL; re-closes the gate).

6. New-client checklist (the repeatable process)

Section titled “6. New-client checklist (the repeatable process)”
  1. Provision the client Supabase project in the agreed region (3a).
  2. supabase link + confirm .temp/project-ref + supabase db push the canonical migration set (3b); then restrict the Data API to the api schema via set-data-api-exposure.ts --apply (3b.5).
  3. Create the Vercel project (canonical-<client>); set env incl. NEXT_PUBLIC_CLIENT_ID, Supabase URL + keys, NEXT_PUBLIC_APP_URL (3c).
  4. Seed tenant_config + signup_policy + branding bucket via seed-tenant-from-bundle.ts — dry-run, confirm ref, then --apply (3d).
  5. Seed the storage buckets — supabase seed buckets --linked (declared in config.toml [storage.buckets.*]; run on Platform projects too, not just client ones); confirm the corpus RLS policies landed with the migration set (3d.5).
  6. Wire the Before-User-Created auth hook to the generic function (3e).
  7. Add the custom domain; deploy — the build pulls branding via fetch-client-branding.ts, fail-closed (3f).
  8. Run the verification checklist (4).
  9. Propagate platform-owned canonical content to the new client DB (§7).

7. Canonical content propagation (after stand-up)

Section titled “7. Canonical content propagation (after stand-up)”

Platform-owned canonical knowledge (reference content, taxonomy, catalogue requirements) reaches the isolated client DB by a one-way, additive platform → client push — never reading client data back (PI-18/PI-19). Run the fan-out worker scripts/propagate-canonical-content.ts: it reads the platform-curated baseline from a SOURCE DB and upserts-on-stable-key (with tombstone deletes) into one or more TARGET client DBs, recording applied state in content_propagation_version.

The target list comes from the gitignored out-of-band catalog (scripts/.propagation-catalog.json, which holds service-role DSNs and must NEVER be committed), or from an explicit --target-url/--target-key pair. It is dry-run-capable; fail-loud on source errors:

Terminal window
# DRY-RUN against a single explicit target (writes nothing):
SOURCE_SUPABASE_URL=<platform-url> SOURCE_SUPABASE_SERVICE_ROLE_KEY=<platform-svc-key> \
bun run scripts/propagate-canonical-content.ts \
--target-url=<client-url> --target-key=<client-svc-key> --dry-run
# APPLY via the gitignored catalog (all listed targets):
SOURCE_SUPABASE_URL=<platform-url> SOURCE_SUPABASE_SERVICE_ROLE_KEY=<platform-svc-key> \
bun run scripts/propagate-canonical-content.ts

The same one-way mechanism serves both eras (DB-direct service-role upsert at Tier 1; GitOps-delivered versioned payload once a client DB sits in its own VPC at Tier 3). See specs/id-95-per-client-topology/ PRODUCT.md PI-18/PI-19 and PLAN.md §“D-2 PI-18 worker mechanism”.