Multi-tenant apps: subdomains, CNAMEs and custom domains, with or without Cloudflare for SaaS
TL;DR: A multi-tenant app is one deploy that decides which tenant to serve by looking at the
Hostheader. Subdomains (shop.example.com) need one wildcard DNS record and one wildcard certificate. Custom domains (shop.theirbrand.com) are harder: the customer adds a CNAME to you, and you must get a TLS certificate for a hostname you do not own. Cloudflare for SaaS does that for you. Without it, a reverse proxy with on-demand TLS (Caddy is the easiest) does the same job on your own server. Either way, the app side is the same: map host to tenant, cache the lookup, and redirect every alias to one canonical host.
This is what I built for a multi-tenant e-commerce SaaS on SvelteKit and Cloudflare Workers, plus how I would do it without Cloudflare.
The three kinds of hosts
One deploy serves everything. The first thing every request does is classify its hostname:
| Host | Example | Serves |
|---|---|---|
| Root | example.com |
Marketing site |
| App | app.example.com |
Login, merchant dashboard, admin |
| Tenant subdomain | acme.example.com |
Acme's storefront |
| Custom domain | shop.acme.com |
Acme's storefront, on their own domain |
Anything else is unknown and gets a 404.
export function classifyHost(hostname: string, root: string) {
const host = hostname.toLowerCase().replace(/\.$/, '');
if (host === root || host === `www.${root}`) return { kind: 'marketing' };
if (host === `app.${root}`) return { kind: 'app' };
if (host.endsWith(`.${root}`)) {
const slug = host.slice(0, -(root.length + 1));
if (!slug.includes('.') && !RESERVED.has(slug)) return { kind: 'store', slug };
return { kind: 'unknown' };
}
return { kind: 'custom', hostname: host };
}
Two details that matter later:
- Reserved slugs. A tenant must not be able to register
app,www,admin,api,mail, or any hostname your infrastructure uses (more oncustomersandproxy-fallbackbelow). Keep a list and check it at signup. - One label only.
www.acme.example.comis not supported. A wildcard certificate for*.example.comcovers exactly one label, soa.b.example.comwould fail TLS anyway.
Routing inside the app
In SvelteKit I rewrite the URL path per surface with the reroute hook, so each surface has its own route tree but the public URLs stay clean:
example.com/pricing -> /site/pricing
app.example.com/orders -> /app/orders
acme.example.com/product/x -> /store/acme/product/x
shop.acme.com/product/x -> /store/acme/product/x (after a lookup)
The internal prefixes never appear in links. Inside a storefront, a link to a product is just /product/x.
Other frameworks have the same idea under different names: Next.js middleware rewrites, an Express router chosen by host, nginx server_name blocks.
Subdomains: the easy part
Subdomains need two things:
- A wildcard DNS record:
*.example.compointing at your app. - A wildcard certificate for
*.example.com.
On Cloudflare, a proxied wildcard record covers both, because Universal SSL issues the edge certificate. On Workers, the record does not even need a real target:
AAAA * 100:: (proxied)
100:: is a discard address. Cloudflare intercepts the request at the edge and hands it to the Worker before it would ever go there. Then add a route so the Worker receives it:
"routes": [
{ "pattern": "example.com", "custom_domain": true },
{ "pattern": "app.example.com", "custom_domain": true },
{ "pattern": "*/*", "zone_name": "example.com" }
]
Why */* and not *.example.com/*? Custom domains (next section) arrive through the zone with their own hostname, which *.example.com/* would not match.
Without Cloudflare, you need a wildcard certificate from Let's Encrypt, which requires the DNS-01 challenge: your ACME client must be able to create a TXT record at your DNS provider. Caddy, Traefik, certbot and acme.sh all have DNS plugins for the common providers.
Custom domains: the hard part
The customer owns acme.com and wants their store at shop.acme.com. They add one record at their DNS provider:
CNAME shop customers.example.com
Now traffic for shop.acme.com reaches you. Two problems remain:
- TLS. The browser expects a certificate for
shop.acme.com. You do not controlacme.com's DNS, so you cannot use DNS-01, and you cannot pre-issue certificates for domains you do not know about yet. - Which tenant is this? Your app has to map
shop.acme.comto Acme.
Problem 2 is yours to solve in the app either way. Problem 1 is what Cloudflare for SaaS, Caddy on-demand TLS, Vercel's domains API and similar products exist for. They all work the same way: once the customer's CNAME points at you, they answer an HTTP-01 challenge for that hostname on the customer's behalf and get a certificate issued for it.
Option A: Cloudflare for SaaS (Custom Hostnames)
The free tier includes 100 custom hostnames. One-time setup on your zone:
- Enable it: SSL/TLS, then Custom Hostnames.
- Fallback origin: create an originless proxied record and set it as the fallback origin. Wait until its status is Active.
AAAA proxy-fallback 100:: (proxied) - CNAME target for customers to point at:
CNAME customers proxy-fallback.example.com (proxied) - Worker route
*/*on the zone, as above, so custom hostname traffic reaches the Worker. - API token with "SSL and Certificates: Edit" on the zone, stored as a secret.
Then, when a merchant connects a domain, the app registers it:
await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/custom_hostnames`, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
hostname: 'shop.acme.com',
ssl: { method: 'http', type: 'dv', settings: { min_tls_version: '1.2' } }
})
});
Poll GET .../custom_hostnames/<id> until both the hostname and SSL status are active, then start routing it. The traffic path ends up as:
shop.acme.com -> customers.example.com -> proxy-fallback.example.com -> your Worker
Gotchas I hit:
- Dashboard vars get wiped.
wrangler deployreplaces plain-text vars and routes with what is inwrangler.jsonc. Put the zone id, CNAME target and routes in the config file. Only true secrets (the API token) belong in the dashboard, as type Secret. - The fallback origin form said "You must use a hostname that is a part of your zone" even though the record existed. Setting it through the API worked. Check for a pasted invisible character, or that you are on the right zone's page.
custom_metadatais Enterprise only, so you cannot attach the tenant id to the hostname at Cloudflare. Keep the mapping in your own database.
Option B: your own server with Caddy on-demand TLS
If you run a VPS (or want to avoid Cloudflare entirely), Caddy can obtain a certificate the first time it sees a new hostname. Put it in front of your app:
{
on_demand_tls {
ask http://localhost:3000/api/domain-allowed
}
}
https:// {
tls {
on_demand
}
reverse_proxy localhost:3000
}
The ask endpoint is not optional. Before issuing a certificate, Caddy calls it with ?domain=shop.acme.com. Your app returns 200 only if that hostname belongs to a tenant, otherwise 404. Without it, anyone can point random domains at your server and make you request certificates until you hit Let's Encrypt rate limits.
// GET /api/domain-allowed?domain=shop.acme.com
const domain = url.searchParams.get('domain')?.toLowerCase();
const known = domain && (await db.domainExists(domain));
return new Response(null, { status: known ? 200 : 404 });
Customers then point their CNAME at your server's hostname (for example customers.example.com with an A/AAAA record to your server). Caddy passes the original Host header through, so the app sees shop.acme.com and does the same tenant lookup as before.
Traefik and nginx can do the same with more work (Traefik's ACME resolver per router, or OpenResty with lua-resty-auto-ssl). Caddy is the shortest path.
Option C: let your host do it
Vercel, Netlify, Fly.io and Render all have a domains API: you add the customer's hostname to your project by API, they issue the certificate, and your app receives the request with the original host. Check the per-project domain limits and the pricing before you build on it.
Apex domains
acme.com (no subdomain) cannot have a CNAME record under the DNS spec, because the apex also holds SOA and NS records. Options for your customer:
- Their DNS provider supports CNAME flattening or an ALIAS/ANAME record (Cloudflare, DNSimple, Route 53 alias and others). That works like a CNAME at the apex.
- You give them a static IP to use in an A record. That needs a stable anycast IP, which Cloudflare for SaaS offers only on paid plans, and which you naturally have with your own server.
- They use
www.acme.comand redirect the apex to it at their provider.
When a merchant connects acme.com, I also register www.acme.com (and the reverse), so both work.
The app side: host to tenant
Whichever option handles TLS, the app has to resolve the hostname on every request. That lookup sits on the hot path of every storefront page, so cache it.
My setup:
- Postgres is the source of truth: a
store_domaintable withhostname(primary key),store_id,is_primaryandstatus(pending,active,failed). - KV caches lookups for an hour:
route:<host>maps to{ slug, primary }, and an empty string caches "not a tenant" so unknown hosts do not hit the database either. - Only
activedomains route. A domain whose certificate is still pending is not served. - Invalidate on change: connecting, removing or re-verifying a domain deletes the related KV keys.
One canonical host per store
A store with a custom domain is reachable at three or four addresses: acme.example.com, shop.acme.com, www.shop.acme.com... Search engines treat those as duplicate content. Pick the one the merchant typed as primary and 301 every other address to it.
In SvelteKit 3 there is a trap here: redirect() throws on any target with a different host unless you opt in. Without the option the redirect becomes a 500, and in production you only see "Internal Error".
if (target) {
const to = new URL(event.url);
to.protocol = 'https:';
to.host = target;
redirect(301, to, { external: true });
}
Skip the redirect for non-GET requests and for paths like /robots.txt, /sitemap.xml and static files, which should answer on every host.
Security checklist
- Never issue certificates for arbitrary hostnames. Always check the hostname against your database first (the Caddy
askendpoint, or only calling the Cloudflare API for domains a merchant registered). - Host-only cookies. Do not set
Domain=.example.comon session cookies, or one tenant's storefront can read cookies meant for another subdomain. Better: keep merchant sessions onapp.example.comonly and never load them on storefront hosts. - Reserve infrastructure names (
app,www,api,customers,proxy-fallback,mail...) so a tenant cannot claim them as a slug. - Do not trust
X-Forwarded-Hostunless your own proxy sets it. Use the host your platform guarantees. - Verify ownership if domains can be claimed by more than one account. A TXT record challenge (
_verify.shop.acme.com TXT <token>) prevents one customer from claiming another's domain before they point it at you.
Which one should you use?
- Already on Cloudflare Workers or Pages: Cloudflare for SaaS. 100 hostnames free, certificates and DDoS protection included, no server to run.
- Running your own VPS or containers: Caddy with on-demand TLS and an
askendpoint. One small config file. - On Vercel, Netlify or similar: their domains API, as long as the limits fit your growth.
The DNS and TLS part is a one-time setup. The part you will keep touching is the app side: the host classification, the cached tenant lookup and the canonical redirects. Get those right and adding a new way to serve TLS later is a small change.