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_configrow + branding Storage bucket in the client’s OWN Supabase project; build-time fetch viascripts/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: thespecs/id-95-per-client-topology/PRODUCT.md + TECH.md (the ratified model — PI-10/PI-11/PI-12/PI-17/PI-20), theid-69-client-corpus-onboardingspec (corpus onboarding), theper-client-release-pins.mdrunbook (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.
1. What a “client app deploy” is
Section titled “1. What a “client app deploy” is”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).
| Knob | Where set | Status | Notes |
|---|---|---|---|
NEXT_PUBLIC_CLIENT_ID | Vercel 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_URL | Vercel 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 domain | client 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 wiring | Supabase 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 domain | Vercel project → Domains | [manual] | e.g. kh.<client>.<tld>. |
Why data, not schema or repo files.
tenant_configandsignup_policyare 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 — seespecs/id-95-per-client-topology/PRODUCT.mdPI-7/PI-10 andTECH.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 viaALTER DATABASE postgres SET app.allowed_signup_domain— both are DEAD: the overlay was never built, and managed Supabase rejects the GUC withERROR: 42501: permission denied to set parameter(no superuser in the managed SQL editor).
3. Deploy steps
Section titled “3. Deploy steps”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).
3b. Push the canonical schema [manual]
Section titled “3b. Push the canonical schema [manual]”Apply the full canonical migration set to the new project via per-client
supabase db push:
supabase link --project-ref <new-ref>cat supabase/.temp/project-ref # CONFIRM you are linked to the new projectsupabase db push # full canonical migration setThis 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-refdrift is the classic hazard. Alwayscat supabase/.temp/project-refand confirm the ref beforedb 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:
# 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> --applyRun 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).
3c. Set the Vercel project env [manual]
Section titled “3c. Set the Vercel project env [manual]”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:
# 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 --applyThe 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:
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.ukThis 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
corpusbucket provisioned viabun 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 livestorage.objectspolicies 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:
supabase link --project-ref <ref>supabase seed buckets --linkedsupabase 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-branding → scripts/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 thetenant_configrow and downloadsbranding/<id>/*via the service-role key, writinglib/branding/clients/<id>.json+public/clients/<id>/*— which the existinggenerate:brandingglob, loader, and contrast/schema validation then consume unchanged. FAIL-CLOSED: a client build whose fetch finds notenant_configbranding 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 resolvesdefaultbranding 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.
3A.1. The staging tier’s three surfaces
Section titled “3A.1. The staging tier’s three surfaces”| Surface | Value | Notes |
|---|---|---|
| Branch | shared staging branch (continuous, deploy-only) | Same staging branch the platform CI gates; never pinned. The client’s staging Vercel env tracks it. |
| Supabase | the 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). |
| Vercel | the client’s OWN Vercel preview env on the client Vercel project | URL 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 =
stagingbranch build × client staging Supabase. It is NOT the platform/shared staging app (canonical-git-staging-<team>.vercel.app, platform DBrbwqewalexrzgxtvcqrh), which is the platform’s own staging surface. Two different builds of the samestagingbranch, 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 tier | PIPELINE_RUN_WEBHOOK_URL target | Auth | Lands run-record in |
|---|---|---|---|
| Client staging pipeline | the client’s OWN staging Vercel preview: https://canonical-<client>-git-staging-<team>.vercel.app/api/internal/pipeline-runs/record | the 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 pipeline | the client’s OWN production domain: https://<client-domain>/api/internal/pipeline-runs/record | the 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/recordroute — IN ADDITION to the route’s ownBearer ${PIPELINE_TRIGGER_SECRET}, which is enforced in BOTH tiers ({127.18} — the legacyCRON_SECRETno longer authenticates on this route; Vercel-cron/api/cron/*routes stay onCRON_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).
3A.3. Stand-up delta vs the prod steps
Section titled “3A.3. Stand-up delta vs the prod steps”The staging tier reuses §3 with these substitutions:
- Provision / select the client’s staging Supabase project;
db pushthe canonical set +set-data-api-exposure.ts --applyagainst the staging ref (§3b / §3b.5). - On the client Vercel project, the preview env (Git → staging branch)
carries the client staging Supabase URL + keys +
NEXT_PUBLIC_APP_URLset to the staging preview URL (§3c, preview scope). - Seed
tenant_config+signup_policy+ branding against the staging ref (§3d) and wire the auth hook on the staging project (§3e). - Configure the client staging pipeline’s
PIPELINE_RUN_WEBHOOK_URL+ protection-bypass token per the table in §3A.2. - Confirm parity against
/runbooks/staging-refresh/§4.3 (application_types= 6; api anon-exposure = 1 of 179) on the client staging Supabase.
4. Verification checklist
Section titled “4. Verification checklist”- 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_policyrow is rejected (fail-closed) — then seed the row (3d) and re-test. - A client build with no
tenant_configbranding 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)
-
corpusbucket exists and its threestorage.objectsRLS policies are live (pg_policies); authed editor PUT succeeds, anon PUT rejected (3d.5, ID-138.18).
5. Rollback
Section titled “5. Rollback”- Branding: re-run the operator CLI (3d) to re-seed/correct the
tenant_configrow + bucket assets, then redeploy; or redeploy a prior build. The fetch is fail-closed, so a misconfiguredtenant_configfails 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_policyrow (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)”- Provision the client Supabase project in the agreed region (3a).
supabase link+ confirm.temp/project-ref+supabase db pushthe canonical migration set (3b); then restrict the Data API to theapischema viaset-data-api-exposure.ts --apply(3b.5).- Create the Vercel project (
canonical-<client>); set env incl.NEXT_PUBLIC_CLIENT_ID, Supabase URL + keys,NEXT_PUBLIC_APP_URL(3c). - Seed
tenant_config+signup_policy+ branding bucket viaseed-tenant-from-bundle.ts— dry-run, confirm ref, then--apply(3d). - Seed the storage buckets —
supabase seed buckets --linked(declared inconfig.toml [storage.buckets.*]; run on Platform projects too, not just client ones); confirm the corpus RLS policies landed with the migration set (3d.5). - Wire the Before-User-Created auth hook to the generic function (3e).
- Add the custom domain; deploy — the build pulls branding via
fetch-client-branding.ts, fail-closed (3f). - Run the verification checklist (4).
- 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:
# 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.tsThe 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”.