Draft — screenshots pending. The
SCREENSHOT:captions below mark where UI captures go; see the figure manifest at the end. Every hostname and IP here is a placeholder.
On a multi-tenant commerce platform, every new store should just work at its own
address — acme.example.com, demo.example.com, test1-f1m86.example.com —
with DNS assigned automatically and HTTPS provisioned automatically, no
per-store setup.
That is a one-line promise with a surprising amount of plumbing behind it. What
follows is the whole thing, including a bug hunt that went 526 → 404 → 403
before every subdomain rendered. If you run SvelteKit — or any SSR framework —
behind a CDN, the 403 at the end is the part worth your attention. It is easy
to ship and hard to see.
The goal
One wildcard, set up once, covering every current and future tenant:
- DNS:
*.example.com→ your edge, so no per-store DNS record is ever created. - SSL: a wildcard certificate covering every subdomain, auto-renewing.
- Routing: any
*.example.comrequest reaches the one multi-tenant app, which resolves the tenant from theHostheader.
When those three hold, provisioning a new store is a database row and the URL is live immediately.
A wildcard setup is really three wildcards
The most useful mental model here: “wildcard SSL” is not one thing. There are three independent layers, and each has to be a wildcard or new subdomains break.
| Layer | What it is | Failure symptom |
|---|---|---|
| DNS | *.example.com A/CNAME → your edge, proxied | NXDOMAIN, does not resolve |
| Edge TLS | The certificate the browser sees — Universal SSL covers example.com and *.example.com | Browser certificate warning |
| Origin TLS + routing | The certificate Cloudflare sees when it talks to your server, and a router that accepts any subdomain | 526 (bad origin cert) or 404 (no route) |
The browser-facing layers are the easy 90%. Universal SSL already covers one
level of wildcard for free, so test1-f1m86.example.com is handled at the edge
with no work at all. The origin is where the surprises live.
Symptom 1 — 526: the origin certificate
A brand-new store subdomain returned a Cloudflare 526:
curl -sS -I https://test1-f1m86.example.com/HTTP/1.1 526
server: cloudflare
cf-cache-status: DYNAMICSCREENSHOT: terminal showing the HTTP/1.1 526 response headers
526 means one specific thing. Cloudflare is in Full (strict) SSL mode, it connected to your origin over TLS, and the certificate the origin presented could not be validated. Not DNS. Not the edge. The origin certificate.
The tell that this was wildcard-specific: sibling subdomains configured by hand all worked, but anything un-provisioned failed identically.
for h in test1-f1m86 zzznotreal-9q7x2 shop store; do
curl -sS -o /dev/null -w "$h -> %{http_code}\n" "https://$h.example.com/"
donetest1-f1m86 -> 526
zzznotreal-9q7x2 -> 526 # never existed
shop -> 526
store -> 526Every subdomain that had not been individually set up got the origin’s default self-signed certificate, which Full (strict) rightly rejects.
The fix is a wildcard origin certificate that Cloudflare trusts. Cloudflare’s
Origin CA issues exactly that: a long-lived certificate for example.com plus
*.example.com, trusted by Cloudflare’s edge in Full (strict) mode. Generate it
under SSL/TLS → Origin Server → Create Certificate, install it on the origin
reverse proxy as the default certificate, and every subdomain — present and
future — is covered in one shot.
SCREENSHOT: Cloudflare “Create Origin Certificate” form with hostnames example.com and *.example.com
Result: 526 → 404. Progress. A 404 means the request now reaches the origin,
so TLS is happy.
Symptom 2 — 404: wildcard routing at the origin
The 404 was the reverse proxy’s default “no router matched” page. The certificate let Cloudflare in, but the origin had no route for an arbitrary subdomain.
Most panels — this one runs Traefik underneath — let you add domains per app.
The trap: typing *.example.com into a plain domain field generates a Traefik
Host(`*.example.com`) rule, and Traefik treats that asterisk literally,
so it matches nothing. A real wildcard needs HostRegexp, which is usually
behind a dedicated “wildcard domain” toggle.
# What you want Traefik to generate (v3):
rule = "HostRegexp(`^.+\\.example\\.com$`)" # matches any subdomain
# What a plain domain field gives you:
rule = "Host(`*.example.com`)" # matches the literal "*." — never firesSCREENSHOT: hosting panel “Add Domain” dialog with the wildcard toggle enabled and the target port shown
Point the wildcard route at the single multi-tenant app and any subdomain reaches it:
test1-f1m86 -> 500 # reaching the app now
randomstore-ab12x -> 500
hello-world-99 -> 500526 (SSL) and 404 (routing) are both gone. The 500 is a new and better
problem: the app is running and answering, it just cannot render these
particular stores yet.
Symptom 3 — 403: the SSR hairpin
The app returned:
{ "message": "HTTP error 403: Forbidden", "status": 500 }This is the interesting one, because every obvious explanation was wrong.
- “The store does not exist.” No — the API resolved it fine. The
public-details endpoint returned a real store with
200. - “A WAF or bot rule is blocking it.” It was deterministic, five times out of five, so not rate limiting.
- “It is Cloudflare at the edge.” Bypassing Cloudflare entirely, hitting the
origin directly with the
Hostheader set, still returned 500. Not the edge.
The decisive clue was a correlation. Subdomains on DNS-only records (grey-cloud, straight to origin) worked. Subdomains on the proxied wildcard (orange-cloud) failed — for the same underlying store.
The root cause
The storefront resolves the tenant during SSR with a relative fetch:
// hooks.server.ts — resolve the store for this request
const store = await storeService.getStoreByIdOrDomain({ domain });
// under the hood: event.fetch('/api/stores/public-details?domain=…')That /api/... is relative, so SvelteKit’s event.fetch resolves it against
the current request’s origin — the public hostname. In production the /api
prefix is proxied at the edge, not inside the app. So the server, in the middle
of rendering a page, makes an outbound HTTPS call to its own public domain.
For a proxied subdomain that call leaves the container, goes out to Cloudflare and comes back — and Cloudflare blocks it, because it arrives from a datacenter IP (the server’s own egress) and trips bot protection. Hence 403.
For DNS-only domains the same relative fetch resolves straight to the origin, never touches Cloudflare, and works. That is the entire correlation.
We proved it from inside the container, using the same fetch the SSR path uses:
# via the public hostname — out through the CDN and back
node -e 'fetch("https://test1-f1m86.example.com/api/stores/public-details?domain=test1-f1m86.example.com").then(r=>console.log("via public/CDN:", r.status))'
# straight to the backend on the internal network
node -e 'fetch("http://10.0.0.5:7000/api/stores/public-details?domain=test1-f1m86.example.com").then(r=>console.log("direct:", r.status))'via public/CDN: 403
direct: 200SCREENSHOT: container console showing “via public/CDN: 403” and “direct: 200”
That is the whole bug in two lines: the SSR sub-request hairpins through the CDN and gets blocked.
The fix
Two layers. Use both.
1. Immediate, at the infrastructure level. Allow the origin’s egress IP through the CDN: Security → WAF → IP Access Rules, add an Allow for the server’s IP. The hairpin passes. No code, no redeploy.
SCREENSHOT: Cloudflare IP Access Rules list with the origin IP set to Allow
2. Proper, in code. Stop hairpinning at all. SvelteKit’s handleFetch hook
rewrites server-side fetches so /api calls go straight to the backend and
never leave the network.
// hooks.server.ts
import { env } from '$env/dynamic/public';
import type { HandleFetch } from '@sveltejs/kit';
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
const apiBase = env.PUBLIC_API_URL; // e.g. http://10.0.0.5:7000
if (apiBase) {
const url = new URL(request.url);
if (url.pathname.startsWith('/api')) {
const target = new URL(apiBase);
url.protocol = target.protocol;
url.hostname = target.hostname;
url.port = target.port;
return fetch(new Request(url, request)); // direct to backend
}
}
return fetch(request);
};Browser fetches are untouched — they never run through handleFetch — so there
is no mixed-content problem. Only server-side calls are redirected inward. That
makes the fix independent of any egress-IP allowlist and removes a pointless CDN
round trip on every render.
While in there, we also turned “no store maps to this domain” from a raw 500 into a proper 404:
// hooks.server.ts — a genuine 404 means "store not found"; re-throw real errors
try {
storeDetails = await storeService.getStoreByIdOrDomain({ domain });
} catch (e) {
if (!/not found|404/i.test(e?.message || '')) throw e; // real API failure → 500
}
if (storeDetails?.id) event.locals.storeDetails = storeDetails;
else event.locals.storeNotFound = true;// +layout.server.ts
if (event.locals?.storeNotFound) throw error(404, 'Store not found');Bonus: the deploy that succeeded and changed nothing
One last trap, because it cost real time. After pushing the fix the deploy went
green — the build log showed a fresh vite build and a new image — yet the
running app served the old code. Every deploy produced a new image but kept
running the same container.
That is the Docker Swarm classic: the service references an image by the
:latest tag, and updating a service with an unchanged tag does not re-pull.
The build succeeds; the container never swaps.
Do not trust “deploy succeeded” — verify the running code. Exec into the container and grep for a string you just added:
# inside the running container
grep -c storeNotFound src/hooks.server.ts0 # still the old code, despite a green deployThe fix is to force the service to recreate its container from the new image — a stop and start, or pin the image by digest instead of by tag.
Takeaways
- “Wildcard SSL” is three wildcards: DNS, edge certificate, and origin
certificate plus route. Debug them independently; the HTTP status tells you
which layer.
526is the origin certificate,404is the origin route, and anything after that is the app. - Cloudflare Origin CA gives you a free, long-lived wildcard certificate for the origin — the clean way to satisfy Full (strict) for every subdomain at once.
Host()versusHostRegexp(): a literal*.in a Traefik host rule matches nothing. Wildcard routing needs a regex matcher.- SSR plus a CDN means watching your relative fetches. A relative
/apifetch during server rendering resolves to your public origin and can hairpin out through your CDN, where your own server looks like a bot. UsehandleFetch, or the equivalent in your framework, to send server-side calls straight to the backend. - Verify the running code, not the deploy status. A green build is not a swapped container. Same-tag images do not re-pull.
The end state: a wildcard that provisions DNS, SSL and routing for every tenant automatically, and an SSR path that talks to the backend directly instead of taking the scenic route through the CDN.
Figure manifest
Screenshots to capture, then wire in place of the SCREENSHOT: placeholders
above. Sanitise each one — crop or redact real IPs, container IDs, account
emails and dashboard URLs before publishing.
526-headers— terminal: theHTTP/1.1 526response.origin-ca— Cloudflare Create Origin Certificate, hostnamesexample.comand*.example.com.wildcard-domain— hosting panel Add-Domain dialog with the wildcard toggle on.container-hairpin— container console:via public/CDN: 403next todirect: 200.ip-access-rule— Cloudflare IP Access Rules with the origin IP set to Allow.