flowchart LR
C["Browser<br/>alice.example.com"] -->|"1 · DNS<br/>*.example.com"| D["Wildcard A record<br/>→ 203.0.113.10"]
D -->|"2 · HTTPS"| N["NGINX<br/>server_name regex<br/>captures 'alice'"]
N -->|"3 · proxy_pass<br/>X-Tenant: alice"| A["App backend<br/>one deployment"]
Per-tenant subdomains with NGINX wildcard routing
Tumblr gives every blog its own subdomain, and so does every SaaS that hands you yourcompany.app.com on signup. None of them add a vhost per customer. The whole thing is a wildcard DNS record plus one server block that treats the subdomain as a variable.
Wildcard DNS
A single wildcard A record answers for every name at that level:
*.example.com. 300 IN A 203.0.113.10
Every subdomain now resolves to the same address, so adding a tenant needs no DNS change at all. Two things to know about the matching:
- A wildcard covers exactly one label.
*.example.comanswers foralice.example.combut not fora.b.example.com. - An explicit record always beats the wildcard. If
www.example.comhas its own A record, that record wins and the wildcard never applies to it.
The NGINX side
NGINX matches server_name with a regex when the value starts with ~, and PCRE named captures become ordinary variables you can use anywhere in the block:
# Tenant sites. Matches any single-label subdomain and captures it as $tenant.
server {
listen 443 ssl;
server_name ~^(?<tenant>[^.]+)\.example\.com$;
ssl_certificate /etc/nginx/certs/wildcard.example.com.crt;
ssl_certificate_key /etc/nginx/certs/wildcard.example.com.key;
location / {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Tenant $tenant;
proxy_set_header X-Real-IP $remote_addr;
}
}
[^.]+ is doing real work there. Without it the pattern would also match a.b.example.com and capture a.b, which is not a tenant and which wildcard DNS would not have resolved anyway.
Reserved subdomains come for free
In Apache you exclude www from the rewrite with a negative RewriteCond before the rule. In NGINX you do not need a guard at all, because server_name matching has a fixed precedence:
- Exact names
- Leading wildcards,
*.example.com - Trailing wildcards,
example.com.* - Regular expressions, in the order they appear in the config
Regexes come last, so any exact block wins automatically:
# Wins over the regex block above purely by being an exact match.
server {
listen 443 ssl;
server_name www.example.com example.com;
# ... marketing site ...
}
Add api.example.com or admin.example.com the same way and they are carved out of the tenant space with no change to the wildcard block.
Getting the tenant to the application
Passing it as a header keeps the upstream URL clean and works with any framework:
proxy_set_header X-Tenant $tenant;
The rewrite-to-query-parameter style works too, if the app already expects it:
proxy_pass http://app_backend/user?id=$tenant;
proxy_set_header replaces any header of that name from the client, so a request carrying its own X-Tenant cannot spoof one. That is worth checking rather than assuming. Passing the client’s value through instead is a tenant isolation bug of the worst kind.
$tenant is attacker-controlled: anyone can request someoneelses-tenant.example.com. It tells the application which tenant is being asked for, never which tenant the caller is entitled to. The session still has to be checked against it on every request.
TLS is the part that actually costs you
One certificate covers the whole scheme, but a wildcard certificate cannot be issued over the HTTP-01 challenge. Let’s Encrypt requires DNS-01 for *.example.com, which means your ACME client needs API credentials for the DNS provider. That is the real setup cost here, and it is worth sorting out before the routing rather than after.
Note that *.example.com does not cover the apex example.com, so the certificate needs both names on it.