TL;DR: A multi-tenant app is one deploy that decides which tenant to serve by looking at the Host header. 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 on customers and proxy-fallback below). Keep a list and check it at signup.
  • One label only. www.acme.example.com is not supported. A wildcard certificate for *.example.com covers exactly one label, so a.b.example.com would 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:

  1. A wildcard DNS record: *.example.com pointing at your app.
  2. 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:

  1. TLS. The browser expects a certificate for shop.acme.com. You do not control acme.com's DNS, so you cannot use DNS-01, and you cannot pre-issue certificates for domains you do not know about yet.
  2. Which tenant is this? Your app has to map shop.acme.com to 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:

  1. Enable it: SSL/TLS, then Custom Hostnames.
  2. 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)
    
  3. CNAME target for customers to point at:
    CNAME  customers  proxy-fallback.example.com   (proxied)
    
  4. Worker route */* on the zone, as above, so custom hostname traffic reaches the Worker.
  5. 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 deploy replaces plain-text vars and routes with what is in wrangler.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_metadata is 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.com and 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_domain table with hostname (primary key), store_id, is_primary and status (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 active domains 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 ask endpoint, or only calling the Cloudflare API for domains a merchant registered).
  • Host-only cookies. Do not set Domain=.example.com on session cookies, or one tenant's storefront can read cookies meant for another subdomain. Better: keep merchant sessions on app.example.com only 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-Host unless 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 ask endpoint. 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.