Skip to content

Wildcard Subdomain Routing

A ProxPanel SaaS cluster serves an arbitrary number of tenants from a single set of containers. Every tenant gets a stable subdomain — acme.saas.proxrad.com, bigisp.saas.proxrad.com, demo.saas.proxrad.com — and the request path from the user’s browser through DNS, Cloudflare, nginx, and into the API picks the right tenant at each hop. This page documents that path end-to-end, the Cloudflare grey-cloud requirement that makes RADIUS and WireGuard work alongside HTTP, and the operational gotchas.

Browser: https://acme.saas.proxrad.com/api/subscribers
|
v
DNS: *.saas.proxrad.com → <saas-server> (wildcard A record, grey-clouded)
|
v
Direct TCP/443 → SaaS host (no Cloudflare proxy)
|
v
nginx: server_name ~^(?<tenant>[a-z0-9\-]+)\.saas\.proxrad\.com$
| $tenant = "acme"
| proxy_pass http://proxpanel-api:8080
| sets X-Forwarded-Host: acme.saas.proxrad.com
v
TenantMiddleware (in API container):
hostname = "acme.saas.proxrad.com"
strip suffix ".saas.proxrad.com" → slug = "acme"
lookup tenants WHERE subdomain = 'acme' → schema_name = 'tenant_acme'
bind GetTenantDB('tenant_acme') to c.Locals("tenant_db")
|
v
Handler runs against tenant_acme schema

Every hop is independent. nginx never opens a connection to the database; the API never resolves DNS. The chain is debuggable one component at a time.

saas.proxrad.com is hosted on Cloudflare. Two records do all the work:

TypeNameValueProxy state
Asaas.proxrad.com<saas-server>DNS only (grey cloud)
A*.saas.proxrad.com<saas-server>DNS only (grey cloud)

The wildcard means every undefined subdomain resolves to the same SaaS host. A new tenant landing in tenants.subdomain is immediately reachable — DNS has nothing to do because the record already covers *.

The SaaS host runs Let’s Encrypt with the DNS-01 challenge to obtain a wildcard certificate for *.saas.proxrad.com plus the apex saas.proxrad.com. The cert covers every tenant subdomain without needing a per-tenant cert request.

Renewal is automated by certbot inside the API container, daily at 03:30 cluster local time. DNS-01 requires writing a TXT record at _acme-challenge.saas.proxrad.com and removing it after validation — the API container holds a Cloudflare API token (scoped only to that zone) to do this.

Tenant custom domains are different — they get their own certificate via HTTP-01, see Tenant Onboarding.

The nginx server block that handles tenant requests:

server {
listen 443 ssl http2;
server_name ~^(?<tenant>[a-z0-9\-]+)\.saas\.proxrad\.com$;
ssl_certificate /etc/letsencrypt/live/saas.proxrad.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/saas.proxrad.com/privkey.pem;
# Pass through to the API
location /api/ {
proxy_pass http://proxpanel-api:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Serve the React app
location / {
root /opt/proxpanel/frontend/dist;
try_files $uri /index.html;
}
}

Key points:

  • server_name ~^(?<tenant>...)\.saas\.proxrad\.com$ — the regex captures the subdomain into a named capture group. nginx exposes this as $tenant for use later in the config if needed.
  • Host header is preserved — the API sees the original hostname (acme.saas.proxrad.com), which the tenant resolver needs.
  • The regex disallows uppercase, dots, underscores, and consecutive dashes — same character class as the slug-validation regex on the signup form, so any subdomain accepted at signup is matched here.

A request for acme.saas.proxrad.com/login is served by this block. A request for an unmatched hostname (e.g. attacker.example.com aimed at the same IP) falls through to a default server block that returns a 404 — nothing about the cluster is exposed.

The super-admin console (saas.proxrad.com itself) is a separate server block that does not use the regex:

server {
listen 443 ssl http2;
server_name saas.proxrad.com;
# super-admin React app + /api/saas/* endpoints
...
}

That separation is deliberate — the super-admin URL must never collide with a tenant subdomain, even if a tenant somehow registered the slug www or api (the reserved-words list at signup prevents this).

Once the request reaches the API container, TenantMiddleware in backend/internal/middleware/tenant.go extracts the tenant:

  1. Read c.Hostname() — Fiber returns acme.saas.proxrad.com. Port is stripped if present.

  2. Check the cache. tenantCache.Load(host) returns a cachedTenant (id, schema name, status) if seen in the last 60 seconds. Cache hit → skip to step 5.

  3. Subdomain match. If the host ends in .saas.proxrad.com, the slug is strings.TrimSuffix(host, ".saas.proxrad.com"). Look up WHERE subdomain = 'acme' AND status != 'deleted'.

  4. Custom-domain match. Otherwise, look up WHERE custom_domain = 'panel.myisp.com' AND status != 'deleted'.

  5. Status check. If tenant.status = 'suspended' → 403. If deleted → 404. Otherwise continue.

  6. Bind context. c.Locals("tenant_id", id), c.Locals("tenant_schema", schemaName), c.Locals("tenant_db", database.GetTenantDB(schemaName)). The handler picks these up.

The cache is invalidated explicitly when a tenant is created, its custom domain changes, or its status changes. See InvalidateTenantCache(host) in the same file.

A tenant can simultaneously be reachable at both acme.saas.proxrad.com (always) and panel.myisp.com (after onboarding their custom domain). The custom domain is added to tenants.custom_domain and the second nginx server block — bound to the specific custom hostname — is generated by the tenant-onboarding code on certificate issue.

Both routes resolve to the same tenant ID, same schema. There is no separation between the two — branding, data, and admin users are identical. A user who logs in at acme.saas.proxrad.com and one who logs in at panel.myisp.com get the same data.

DNS, TLS, and nginx need nothing done. The wildcard A record covers the new subdomain, the wildcard cert covers it, and the regex server block matches it. Onboarding only writes to the tenants table and creates the schema — see Tenant Onboarding.

This is a breaking operation:

  1. Tenant emails everyone with bookmarks pointing to the old subdomain.
  2. Super-admin updates tenants.subdomain from the console.
  3. The cache for both old and new hostnames is invalidated.
  4. Existing JWTs continue to work — the JWT’s tenant_id matches regardless of hostname.
  5. The old subdomain will resolve via wildcard DNS to the same SaaS host, but the API’s tenant resolver will return 404 because there is no tenant with that slug.

You cannot redirect the old slug to the new one — there is no nginx state that maps the old name to the new one. Tenants who change their slug must notify their users out-of-band.

Migrating tenants to a different SaaS cluster

Section titled “Migrating tenants to a different SaaS cluster”
  1. Spin up the new cluster, point its *.saas.proxrad.com at the new IP via Cloudflare.
  2. Pre-migrate the tenant’s data into the new cluster (under the same slug).
  3. Flip the Cloudflare A record. DNS TTL is 60 seconds, so cutover is fast.
  4. Old cluster’s tenant rows go to status = migrated so the resolver returns a redirect notice.

The wildcard makes mass-cluster migration mechanically simple.

SymptomCauseFix
New tenant subdomain returns 404tenants.status != 'active', or tenantCache hasn’t refreshedForce invalidate via super-admin console, or wait 60 seconds
All tenants return 502nginx can reach but API container is downdocker logs proxpanel-api, check the host’s resource pressure
TLS handshake fails with unknown nameWildcard cert expired or renewal failedCheck certbot renew --dry-run, check the Cloudflare token used for DNS-01
RADIUS UDP packets droppedCloudflare orange-clouded the SaaS hostnameSwitch the A record back to grey-cloud
Custom domain returns the wildcard cert (browser warning)Custom-domain HTTP-01 cert not issued yetWait 60 seconds, then check /var/log/letsencrypt/ for failures

The routing layer enforces no permissions of its own — it routes the request and lets the API decide. Inside the API, the JWT carries the tenant ID and the auth middleware checks it against the hostname-derived tenant. A token issued to tenant A cannot be replayed against tenant B even if the attacker manipulates DNS to point at the cluster.