multi-tenant-architecture
Designs tenant isolation, hostname routing, custom-domain lifecycle, and plan limits on Cloudflare or Vercel. Use when asked to "isolate tenant data", "support custom domains", "build a white-label platform", or assess PSL registration. For general module structure use codebase-a
Install
npx skills add https://github.com/mblode/agent-skills/tree/main/skills/multi-tenant-architecture
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install mblode-agent-skills@llmmart
git clone https://github.com/mblode/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole mblode/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Multi-Tenant Platform Architecture (Cloudflare or Vercel)
- IS: platform choice, domain strategy and PSL, tenant identification, compute and data isolation, hostname routing, tenant context propagation, custom domains and SSL, per-tenant static files, and mapping platform limits to plans.
- IS NOT: general folder structure or module contracts (use
codebase-architecture), scaffolding a new repo (usescaffold-nextjs), or the content of per-tenant SEO files once routing serves them dynamically: sitemap entries, canonical URLs, structured data, indexing policy (useseo).
Contents
- Platform dispatch (decide first)
- Reference files
- Workflow (order matters)
- Gotchas
- Output schema
- Pre-commit checklist
- Related skills
Platform dispatch (decide first)
| Signals | Platform | Model |
|---|---|---|
| Tenants upload or generate their own code; code-level isolation; edge compute on KV, D1, Durable Objects, R2 | Cloudflare | Dispatch Worker in front of a dispatch namespace of per-tenant Workers; Cloudflare for SaaS for custom hostnames |
| Every tenant runs the same Next.js codebase and differs by content, branding, and plan; ISR, Server Components, Vercel deploys | Vercel | One deployment; proxy.ts resolves the tenant from the hostname; wildcard plus custom domains on the project |
- Pick one platform per product. Fronting a Vercel app with a Cloudflare proxy doubles the TLS and redirect layers and is the usual cause of redirect loops and failed certificate issuance.
- Tenants shipping their own code on Vercel is the multi-project model (one Vercel project per tenant, created with the SDK). It follows the Cloudflare row's isolation reasoning; this skill's Vercel references cover the single-deployment model only.
Reference files
| File | Read when |
|---|---|
| cloudflare-platform.md | Cloudflare chosen: dispatch namespaces, routing, Cloudflare for SaaS custom hostnames, isolation modes, KV and D1 (steps 3 to 7) |
| vercel-platform.md | Vercel chosen: proxy.ts resolution, App Router layout, Global Config lookups, per-tenant static files, custom subpaths, local dev (steps 4 to 7) |
| vercel-domains.md | Vercel chosen: SDK domain lifecycle, DNS targets, verification, wildcard nameservers, SSL, troubleshooting (step 7) |
| data-isolation.md | Step 3 on either platform: shared schema with RLS, schema-per-tenant, database-per-tenant, and the Postgres/Supabase/Drizzle policy pattern |
| psl.md | Step 1 when tenants publish content or run code on sibling subdomains: eligibility, submission, interim cookie controls |
| limits-and-quotas.md | Step 8: dated snapshot of Cloudflare, Vercel, and Neon limits to map onto plans |
agents/openai.yaml |
Never during a task: launcher metadata for external runners |
Workflow (order matters)
Copy this checklist to track progress:
Multi-tenant progress:
- [ ] Step 1: Domain strategy and PSL decision
- [ ] Step 2: Tenant identification strategy
- [ ] Step 3: Isolation model (compute and data)
- [ ] Step 4: Deterministic routing
- [ ] Step 5: Tenant context propagation
- [ ] Step 6: Tenant config and least-privilege bindings
- [ ] Step 7: Custom domains and per-tenant static files
- [ ] Step 8: Limits mapped to plans, evidence captured
- Choose the domain strategy
- Put tenant workloads on a dedicated registrable domain (
acme.appfor tenants,acme.comfor brand). One phishing tenant onx.acme.computs the whole domain on blocklists, and a tenant cookie withDomain=acme.comreaches your dashboard. - Keep the dashboard and auth on a different apex from tenant subdomains (
app.acme.comfor the console,*.acme.appfor tenants). - If tenants publish content or run code on sibling subdomains, submit the label directly above the tenant name (
acme.app, orsites.acme.appfor<tenant>.sites.acme.app) to the PSL and start now: there is no SLA. Tenant-owned custom domains need no PSL entry. Otherwise recordNo PSLwith the reason.
- Choose tenant identification (one primary; custom domain as the upgrade path)
- Subdomain
tenant.acme.app: wildcard DNS plus wildcard certificate. The default. - Custom domain
tenant.com: the tenant CNAMEs to you. Paying tenants; reputation shifts to them; needs the onboarding lifecycle in step 7. - Path
acme.app/tenant: no per-tenant DNS or certificates, but no cookie isolation and no branding. Choose it only when tenants will never get a hostname.
- Define the isolation model
- Compute, Cloudflare: one dispatch namespace in untrusted mode; per-invocation
cpuMsandsubRequestslimits per plan; an outbound Worker if tenant code may call the internet. - Compute, Vercel: one deployment, tenant code never executes. If tenants must ship code, move to Vercel multi-project or Cloudflare rather than sandboxing inside the app.
- Data: shared schema with
tenant_idon every tenant-aware table plus RLS is the default; database-per-tenant for regulated or noisy tenants, selectable per plan. See data-isolation.md.
- Route deterministically (tenants never influence routing or see each other)
- Cloudflare: a single
*/*route on the SaaS zone to the dispatch Worker; hostname -> tenant record (KV, D1 on miss) ->env.DISPATCHER.get(script);Worker not found-> 404. - Vercel:
proxy.ts(Next.js 16;middleware.tswithruntime: 'nodejs'on 15) readshost, looks the tenant up in Global Config or the database, rewrites into the tenant segment; unknown hostname -> 404, never the brand site. - Let
/.well-knownthrough before any tenant rewrite. Routerobots.txt,sitemap.xml, andllms.txtinto the tenant segment so they vary per tenant.
- Propagate tenant context from one authority
- Delete every inbound
x-tenant-*header, setx-tenant-id,x-tenant-slug,x-tenant-planfrom the resolved tenant, and forward them on the request (NextResponse.next({ request: { headers } })). Server Components readawait headers(); route handlers readrequest.headers. Cloudflare: the dispatch Worker sets headers or passes parameters beforefetch. - The proxy is routing, not authorization. Server Functions, route handlers, and jobs re-derive the tenant from the session and the data layer enforces it (RLS or
tenant_idpredicates).
- Bind only what the tenant needs
- Cloudflare: each user Worker gets its own bindings (KV namespace, D1 database, R2 prefix); adding a binding is an explicit redeploy. No shared globals.
- Vercel: Global Config holds only
hostname -> { id, slug, plan }; the database is the source of truth and write-through happens when a domain verifies. Feature flags and branding come from the database keyed by tenant id.
- Support custom domains and per-tenant static files
- Lifecycle to design and record: add domain -> show DNS target -> verify ownership -> certificate issued -> mapping activated -> removal or failure path.
- Cloudflare: Cloudflare for SaaS custom hostname on the SaaS zone, proxied fallback origin,
customers.<you>.comCNAME target,httportxtvalidation, pre-validate before DNS cutover. See cloudflare-platform.md. - Vercel:
projectsAddProjectDomain-> DNS values from the project's domain card ->_vercelTXT only if the domain is already on Vercel ->projectsVerifyProjectDomain-> Let's Encrypt HTTP-01. See vercel-domains.md. robots.txt,sitemap.xml,llms.txtare route handlers inside the tenant segment with explicitContent-Type; nothing tenant-specific lives in/public. Their content isseoterritory.
- Surface limits as plans and capture evidence
- Fill the limits-to-plan table from limits-and-quotas.md, re-checking each source URL and dating it; enforce at the routing layer (Cloudflare
limits, Vercel plan header plus server checks). - Nothing long-running in the request path: Cloudflare Queues or Workflows, Vercel background functions or cron.
- Every tenant operation (create tenant, add domain, verify, remove) works over HTTP with the same authority as the UI; if it only works in the dashboard, the platform leaks into the UI.
- Run the evidence commands in the pre-commit checklist and paste results into the output.
Gotchas
- Tenant headers set on the response instead of the request:
NextResponse.next({ headers })sendsx-tenant-idto the browser andheaders()in Server Components reads nothing. UseNextResponse.next({ request: { headers: requestHeaders } }). - Forwarding inbound tenant headers:
curl -H "x-tenant-id: <other>"then serves another tenant's data. Delete or overwritex-tenant-*on every path through the proxy, including paths that skip resolution. - The starter kit matcher
'/((?!api|_next|[\\w-]+\\.\\w+).*)'excludes every root file with an extension, sorobots.txtandsitemap.xmlskip the proxy and every tenant gets the platform's/publiccopy. Match them, and rewrite them into the tenant segment. - Next.js 16 renamed
middleware.tstoproxy.ts(exportproxy, Node.js runtime, aruntimeconfig option throws).npx @next/codemod@canary middleware-to-proxy .migrates. A matcher that excludes a path also skips Server Function POSTs on it, so tenant checks live in the data layer too. - Global Config (formerly Edge Config) key names must match
^[\w-]+$;tenant_acme.comis rejected. Use a collision-free encoding or hash; replacing dots with underscores can map different hostnames to the same key. Writes propagate in up to 10 s, so a "domain connected" screen that reads Global Config right after the write shows stale state; read the database there. The legacy@vercel/edge-configSDK cannot read stores connected after the rename (they createGLOBAL_CONFIG, notEDGE_CONFIG). - RLS is bypassed by superusers and
BYPASSRLSroles; table owners bypass it unlessFORCE ROW LEVEL SECURITYis enabled. An app connecting as the migration role sees every tenant with policies "on". Connect as a separate role, addALTER TABLE ... FORCE ROW LEVEL SECURITY, and test withSET ROLE app_user. SET app.tenant_id = ...outside a transaction on a pooled connection persists into the next request. Useset_config('app.tenant_id', $1, true)inside the transaction; with PgBouncer in transaction mode it is the only safe form.- Wildcard
*.acme.appon Vercel without Vercel nameservers never gets a certificate: DNS-01 needs Vercel to write_acme-challenge. Pointns1.vercel-dns.comandns2.vercel-dns.comfirst and re-add MX records. /.well-knownis reserved on Vercel and cannot be rewritten or redirected; a proxy that rewrites every path into/s/[slug]breaks HTTP-01 and custom-domain certificates never issue. Pass it through first.- Cloudflare for SaaS: the fallback origin must be a proxied record in the SaaS zone; a custom hostname equal to the zone name is unsupported;
_cf-custom-hostnamepre-validation does not work when the customer's zone is also on Cloudflare (O2O, marked bycf-connecting-o2o: 1). - Untrusted dispatch namespaces (default) have no
request.cfand nocaches.default, so tenant code readingrequest.cf.countrythrows. Trusted mode restores them but shares one cache across every tenant Worker in the namespace. - KV is eventually consistent (up to 60 s, negative lookups cached): a hostname added after the dispatch Worker's first lookup 404s for a minute. Fall back to D1 on miss during onboarding.
- PSL rejects domains with under two years of registration left; the
_psl.<suffix>TXT stays in place after merge; browsers ship the list on their own release cycles. Listing also killsDomain=acme.appcookies, including your own cross-subdomain SSO if it lives there. - Starting path-based with custom domains on the roadmap means URL rewrites, cookie changes, and DNS migration later.
- Domain quotas and charges vary by provider and plan. Put current official limits and their access dates in the plan table before setting pricing.
Output schema
Length follows the decisions: drop any section the project does not face rather than filling it.
# Multi-tenant architecture
## Platform decision
- Platform: Cloudflare | Vercel
- Why this platform:
- Rejected platform and reason:
## Domain map
- Brand domain:
- Tenant domain:
- Tenant subdomains:
- Custom domains:
- PSL decision: Submit (suffix, owner, PR link, _psl TXT date) | No PSL (reason)
## Routing matrix
| Host pattern | Resolver | Destination | Unknown tenant behavior |
|---|---|---|---|
## Tenant context flow
- Authority: proxy.ts | dispatch Worker
- Headers set and stripped:
- Server read path:
- Data-layer enforcement:
## Isolation model
- Compute isolation:
- Data isolation (and per-plan variant):
- Config/binding isolation:
## Custom-domain lifecycle
1. DNS target:
2. Ownership verification:
3. Certificate provisioning:
4. Routing activation:
5. Removal/failure path:
## Limits-to-plan table
| Limit | Source URL / access date | Free | Pro | Enterprise | Enforcement point |
|---|---|---:|---:|---:|---|
## Validation evidence
| Check | Command | Expected | Result |
|---|---|---|---|
Pre-commit checklist
- Platform chosen with rationale; multi-project or Cloudflare chosen if tenants ship code
- Tenant workloads off the brand domain; dashboard on a separate apex; PSL decision recorded
- Identification strategy chosen; custom-domain upgrade path defined
- Isolation model defined for compute and data, including the per-plan variant
- Routing tenant-blind: unknown host -> 404;
/.well-knownpasses through; static files vary per tenant - Inbound
x-tenant-*stripped; context set by the proxy or dispatch Worker only; data layer enforces tenant - Custom-domain lifecycle defined end to end, including removal
- Limits table dated from official URLs; enforcement points named; long work off the request path
Evidence commands (run against local or preview; mark N/A with a reason):
| Check | Command | Expected |
|---|---|---|
| Tenant boundary exists in code | rg -n "x-tenant-id\|CREATE POLICY\|FORCE ROW LEVEL SECURITY\|DISPATCHER.get" . |
Hits in proxy or dispatch Worker and in the schema |
| Unknown host is 404 | curl -sI -H "Host: nope.acme.app" <url> |
404 |
| Forged header ignored | curl -s -H "Host: a.acme.app" -H "x-tenant-id: tenant-b" <url>/api/whoami |
Tenant A |
| Static files vary | curl -s -H "Host: a.acme.app" <url>/robots.txt vs -H "Host: b.acme.app" |
Different bodies, Content-Type: text/plain |
| RLS holds for the app role | psql -c "BEGIN; SET LOCAL ROLE app_user; SELECT set_config('app.tenant_id','<t1>',true); SELECT count(*) FROM posts; ROLLBACK;" |
Only tenant t1's rows |
| ACME path reachable | curl -sI -H "Host: tenant.com" <url>/.well-known/acme-challenge/test |
Not a redirect into the tenant segment |
| Limits current | Access date next to each URL in the limits table | Dated within the planning window |
Related skills
codebase-architecture: folder structure, module contracts, and the request-context pipeline for the application itself.scaffold-nextjs: bootstrap the Next.js turborepo before applying these tenancy patterns.seo: content of per-tenantrobots.txt,sitemap.xml,llms.txt, canonical URLs, and structured data once routing serves them.
Maintenance only: evals/evals.json contains regression scenarios for changes to this skill; it does not load during a user task.
Files (agent-skills)
-
agents
-
openai.yaml 296 B
interface: display_name: "Multi-tenant Platform Architecture" short_description: "Plan Cloudflare or Vercel tenant architecture" default_prompt: "Use $multi-tenant-architecture to design a multi-tenant platform, including domains, routing, tenant isolation, and limits-to-pricing mapping."
-
-
evals
-
evals.json 1.5 KB
{ "skill_name": "multi-tenant-architecture", "evals": [ { "id": 1, "prompt": "Design isolation for shared-schema Postgres. The app currently uses the table owner role; tenant id is set once on a pooled connection.", "expected_output": "Specify a restricted app role and transaction-local tenant context.", "files": [], "assertions": [ "Distinguishes table owner from BYPASSRLS", "Uses transaction-local set_config or equivalent", "Includes a cross-tenant read/write check under the app role" ] }, { "id": 2, "prompt": "Store domain mappings for a-b.example.com and a.b-example.com using a key alphabet of letters, digits, underscores, and hyphens.", "expected_output": "Choose a collision-free encoding or hash.", "files": [], "assertions": [ "Does not replace every separator with underscore", "Keeps the original hostname in the authoritative record", "Treats an unknown hostname as unresolved rather than another tenant" ] } ], "routing": { "should_trigger": [ "Design isolation for shared-schema Postgres. The app currently uses the table owner role; tenant id is set once on a pooled connection.", "Store domain mappings for a-b.example.com and a.b-example.com using a key alphabet of letters, digits, underscores, and hyphens." ], "near_miss": [ { "prompt": "Write the JSON-LD content for this tenant page; routing already works.", "expected": "seo" } ] } }
-
-
references
-
cloudflare-platform.md 8.8 KB
# Cloudflare platform primitives (Workers for Platforms + Cloudflare for SaaS) Applies to steps 3 to 7 when Cloudflare is the chosen platform. Numbers here were checked 2026-09-01; the limits file carries the dated table. ## Contents - Architecture - Routing (hostname -> tenant -> dispatch) - Custom hostnames (Cloudflare for SaaS) - Isolation modes and per-tenant limits - Data primitives per tenant - Local checks - Sources ## Architecture - **Dispatch namespace**: the container for tenant ("user") Workers. Unlimited scripts, no per-account script cap, every script runs in untrusted mode by default. - **Dynamic dispatch Worker**: the only Worker with a route. Resolves the tenant, applies limits, sanitizes the response, and invokes the tenant Worker through the namespace binding. - **User Workers**: uploaded with `wrangler deploy --dispatch-namespace <namespace>` or the dispatch namespaces script upload API (`PUT /accounts/{account_id}/workers/dispatch/namespaces/{namespace}/scripts/{script_name}`). Bindings to KV, D1, R2, and Durable Objects are declared per script. No gradual deployments: each upload takes 100% of traffic immediately. - **Outbound Worker** (optional): intercepts every `fetch()` a user Worker makes. Use for hostname allowlists, egress logging, and injecting credentials the tenant never sees. Enabling it disables the `connect()` TCP API inside user Workers. Binding in the dispatch Worker's `wrangler.toml`: ```toml [[dispatch_namespaces]] binding = "DISPATCHER" namespace = "tenants-prod" ``` Dispatch Worker shape: ```js export default { async fetch(request, env) { const host = new URL(request.url).hostname; let tenant = await env.TENANTS.get(host, { type: "json" }); // KV: hostname -> { script, plan, cpuMs, subRequests } if (!tenant) tenant = await lookupInD1(env, host); // KV misses for ~60 s after onboarding if (!tenant) return new Response("Not found", { status: 404 }); try { const worker = env.DISPATCHER.get(tenant.script, {}, { limits: { cpuMs: tenant.cpuMs, subRequests: tenant.subRequests }, }); const headers = new Headers(request.headers); headers.set("x-tenant-id", tenant.id); headers.set("x-tenant-plan", tenant.plan); return await worker.fetch(new Request(request, { headers })); } catch (e) { if (e.message.startsWith("Worker not found")) return new Response("Not found", { status: 404 }); throw e; } }, }; ``` ## Routing (hostname -> tenant -> dispatch) - One `*/*` route on the SaaS zone pointing at the dispatch Worker. Per-hostname routes hit the 1,000 routes-per-zone limit and behave differently for grey-clouded customer DNS; the wildcard is consistent for proxied and unproxied customers and scales to millions of hostnames. - `*.saas.example/*` is enough when routing platform subdomains only; it needs a proxied wildcard DNS record. - Custom hostnames arrive because the customer's CNAME points at your zone; Cloudflare for SaaS routes them to the fallback origin, which can be a placeholder proxied record when the Worker is the origin. - Resolve `hostname -> tenant -> script name`; never derive the script name from the hostname string alone, or a tenant registering `victim.saas.example` picks their neighbor's script. ## Custom hostnames (Cloudflare for SaaS) - Bundled on Free, Pro, and Business; add-on for Enterprise. 100 custom hostnames included per zone, then $0.10 per hostname per month; hard cap 50,000 per zone below Enterprise. - **Setup**: fallback origin = a proxied `A`, `AAAA`, or `CNAME` record in your zone. Publish a friendly CNAME target such as `customers.<you>.com` and give tenants that, not the zone apex. - **Create** per tenant in the dashboard or with the Create Custom Hostname API: `hostname`, `ssl.method` (`http` | `txt` | `email`), minimum TLS version, optional custom origin server. The `POST` response may omit `validation_records`; `GET` the hostname afterwards. - **Two validations, two statuses**: hostname ownership (`ownership_verification`, drives `status`) and certificate DCV (`ssl.validation_records`, drives `ssl.status`). HTTP validation completes automatically once DNS points at you. TXT pre-validation (`_cf-custom-hostname.<hostname>` TXT with the returned UUID, or serving `/.well-known/cf-custom-hostname-challenge/<id>` with the token) reaches `active` before DNS cutover, so the tenant sees no downtime; traffic still moves only when the customer changes their DNS target. Wildcard custom hostnames require TXT. - Do not create a custom hostname equal to the SaaS zone name. - Hostnames over 64 characters need `cloudflare_branding: true` (the certificate CN becomes `sni.cloudflaressl.com`). - **Enterprise only**: wildcard custom hostnames, choosing the CA (`certificate_authority`), uploading custom certificates, apex proxying (customer `A` record at their apex), BYOIP. Below Enterprise a tenant apex needs a DNS provider with CNAME flattening, or a `www` redirect. - **O2O (Orange-to-Orange)**: the customer's zone is also on Cloudflare and proxied. Their zone's settings apply first, then yours; requires two different accounts; pre-validation is unsupported; requests carry `cf-connecting-o2o: 1`. Custom hostnames behind another CDN are not compatible. ## Isolation modes and per-tenant limits - **Untrusted** (default): no `request.cf`, `caches.default` disabled, each Worker has an isolated cache. Required when customers control the code. - **Trusted**: `request.cf` available and `caches.default` shared across the namespace, so a Worker can read another tenant's cached responses. Only when you author every script. - Per-invocation limits in `DISPATCHER.get(name, {}, { limits: { cpuMs, subRequests } })`; the user Worker throws the moment it exceeds either. Map plan tiers to these values and keep the mapping next to billing. - Up to eight tags per script; tag with tenant id and plan for bulk list and delete. - Platform ceiling on Workers for Platforms: 30 s CPU per invocation, 15 min per Cron Trigger or Queue consumer invocation. ## Data primitives per tenant - **KV** for `hostname -> tenant` in the hot path. Eventually consistent: up to 60 s across locations, negative lookups cached for the default `cacheTtl` of 60 s. Fall back to D1 on miss during onboarding. - **D1** for tenant records and, if chosen, database-per-tenant: 10 GB per database on Paid, 50,000 databases per account, 1,000 queries per invocation, 30 s per statement. Each database is a single writer, so shard a busy tenant rather than a busy table. - **Durable Objects** for per-tenant coordination and rate limiting; no namespace cap under Workers for Platforms. - **R2** with per-tenant prefixes (or buckets for regulated tenants); zero egress fees. ## Local checks - `curl -sI -H "Host: tenant.saas.example" http://127.0.0.1:8787/` against `wrangler dev` of the dispatch Worker: expect 200 for a known tenant, 404 for unknown. - `curl -s -H "Host: tenant.saas.example" -H "x-tenant-id: other" http://127.0.0.1:8787/whoami`: expect the resolved tenant, not `other`. ## Sources Accessed 2026-09-01. - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/reference/how-workers-for-platforms-works/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/custom-limits/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/configuration/outbound-workers/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/get-started/hostname-routing/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/worker-isolation/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/limits/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/pricing/ - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/ - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/plans/ - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/start/getting-started/ - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/create-custom-hostnames/ - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/hostname-validation/pre-validation/ - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/security/certificate-management/issue-and-validate/validate-certificates/ - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/saas-customers/how-it-works/ - https://developers.cloudflare.com/workers/platform/limits/ - https://developers.cloudflare.com/kv/concepts/how-kv-works/ - https://developers.cloudflare.com/d1/platform/limits/ -
data-isolation.md 6.2 KB
# Tenant data isolation Applies to step 3 on either platform. Pick a model per plan tier, then implement the enforcement so a missing `WHERE tenant_id` cannot leak. ## Contents - Choosing a model - Shared schema with tenant_id and RLS (default) - RLS implementation notes (Postgres, Supabase, Drizzle) - Schema-per-tenant - Database-per-tenant - Evidence - Sources ## Choosing a model | Model | Isolation | Operational cost | Fits | |-------|-----------|------------------|------| | Shared schema, `tenant_id` column, RLS | Logical, enforced in the database | One migration, one pool, large tables need tuning as they grow | Default for SaaS; every tenant on the same features | | Schema-per-tenant | Namespace | N migrations per release, `search_path` per request, catalog bloat, no per-tenant restore on Neon | Rare; legacy per-customer customizations | | Database-per-tenant (Neon project or D1 database per tenant) | Physical | Provisioning API, one connection reference per tenant, fleet migrations; per-tenant PITR, region pinning, scale-to-zero for idle tenants | Regulated or noisy tenants, data-residency, tenant-owned exports | Hybrid is normal: shared schema for Free and Pro, a dedicated database for the Enterprise tier, selected by `tenant.plan` in the data layer. The tenant row carries the connection reference either way. ## Shared schema with tenant_id and RLS (default) ```sql ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ALTER TABLE posts FORCE ROW LEVEL SECURITY; -- owners are bound too CREATE POLICY posts_tenant ON posts FOR ALL TO app_user USING (tenant_id = current_setting('app.tenant_id', true)::uuid) WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid); CREATE INDEX posts_tenant_id_idx ON posts (tenant_id); ``` Per request, inside one transaction on the app role: ```sql BEGIN; SELECT set_config('app.tenant_id', $1, true); -- true = local to this transaction -- queries COMMIT; ``` - `set_config(..., true)` or `SET LOCAL` only. A plain `SET` on a pooled connection survives the request and the next tenant inherits it; with PgBouncer in transaction mode the transaction-local form is the only safe one. - Two roles: a migration role that owns tables, and `app_user` that the application connects as. Superusers, `BYPASSRLS` roles, and table owners (without `FORCE`) skip every policy, so an app connected as the owner sees all tenants with RLS "on". - RLS enabled with no policy is default-deny: a forgotten policy shows up as empty results, not a leak. The dangerous failure is the reverse (owner connection or a permissive `USING (true)` policy), so test both directions. - Views run with the definer's privileges by default and bypass RLS; set `security_invoker = true` (Postgres 15+). - Grants and policies are separate: revoke default grants, then grant per role. A table protected only by policies still accepts `INSERT` from a role that keeps the grant. - `current_setting('name', true)` returns `NULL` instead of erroring when unset, which makes the policy evaluate to no rows rather than failing the query; log the unset case in the app so it never passes silently. ## RLS implementation notes (Postgres, Supabase, Drizzle) - **Supabase**: identify the tenant from the JWT, `USING (tenant_id = (select (auth.jwt() -> 'app_metadata' ->> 'tenant_id')::uuid))`. Wrap the function in `select` so Postgres evaluates it once per query; read `app_metadata` (server-set) never `user_metadata` (user-editable); scope policies `TO authenticated`; index every column a policy filters on. - **Drizzle**: `pgTable.withRLS('posts', {...})` or add `pgPolicy(...)` as a table extra (adding a policy enables RLS); `pgRole('app_user')` or `.existing()`; set the tenant with `tx.execute(sql\`select set_config('app.tenant_id', ${id}, true)\`)` inside `db.transaction`. - **Neon**: plain Postgres RLS as above; Neon's own multitenancy guide recommends project-per-tenant when isolation or per-tenant restore matters, and warns that shared-schema compliance work grows with tenant count. - Keep the `tenant_id` predicate in application queries as well; RLS is the last line, and the explicit predicate keeps the planner on the index. ## Schema-per-tenant - `CREATE SCHEMA t_<id>` per tenant; per request `SET LOCAL search_path = t_<id>, public` inside the transaction. - Migrations run once per schema; thousands of schemas bloat the catalog and slow `pg_dump` and planning. Neon notes it saves nothing operationally over separate databases and forfeits per-tenant PITR. - Choose it only when tenants need divergent table shapes and you cannot afford separate databases. ## Database-per-tenant - **Neon**: one project per tenant via the API; compute scales to zero after 5 minutes of inactivity on Free and Launch (configurable on Scale), so idle tenants cost close to nothing. Included projects: 100 on Free and Launch, 1,000 on Scale (soft limit). Per-tenant point-in-time restore and region choice come free with the model. - **D1**: one database per tenant under the 50,000 databases per account cap on Workers Paid, 10 GB each; bind the tenant's database in its user Worker or open it by id from the dispatch Worker. - Store the connection reference (Neon connection string or D1 database id) on the tenant row; run migrations as a fleet job that iterates tenants and records the applied version per tenant. - Connection pooling: one pool per active tenant is fine on Neon's pooled endpoint; keep pools small and lazily created. ## Evidence ```bash psql "$DATABASE_URL" -c "BEGIN; SET LOCAL ROLE app_user; SELECT set_config('app.tenant_id','<tenant-a>',true); SELECT count(*) FROM posts; ROLLBACK;" psql "$DATABASE_URL" -c "BEGIN; SET LOCAL ROLE app_user; SELECT count(*) FROM posts; ROLLBACK;" # unset tenant: expect 0 ``` The first count matches tenant A's rows only; the second returns 0. A count equal to the full table in either case means the role owns the table, has `BYPASSRLS`, or a policy uses `USING (true)`. ## Sources Accessed 2026-09-01. - https://www.postgresql.org/docs/current/ddl-rowsecurity.html - https://supabase.com/docs/guides/database/postgres/row-level-security - https://orm.drizzle.team/docs/rls - https://neon.com/docs/guides/multitenancy - https://neon.com/docs/introduction/plans - https://developers.cloudflare.com/d1/platform/limits/ -
limits-and-quotas.md 5.4 KB
# Platform limits and plan mapping Applies to step 8. Copy the rows that shape your plans into the limits-to-plan table with the source URL and access date. ## Freshness policy - Snapshot date: 2026-09-01. - Re-check each source before pricing, launch, or an enforcement change; the docs win on conflict and this file gets the correction. - Vendors rename products (Edge Config became Global Config in 2026) and move docs (`vercel.com/docs/multi-tenant/*` now lives under `/docs/platforms/multi-tenant-platforms/`); follow redirects and update URLs here. ## Cloudflare | Limit | Free | Paid | Notes | |-------|------|------|-------| | Workers CPU time per request | 10 ms | 30 s default, 5 min max | Workers for Platforms user Workers: 30 s per invocation, 15 min per Cron or Queue invocation | | Memory per isolate | 128 MB | 128 MB | | | Worker size (compressed) | 3 MB | 10 MB | | | Subrequests per invocation | 50 | 10,000 | Redirect hops count | | Workers per account | 100 | 500 | Not applied to user Workers in a dispatch namespace (unlimited) | | Routes per zone | 1,000 | 1,000 | Why the dispatch Worker uses one `*/*` route | | Custom Domains (Workers) per zone | 100 | 100 | Distinct from Cloudflare for SaaS custom hostnames | | Cloudflare for SaaS custom hostnames | 100 included, then $0.10 per hostname per month, cap 50,000 | Same on Pro and Business; Enterprise unlimited (talk to sales above 50,000) | Wildcard hostnames, CA choice, custom certificates, apex proxying, BYOIP: Enterprise | | Workers for Platforms subscription | n/a | $25 per month: 20M requests (+$0.30 per extra million), 60M CPU-ms (+$0.02 per extra million), 1,000 scripts (+$0.02 per extra script) | Per-script custom limits via `cpuMs` and `subRequests` | | D1 databases per account | 10 | 50,000 | Database-per-tenant ceiling | | D1 database size | 500 MB | 10 GB | Per database | | D1 queries per Worker invocation | 50 | 1,000 | 30 s per statement | | KV consistency | Eventual, up to 60 s | Eventual, up to 60 s | Negative lookups cached (default `cacheTtl` 60 s) | | Tags per script | 8 | 8 | | ## Vercel | Limit | Hobby | Pro | Enterprise | Notes | |-------|-------|-----|------------|-------| | Domains per project | 50 | Unlimited (soft 100,000) | Unlimited (soft 1,000,000) | Soft limits raised on request | | Domain API rate limits (per team) | 100 additions/h, 50 verifications/h, 100 removals/h | same | same | Queue onboarding; back off on `rate_limit_exceeded` | | Wildcard domains | Yes | Yes | Yes | Vercel nameservers required (DNS-01) | | Multi-tenant preview URLs on your domain | No | No | Yes | `tenant---branch-project.vercel.app` works everywhere | | Custom SSL certificate upload | No | No | Yes | | | Global Config store size | 1 MB | 1 MB | 1 MB | Formerly Edge Config (8/64/512 KB) | | Global Config stores | 1 total, 1 per project | Unlimited, 3 per project | Unlimited, 3 per project | | | Global Config writes | 250 per month | 100 per hour | 100 per hour | Write propagation up to 10 s | | Deployments per day | 100 | 6,000 | 24,000 | | | Routing Middleware request limits | URL 14 KB, body 4 MB, 64 headers, 16 KB headers | same | same | Applies to `proxy.ts` | | Edge Requests included | Plan allotment | First 10,000,000 | Contract | Regional pricing beyond | | ISR reads and writes | Plan allotment | $0.0004 per 1K reads, $0.004 per 1K writes | Contract | Per-tenant ISR pages multiply reads | | DNS label length | 63 chars | 63 chars | 63 chars | Preview URL tenant labels | ## Neon (database-per-tenant) | Limit | Free | Launch | Scale | |-------|------|--------|-------| | Projects included | 100 | 100 | 1,000 (soft) | | Scale to zero | After 5 min, always on | After 5 min, can be disabled | Configurable, 1 min to always on | ## Planning guidance - Enforce every plan limit at the routing layer (Cloudflare `limits`, Vercel `x-tenant-plan` plus server checks) and expose the same numbers in the API and the billing UI; a limit that only the UI knows about is unenforced. - Keep request work short on both platforms; queue or schedule anything longer than a page render (Cloudflare Queues and Workflows, Vercel background functions and cron). - Durable state lives in storage (D1, Neon, R2, Blob), never in-memory across requests. - Vercel: Global Config holds the hostname map only; everything else reads from the database. Hobby's 250 writes per month rules it out for high-churn onboarding. - Cloudflare: a D1 database is a single writer; shard busy tenants or give them their own database. - Pricing inputs that surprise teams: $0.10 per Cloudflare custom hostname past 100; per-script fees past 1,000 user Workers; Vercel ISR reads scaling with tenant page count. ## Sources Accessed 2026-09-01. - https://developers.cloudflare.com/workers/platform/limits/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/limits/ - https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/pricing/ - https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/plans/ - https://developers.cloudflare.com/d1/platform/limits/ - https://developers.cloudflare.com/kv/concepts/how-kv-works/ - https://vercel.com/docs/platforms/multi-tenant-platforms/limits - https://vercel.com/docs/limits - https://vercel.com/docs/global-config/global-config-limits - https://vercel.com/docs/global-config/migration-guide - https://vercel.com/docs/routing-middleware - https://neon.com/docs/introduction/plans -
psl.md 3.4 KB
# Public Suffix List (PSL): decision and submission Applies to step 1 when tenants publish content or run code on sibling subdomains of your tenant domain. ## What listing does - Browsers treat every label under a listed suffix as a separate site: cookies with `Domain=<suffix>` are rejected, `SameSite` boundaries fall between tenants, and one tenant cannot set a cookie that reaches another tenant or your dashboard. - Isolation only. It confers no trust, reputation, or Safe Browsing separation; a phishing tenant still damages the registrable domain, which is why tenants live on their own domain regardless. ## Decide - Submit when tenants can publish HTML or JavaScript, or run code, on `<tenant>.<suffix>`. - Submit the label directly above the tenant name: `acme.app` for `<tenant>.acme.app`, `sites.acme.app` for `<tenant>.sites.acme.app`. - Not needed for custom domains tenants own, or when only your code runs on the subdomains. - Listing changes behavior you may rely on: parent-scoped cookies, cross-subdomain sign-in, and code that infers "same site" from the hostname. Test those before the PR, because the change lands on the browsers' schedule, not yours. ## Eligibility (PRIVATE section) - Only the domain owner or an authorized representative may submit; third-party requests are declined. - Registration must have more than two years remaining, with a commitment to keep more than a year on the term. - Declined: short-term, sandbox, or lab projects; entries meant to dodge rate limits or vendor protections; wildcard entries used for IP mapping; alternative TLD systems. ## Submission steps 1. Create a permanent `TXT` record at `_psl.<suffix>` whose value is the pull request URL (add it once the PR exists). It stays in the zone after merge to signal continued inclusion. 2. Open a PR against `publicsuffix/list` adding the suffix under `// ===BEGIN PRIVATE DOMAINS===`, with the header: ```text // Acme : https://acme.app/ // Submitted by Jane Doe <jane@acme.app> acme.app ``` Sort the block by company name; within it, by TLD then the label left of the TLD; keep multiple suffixes alphabetical. 3. Describe the service, example tenant hostnames, and the intended site boundaries in the PR template. Respond to maintainer review. 4. After merge, wait: there is no SLA and no way to expedite. Chrome and Firefox ship the list with releases; platforms that embed it in the OS update with the OS. ## Interim controls (before the list propagates) - Dashboard and auth on a different apex (`app.acme.com`) than tenant subdomains (`*.acme.app`). - Session cookies as `__Host-session=...; Secure; HttpOnly; Path=/; SameSite=Lax` with no `Domain` attribute: browsers reject a `__Host-` cookie that carries `Domain`, so a sibling tenant cannot overwrite it. - Validate `Origin` or use CSRF tokens on state-changing requests; `__Host-` does not change `SameSite` semantics. ## Record in the output `PSL decision: Submit` with suffix, owner, PR link, and the `_psl` TXT date; or `No PSL` with the reason (tenant-owned domains only, or no tenant-controlled content on subdomains). ## Sources Accessed 2026-09-01. - https://publicsuffix.org/learn/ - https://publicsuffix.org/submit/ - https://github.com/publicsuffix/list/wiki/Guidelines - https://vercel.com/docs/platforms/multi-tenant-platforms/configuring-domains (Protecting tenant subdomains with the Public Suffix List) - https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie -
vercel-domains.md 6.8 KB
# Vercel domain management (custom domains + SSL) Applies to step 7 when Vercel is the chosen platform: the onboarding lifecycle a tenant walks through, and the platform behaviors that break it. ## Contents - Onboarding lifecycle - DNS targets - SDK surface, error codes, rate limits - Ownership verification - Wildcard domains - SSL certificates - Redirects and canonical hosts - Preview URLs - Troubleshooting - Sources ## Onboarding lifecycle 1. Tenant submits `tenant.com` in your UI or API. 2. `projectsAddProjectDomain(vercel, { idOrName, teamId, requestBody: { name } })` attaches it to the project; Vercel starts certificate issuance as soon as DNS resolves to it. 3. Show the DNS instructions from the API response for this project (`projectsGetProjectDomain` returns `verification` records when needed; `domainsGetDomainConfig` reports `misconfigured`). 4. Tenant sets the records. Poll `projectsVerifyProjectDomain` on user action or a slow schedule; it is rate limited to 50 per hour per team. 5. When `verified` is true and the config is no longer misconfigured, write the mapping to the database, then to Global Config. 6. Offboarding: `projectsRemoveProjectDomain` (detach from the project) then `domainsDeleteDomain` (drop from the account); delete the mapping first so traffic 404s instead of hitting a stale tenant. ## DNS targets - Apex: `A` record. The classic value is `76.76.21.21`, but newer projects receive pool addresses such as `216.198.79.1`. Show the value the API or domain card returns for this project rather than a hardcoded IP. - Subdomain (`www.tenant.com`, `docs.tenant.com`): `CNAME` to the project-specific target, formatted like `d1d4fc829fe7bc7c.vercel-dns-017.com.`; the trailing period marks an absolute name and some providers require it verbatim. - A `CNAME` at the apex violates RFC 1034 when `NS` or `MX` records exist there; tell tenants to use the `A` record or a provider with CNAME flattening. - IPv6 (`AAAA`) is not supported for third-party domains. - Nameservers (`ns1.vercel-dns.com`, `ns2.vercel-dns.com`) work for any domain and are mandatory for wildcards; the tenant must recreate their MX and other records inside Vercel DNS. ## SDK surface, error codes, rate limits - `@vercel/sdk` functional imports: `projectsAddProjectDomain`, `projectsGetProjectDomain`, `projectsVerifyProjectDomain`, `projectsRemoveProjectDomain`, `domainsDeleteDomain`, `domainsGetDomainConfig`. Class form: `vercel.projects.addProjectDomain(...)`, `vercel.domains.getDomainConfig(...)`. - Error codes: `domain_already_in_use` (another project or account holds it: verify with the TXT record), `invalid_domain` (format, punycode needed for IDNs), `forbidden` (token scope or team), `rate_limit_exceeded` (back off exponentially). - Rate limits for platforms: 100 domain additions per hour, 50 verifications per hour, 100 removals per hour, per team. Queue onboarding jobs and never verify in a tight loop. - Plan caps: Hobby 50 domains per project; Pro and Enterprise unlimited with soft limits of 100,000 and 1,000,000 (raised on request). ## Ownership verification - Required only when the domain is already in use on another Vercel account or project. It grants use in your project without moving the domain. - Record: `TXT` at `_vercel.<tenant apex>` with the value from the API; check with `dig TXT _vercel.tenant.com`. No trailing dot in the value, no duplicate `_vercel` records, allow 5 to 10 minutes. - Re-verify after nameserver changes or a domain transfer. ## Wildcard domains - Point the apex's nameservers to Vercel, add the apex, then add `*.acme.app`. All plans. - Vercel issues a certificate per subdomain on demand using DNS-01; that is why nameservers are mandatory. Without them the wildcard shows `Invalid Configuration` and never gets a certificate. - Multi-level names (`docs.tenant1.acme.app`) resolve under the same wildcard. ## SSL certificates - Let's Encrypt for every domain. Non-wildcard: HTTP-01, answered by Vercel as long as the domain points at Vercel. Wildcard: DNS-01 through Vercel nameservers. - `CAA`: if the tenant has any `CAA` records, they must include `0 issue "letsencrypt.org"` or issuance fails. Check with `dig -t CAA +noall +ans tenant.com`. - A stale `_acme-challenge` TXT from a previous host blocks issuance; ask the tenant to remove it (`dig -t TXT _acme-challenge.tenant.com`). - `/.well-known` is reserved: it cannot be rewritten or redirected, and the proxy must let it through. - Renewal is automatic. Uploading custom certificates is Enterprise only. ## Redirects and canonical hosts - Add both `tenant.com` and `www.tenant.com`; set `redirect` on the secondary through the API or dashboard. - When a tenant serves on both `tenant.acme.app` and `tenant.com`, redirect one to the other or set `alternates.canonical` in `generateMetadata`; keep one host in the sitemap. The `seo` skill owns the canonical and sitemap content. - Use `308` for permanent host consolidation (preserves method), `307` for temporary. ## Preview URLs - Default pattern `tenant---branch-project.vercel.app`; split the hostname on `---` to recover the tenant. - Multi-tenant preview URLs on your own domain (`tenant1---project-git-branch.acme.dev`) are Enterprise only and enabled by your account representative. - Each DNS label is limited to 63 characters; keep branch names short or previews stop resolving. ## Troubleshooting - Nameserver changes propagate in up to 24 to 48 hours; record changes follow the old TTL. Lower the TTL to 60 s before cutover so a rollback is fast. - `Invalid Configuration`: wrong or missing records, verification pending, a `CAA` blocking issuance, or a wildcard without Vercel nameservers. - Verification failing with the record present: value mismatch, trailing dot in the value, duplicate records, or checking before propagation. - Tenant's DNS is Cloudflare-proxied (orange cloud) in front of Vercel: HTTP-01 and redirects then pass through Cloudflare's TLS and rule layers. Ask the tenant to set the record to DNS-only, or accept that certificates and redirects are now governed by their zone settings. - Same content on two hosts: canonical or redirect, and one host in the sitemap. - Diagnostics: `letsdebug.net` for issuance, `dnsviz.net` for DNS and DNSSEC, `whatsmydns.net` for propagation. ## Sources Accessed 2026-09-01. - https://vercel.com/docs/platforms/multi-tenant-platforms/configuring-domains - https://vercel.com/docs/platforms/multi-tenant-platforms/quickstart - https://vercel.com/docs/platforms/multi-tenant-platforms/reference - https://vercel.com/docs/platforms/multi-tenant-platforms/limits - https://vercel.com/docs/domains/working-with-domains/add-a-domain - https://vercel.com/docs/domains/working-with-ssl - https://vercel.com/docs/domains/troubleshooting - https://vercel.com/kb/guide/a-record-and-caa-with-vercel - https://vercel.com/docs/limits (Domains and Rate limits sections) - https://github.com/vercel/sdk -
vercel-platform.md 8.6 KB
# Vercel platform primitives (Next.js multi-tenancy) Applies to steps 4 to 7 when Vercel is the chosen platform. Domain onboarding and SSL live in the domains reference. ## Contents - Starter kit facts (what the template actually does) - Proxy tenant resolution - App Router layout - Global Config for the hot path - Per-tenant static files - Custom subpaths - Caching per tenant - Local development and preview URLs - Sources ## Starter kit facts (what the template actually does) `github.com/vercel/platforms` as of 2026-09: Next.js 16 App Router, React 19, Tailwind 4, shadcn/ui, Upstash Redis with keys `subdomain:{name}` (`KV_REST_API_URL`, `KV_REST_API_TOKEN`). `proxy.ts` extracts the subdomain (handles `*.localhost`, `tenant---branch.vercel.app` previews, and `*.<rootDomain>`), blocks `/admin` on subdomains, and rewrites `/` to `/s/{subdomain}`. Its matcher `'/((?!api|_next|[\\w-]+\\.\\w+).*)'` skips every root file with an extension, which is why the template does not serve per-tenant `robots.txt`. Treat it as a routing demo, not a data-isolation reference: it stores no tenant data beyond the subdomain record. ## Proxy tenant resolution Next.js 16 renamed `middleware.ts` to `proxy.ts` (exported function `proxy`, Node.js runtime, setting `runtime` throws). On Next.js 15 keep `middleware.ts`, export `middleware`, and add `runtime: 'nodejs'` to `config` so database clients work. Migrate with `npx @next/codemod@canary middleware-to-proxy .`. ```ts // proxy.ts import { type NextRequest, NextResponse } from "next/server"; import { get } from "@vercel/global-config"; const ROOT = process.env.NEXT_PUBLIC_ROOT_DOMAIN!; // acme.app const TENANT_HEADERS = ["x-tenant-id", "x-tenant-slug", "x-tenant-plan"]; const keyFor = (hostname: string) => hostname.replace(/\./g, "_"); // Global Config keys: ^[\w-]+$ function lookupKey(host: string): string | null { const hostname = host.split(":")[0]; if (hostname.endsWith(".localhost")) return `sub_${hostname.split(".")[0]}`; if (hostname.includes("---") && hostname.endsWith(".vercel.app")) return `sub_${hostname.split("---")[0]}`; if (hostname === ROOT || hostname === `www.${ROOT}`) return null; // brand site if (hostname.endsWith(`.${ROOT}`)) return `sub_${hostname.slice(0, -(ROOT.length + 1))}`; return `domain_${keyFor(hostname)}`; // custom domain } export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const headers = new Headers(request.headers); for (const h of TENANT_HEADERS) headers.delete(h); // clients never supply tenant context if (pathname.startsWith("/.well-known")) return NextResponse.next({ request: { headers } }); const key = lookupKey(request.headers.get("host") ?? ""); if (!key) return NextResponse.next({ request: { headers } }); const tenant = await get<{ id: string; slug: string; plan: string }>(key); if (!tenant) return new NextResponse("Not found", { status: 404 }); // never fall through to brand content headers.set("x-tenant-id", tenant.id); headers.set("x-tenant-slug", tenant.slug); headers.set("x-tenant-plan", tenant.plan); const url = request.nextUrl.clone(); url.pathname = `/s/${tenant.slug}${pathname}`; // robots.txt, sitemap.xml, llms.txt included return NextResponse.rewrite(url, { request: { headers } }); } export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], }; ``` - Request headers, not response headers: `NextResponse.next({ headers })` ships them to the browser and `headers()` never sees them. - The proxy runs for `_next/data` even when excluded, and a matcher that excludes a path also skips Server Function POSTs on it. Re-derive the tenant in Server Functions from the session and enforce in the data layer. - Avoid large headers; some origins return `431` above a few KB. ## App Router layout - `app/(brand)/`: marketing and console on the apex. - `app/s/[slug]/layout.tsx`: tenant branding (logo, theme, fonts) from the database, `generateMetadata` with `metadataBase` set to the tenant's canonical host and `alternates.canonical` when a tenant serves on both a subdomain and a custom domain. - `app/s/[slug]/[[...path]]/page.tsx`: tenant pages. - `app/s/[slug]/robots.txt/route.ts`, `sitemap.xml/route.ts`, `llms.txt/route.ts`: per-tenant files (below). - Reading tenant context: `params.slug` for cache keys and data fetching; `(await headers()).get("x-tenant-plan")` for plan gating; `request.headers.get("x-tenant-id")` in route handlers. ## Global Config for the hot path Edge Config was renamed Global Config. Package `@vercel/global-config` (drop-in for `@vercel/edge-config`), env var `GLOBAL_CONFIG` (legacy `EDGE_CONFIG` still read by the new SDK; the legacy SDK cannot read newly connected stores). - Store only `hostname -> { id, slug, plan }`. 1 MB per store on every plan, 3 stores per project, up to 10 s write propagation, writes 250 per month on Hobby and 100 per hour on Pro and Enterprise. Onboarding many domains on Hobby exhausts the write quota; the database stays the source of truth and Global Config is a write-through cache. - Key names match `^[\w-]+$` (256 chars): encode dots in hostnames. - Prefer `getAll()` over several `get()` calls; each SDK call is one billable read. - The confirmation screen after a domain verifies reads the database, not Global Config, because of the propagation window. ## Per-tenant static files Route handlers inside the tenant segment, reached through the rewrite above: ```ts // app/s/[slug]/robots.txt/route.ts import { NextResponse } from "next/server"; export async function GET(_: Request, { params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const tenant = await getTenantBySlug(slug); if (!tenant) return new NextResponse("Not found", { status: 404 }); const body = `User-agent: *\nAllow: /\nSitemap: https://${tenant.primaryHost}/sitemap.xml\n`; return new NextResponse(body, { headers: { "Content-Type": "text/plain", "CDN-Cache-Control": "s-maxage=3600" }, }); } ``` - `Content-Type`: `text/plain` for `.txt`, `application/xml` for `sitemap.xml`. - `CDN-Cache-Control` caches at the Vercel CDN independent of the browser; purge by tag or path when tenant content changes. - `/public` is for files identical across tenants; large media goes to Blob storage. ## Custom subpaths Platform content under a customer path (`customer.com/docs`) while the customer hosts the rest of their site: - Catch-all `app/sites/[...slug]/page.tsx` with `[customerSlug, ...contentPath]`. - `assetPrefix: '/your-platform-assets'` plus a rewrite `/your-platform-assets/_next/:path*` -> `/_next/:path*`, so the customer only proxies two prefixes: `/docs/:path*` -> `https://acme.app/sites/<slug>/:path*` and `/your-platform-assets/:path*` -> `https://acme.app/your-platform-assets/:path*`. - Subdomain traffic can rewrite into the same path routes (`tenant.acme.app/guide` -> `/sites/tenant/guide`) so one route tree serves both. ## Caching per tenant - Next.js 16 Cache Components: `'use cache'` with `cacheTag(\`tenant-${id}\`)`; invalidate with `revalidateTag`. On Next.js 15, `unstable_cache` with `tags`. - Every cache key includes the tenant id (function argument or tag); a cached tenant layout without it serves one tenant's branding to another. - ISR serves stale while revalidating; per-tenant `generateMetadata` and OG images key on the tenant too. ## Local development and preview URLs - Chromium and Firefox resolve `*.localhost` to loopback without `/etc/hosts`; Safari and `curl` need entries or `curl --resolve tenant1.localhost:3000:127.0.0.1`. HTTP only locally. - Preview deployments: `tenant---branch-project.vercel.app`, parsed by splitting on `---`. Multi-tenant preview URLs on your own domain (`tenant1---project-git-branch.acme.dev`) are Enterprise only. - Each DNS label is capped at 63 characters, so long branch names plus a tenant label fail to resolve. ## Sources Accessed 2026-09-01. - https://vercel.com/docs/platforms - https://vercel.com/docs/platforms/multi-tenant-platforms/concepts - https://vercel.com/docs/platforms/multi-tenant-platforms/middleware-and-routing - https://vercel.com/docs/platforms/multi-tenant-platforms/serving-static-files - https://vercel.com/docs/platforms/multi-tenant-platforms/custom-subpaths - https://vercel.com/docs/platforms/multi-tenant-platforms/limits - https://vercel.com/docs/platforms/examples/multi-tenant-template - https://vercel.com/docs/platforms/multi-project-platforms/concepts - https://github.com/vercel/platforms (proxy.ts, README) - https://nextjs.org/docs/app/api-reference/file-conventions/proxy - https://vercel.com/docs/global-config/global-config-limits - https://vercel.com/docs/global-config/migration-guide - https://vercel.com/docs/routing-middleware
-
-
SKILL.md 15.8 KB
--- name: multi-tenant-architecture description: Designs tenant isolation, hostname routing, custom-domain lifecycle, and plan limits on Cloudflare or Vercel. Use when asked to "isolate tenant data", "support custom domains", "build a white-label platform", or assess PSL registration. For general module structure use codebase-architecture; for SEO content use seo. --- # Multi-Tenant Platform Architecture (Cloudflare or Vercel) - **IS:** platform choice, domain strategy and PSL, tenant identification, compute and data isolation, hostname routing, tenant context propagation, custom domains and SSL, per-tenant static files, and mapping platform limits to plans. - **IS NOT:** general folder structure or module contracts (use `codebase-architecture`), scaffolding a new repo (use `scaffold-nextjs`), or the content of per-tenant SEO files once routing serves them dynamically: sitemap entries, canonical URLs, structured data, indexing policy (use `seo`). ## Contents - Platform dispatch (decide first) - Reference files - Workflow (order matters) - Gotchas - Output schema - Pre-commit checklist - Related skills ## Platform dispatch (decide first) | Signals | Platform | Model | |---------|----------|-------| | Tenants upload or generate their own code; code-level isolation; edge compute on KV, D1, Durable Objects, R2 | Cloudflare | Dispatch Worker in front of a dispatch namespace of per-tenant Workers; Cloudflare for SaaS for custom hostnames | | Every tenant runs the same Next.js codebase and differs by content, branding, and plan; ISR, Server Components, Vercel deploys | Vercel | One deployment; `proxy.ts` resolves the tenant from the hostname; wildcard plus custom domains on the project | - Pick one platform per product. Fronting a Vercel app with a Cloudflare proxy doubles the TLS and redirect layers and is the usual cause of redirect loops and failed certificate issuance. - Tenants shipping their own code on Vercel is the multi-project model (one Vercel project per tenant, created with the SDK). It follows the Cloudflare row's isolation reasoning; this skill's Vercel references cover the single-deployment model only. ## Reference files | File | Read when | |------|-----------| | [cloudflare-platform.md](references/cloudflare-platform.md) | Cloudflare chosen: dispatch namespaces, routing, Cloudflare for SaaS custom hostnames, isolation modes, KV and D1 (steps 3 to 7) | | [vercel-platform.md](references/vercel-platform.md) | Vercel chosen: `proxy.ts` resolution, App Router layout, Global Config lookups, per-tenant static files, custom subpaths, local dev (steps 4 to 7) | | [vercel-domains.md](references/vercel-domains.md) | Vercel chosen: SDK domain lifecycle, DNS targets, verification, wildcard nameservers, SSL, troubleshooting (step 7) | | [data-isolation.md](references/data-isolation.md) | Step 3 on either platform: shared schema with RLS, schema-per-tenant, database-per-tenant, and the Postgres/Supabase/Drizzle policy pattern | | [psl.md](references/psl.md) | Step 1 when tenants publish content or run code on sibling subdomains: eligibility, submission, interim cookie controls | | [limits-and-quotas.md](references/limits-and-quotas.md) | Step 8: dated snapshot of Cloudflare, Vercel, and Neon limits to map onto plans | | `agents/openai.yaml` | Never during a task: launcher metadata for external runners | ## Workflow (order matters) Copy this checklist to track progress: ```text Multi-tenant progress: - [ ] Step 1: Domain strategy and PSL decision - [ ] Step 2: Tenant identification strategy - [ ] Step 3: Isolation model (compute and data) - [ ] Step 4: Deterministic routing - [ ] Step 5: Tenant context propagation - [ ] Step 6: Tenant config and least-privilege bindings - [ ] Step 7: Custom domains and per-tenant static files - [ ] Step 8: Limits mapped to plans, evidence captured ``` 1. Choose the domain strategy - Put tenant workloads on a dedicated registrable domain (`acme.app` for tenants, `acme.com` for brand). One phishing tenant on `x.acme.com` puts the whole domain on blocklists, and a tenant cookie with `Domain=acme.com` reaches your dashboard. - Keep the dashboard and auth on a different apex from tenant subdomains (`app.acme.com` for the console, `*.acme.app` for tenants). - If tenants publish content or run code on sibling subdomains, submit the label directly above the tenant name (`acme.app`, or `sites.acme.app` for `<tenant>.sites.acme.app`) to the PSL and start now: there is no SLA. Tenant-owned custom domains need no PSL entry. Otherwise record `No PSL` with the reason. 2. Choose tenant identification (one primary; custom domain as the upgrade path) - **Subdomain** `tenant.acme.app`: wildcard DNS plus wildcard certificate. The default. - **Custom domain** `tenant.com`: the tenant CNAMEs to you. Paying tenants; reputation shifts to them; needs the onboarding lifecycle in step 7. - **Path** `acme.app/tenant`: no per-tenant DNS or certificates, but no cookie isolation and no branding. Choose it only when tenants will never get a hostname. 3. Define the isolation model - **Compute, Cloudflare:** one dispatch namespace in untrusted mode; per-invocation `cpuMs` and `subRequests` limits per plan; an outbound Worker if tenant code may call the internet. - **Compute, Vercel:** one deployment, tenant code never executes. If tenants must ship code, move to Vercel multi-project or Cloudflare rather than sandboxing inside the app. - **Data:** shared schema with `tenant_id` on every tenant-aware table plus RLS is the default; database-per-tenant for regulated or noisy tenants, selectable per plan. See [data-isolation.md](references/data-isolation.md). 4. Route deterministically (tenants never influence routing or see each other) - **Cloudflare:** a single `*/*` route on the SaaS zone to the dispatch Worker; hostname -> tenant record (KV, D1 on miss) -> `env.DISPATCHER.get(script)`; `Worker not found` -> 404. - **Vercel:** `proxy.ts` (Next.js 16; `middleware.ts` with `runtime: 'nodejs'` on 15) reads `host`, looks the tenant up in Global Config or the database, rewrites into the tenant segment; unknown hostname -> 404, never the brand site. - Let `/.well-known` through before any tenant rewrite. Route `robots.txt`, `sitemap.xml`, and `llms.txt` into the tenant segment so they vary per tenant. 5. Propagate tenant context from one authority - Delete every inbound `x-tenant-*` header, set `x-tenant-id`, `x-tenant-slug`, `x-tenant-plan` from the resolved tenant, and forward them on the request (`NextResponse.next({ request: { headers } })`). Server Components read `await headers()`; route handlers read `request.headers`. Cloudflare: the dispatch Worker sets headers or passes parameters before `fetch`. - The proxy is routing, not authorization. Server Functions, route handlers, and jobs re-derive the tenant from the session and the data layer enforces it (RLS or `tenant_id` predicates). 6. Bind only what the tenant needs - **Cloudflare:** each user Worker gets its own bindings (KV namespace, D1 database, R2 prefix); adding a binding is an explicit redeploy. No shared globals. - **Vercel:** Global Config holds only `hostname -> { id, slug, plan }`; the database is the source of truth and write-through happens when a domain verifies. Feature flags and branding come from the database keyed by tenant id. 7. Support custom domains and per-tenant static files - Lifecycle to design and record: add domain -> show DNS target -> verify ownership -> certificate issued -> mapping activated -> removal or failure path. - **Cloudflare:** Cloudflare for SaaS custom hostname on the SaaS zone, proxied fallback origin, `customers.<you>.com` CNAME target, `http` or `txt` validation, pre-validate before DNS cutover. See [cloudflare-platform.md](references/cloudflare-platform.md). - **Vercel:** `projectsAddProjectDomain` -> DNS values from the project's domain card -> `_vercel` TXT only if the domain is already on Vercel -> `projectsVerifyProjectDomain` -> Let's Encrypt HTTP-01. See [vercel-domains.md](references/vercel-domains.md). - `robots.txt`, `sitemap.xml`, `llms.txt` are route handlers inside the tenant segment with explicit `Content-Type`; nothing tenant-specific lives in `/public`. Their content is `seo` territory. 8. Surface limits as plans and capture evidence - Fill the limits-to-plan table from [limits-and-quotas.md](references/limits-and-quotas.md), re-checking each source URL and dating it; enforce at the routing layer (Cloudflare `limits`, Vercel plan header plus server checks). - Nothing long-running in the request path: Cloudflare Queues or Workflows, Vercel background functions or cron. - Every tenant operation (create tenant, add domain, verify, remove) works over HTTP with the same authority as the UI; if it only works in the dashboard, the platform leaks into the UI. - Run the evidence commands in the pre-commit checklist and paste results into the output. ## Gotchas - Tenant headers set on the response instead of the request: `NextResponse.next({ headers })` sends `x-tenant-id` to the browser and `headers()` in Server Components reads nothing. Use `NextResponse.next({ request: { headers: requestHeaders } })`. - Forwarding inbound tenant headers: `curl -H "x-tenant-id: <other>"` then serves another tenant's data. Delete or overwrite `x-tenant-*` on every path through the proxy, including paths that skip resolution. - The starter kit matcher `'/((?!api|_next|[\\w-]+\\.\\w+).*)'` excludes every root file with an extension, so `robots.txt` and `sitemap.xml` skip the proxy and every tenant gets the platform's `/public` copy. Match them, and rewrite them into the tenant segment. - Next.js 16 renamed `middleware.ts` to `proxy.ts` (export `proxy`, Node.js runtime, a `runtime` config option throws). `npx @next/codemod@canary middleware-to-proxy .` migrates. A matcher that excludes a path also skips Server Function POSTs on it, so tenant checks live in the data layer too. - Global Config (formerly Edge Config) key names must match `^[\w-]+$`; `tenant_acme.com` is rejected. Use a collision-free encoding or hash; replacing dots with underscores can map different hostnames to the same key. Writes propagate in up to 10 s, so a "domain connected" screen that reads Global Config right after the write shows stale state; read the database there. The legacy `@vercel/edge-config` SDK cannot read stores connected after the rename (they create `GLOBAL_CONFIG`, not `EDGE_CONFIG`). - RLS is bypassed by superusers and `BYPASSRLS` roles; table owners bypass it unless `FORCE ROW LEVEL SECURITY` is enabled. An app connecting as the migration role sees every tenant with policies "on". Connect as a separate role, add `ALTER TABLE ... FORCE ROW LEVEL SECURITY`, and test with `SET ROLE app_user`. - `SET app.tenant_id = ...` outside a transaction on a pooled connection persists into the next request. Use `set_config('app.tenant_id', $1, true)` inside the transaction; with PgBouncer in transaction mode it is the only safe form. - Wildcard `*.acme.app` on Vercel without Vercel nameservers never gets a certificate: DNS-01 needs Vercel to write `_acme-challenge`. Point `ns1.vercel-dns.com` and `ns2.vercel-dns.com` first and re-add MX records. - `/.well-known` is reserved on Vercel and cannot be rewritten or redirected; a proxy that rewrites every path into `/s/[slug]` breaks HTTP-01 and custom-domain certificates never issue. Pass it through first. - Cloudflare for SaaS: the fallback origin must be a proxied record in the SaaS zone; a custom hostname equal to the zone name is unsupported; `_cf-custom-hostname` pre-validation does not work when the customer's zone is also on Cloudflare (O2O, marked by `cf-connecting-o2o: 1`). - Untrusted dispatch namespaces (default) have no `request.cf` and no `caches.default`, so tenant code reading `request.cf.country` throws. Trusted mode restores them but shares one cache across every tenant Worker in the namespace. - KV is eventually consistent (up to 60 s, negative lookups cached): a hostname added after the dispatch Worker's first lookup 404s for a minute. Fall back to D1 on miss during onboarding. - PSL rejects domains with under two years of registration left; the `_psl.<suffix>` TXT stays in place after merge; browsers ship the list on their own release cycles. Listing also kills `Domain=acme.app` cookies, including your own cross-subdomain SSO if it lives there. - Starting path-based with custom domains on the roadmap means URL rewrites, cookie changes, and DNS migration later. - Domain quotas and charges vary by provider and plan. Put current official limits and their access dates in the plan table before setting pricing. ## Output schema Length follows the decisions: drop any section the project does not face rather than filling it. ```markdown # Multi-tenant architecture ## Platform decision - Platform: Cloudflare | Vercel - Why this platform: - Rejected platform and reason: ## Domain map - Brand domain: - Tenant domain: - Tenant subdomains: - Custom domains: - PSL decision: Submit (suffix, owner, PR link, _psl TXT date) | No PSL (reason) ## Routing matrix | Host pattern | Resolver | Destination | Unknown tenant behavior | |---|---|---|---| ## Tenant context flow - Authority: proxy.ts | dispatch Worker - Headers set and stripped: - Server read path: - Data-layer enforcement: ## Isolation model - Compute isolation: - Data isolation (and per-plan variant): - Config/binding isolation: ## Custom-domain lifecycle 1. DNS target: 2. Ownership verification: 3. Certificate provisioning: 4. Routing activation: 5. Removal/failure path: ## Limits-to-plan table | Limit | Source URL / access date | Free | Pro | Enterprise | Enforcement point | |---|---|---:|---:|---:|---| ## Validation evidence | Check | Command | Expected | Result | |---|---|---|---| ``` ## Pre-commit checklist - [ ] Platform chosen with rationale; multi-project or Cloudflare chosen if tenants ship code - [ ] Tenant workloads off the brand domain; dashboard on a separate apex; PSL decision recorded - [ ] Identification strategy chosen; custom-domain upgrade path defined - [ ] Isolation model defined for compute and data, including the per-plan variant - [ ] Routing tenant-blind: unknown host -> 404; `/.well-known` passes through; static files vary per tenant - [ ] Inbound `x-tenant-*` stripped; context set by the proxy or dispatch Worker only; data layer enforces tenant - [ ] Custom-domain lifecycle defined end to end, including removal - [ ] Limits table dated from official URLs; enforcement points named; long work off the request path Evidence commands (run against local or preview; mark N/A with a reason): | Check | Command | Expected | |---|---|---| | Tenant boundary exists in code | `rg -n "x-tenant-id\|CREATE POLICY\|FORCE ROW LEVEL SECURITY\|DISPATCHER.get" .` | Hits in proxy or dispatch Worker and in the schema | | Unknown host is 404 | `curl -sI -H "Host: nope.acme.app" <url>` | `404` | | Forged header ignored | `curl -s -H "Host: a.acme.app" -H "x-tenant-id: tenant-b" <url>/api/whoami` | Tenant A | | Static files vary | `curl -s -H "Host: a.acme.app" <url>/robots.txt` vs `-H "Host: b.acme.app"` | Different bodies, `Content-Type: text/plain` | | RLS holds for the app role | `psql -c "BEGIN; SET LOCAL ROLE app_user; SELECT set_config('app.tenant_id','<t1>',true); SELECT count(*) FROM posts; ROLLBACK;"` | Only tenant t1's rows | | ACME path reachable | `curl -sI -H "Host: tenant.com" <url>/.well-known/acme-challenge/test` | Not a redirect into the tenant segment | | Limits current | Access date next to each URL in the limits table | Dated within the planning window | ## Related skills - `codebase-architecture`: folder structure, module contracts, and the request-context pipeline for the application itself. - `scaffold-nextjs`: bootstrap the Next.js turborepo before applying these tenancy patterns. - `seo`: content of per-tenant `robots.txt`, `sitemap.xml`, `llms.txt`, canonical URLs, and structured data once routing serves them. Maintenance only: `evals/evals.json` contains regression scenarios for changes to this skill; it does not load during a user task.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.