Tenancy lives in the database, not the application code. One template repository provisions every client site (repo creation, secret and variable copying, tier-adjusted feature flags, infrastructure, DNS), and every site that comes out of it shares one Postgres project instead of getting its own. If a request reaches the database with the wrong tenant claim in its token, row-level security refuses it before the query runs, regardless of what the application layer thinks it’s allowed to do.
The design decision
I could have given each client its own database, its own Supabase project, its own migration history to keep in sync. Instead every client site ships with a per-tenant signed JWT baked into its environment at provisioning time, and a single RLS policy on each table scopes reads and writes to the tenant claim inside that token. The anonymous client is already tenant-scoped the moment it connects. There’s no header to forget and no application-level filter that a future change could quietly drop. Four design skins currently share this same data layer and provisioning path.
Provisioning as a pipeline, not a runbook
A create-client workflow does the repo creation, copies secrets and variables from the template, patches the tier-specific config, and hands off to infrastructure setup: Amplify and ACM through Terraform, one client app per site. DNS is deliberately the last step: a separate cutover workflow only creates the Cloudflare CNAME after the ACM certificate has verified, which rules out the half-provisioned custom domain that a single combined step would risk.
Keeping the fleet in sync
The harder problem isn’t provisioning a site once. It’s changing the template after ten sites already exist from it. Every push to the template’s main branch diffs itself against each live client and opens a pull request against that client’s branch rather than pushing directly, so a template change is reviewable per site before it lands, and a client-owned file is never silently overwritten by the sync.