Per-tenant subdomains with NGINX wildcard routing

NGINX
Multi-tenancy
DNS
How wildcard DNS and a single NGINX server block give every tenant their own subdomain, without a config entry per tenant.
Author

Lahiru De Silva

Published

August 22, 2026

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.

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"]

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.com answers for alice.example.com but not for a.b.example.com.
  • An explicit record always beats the wildcard. If www.example.com has 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:

  1. Exact names
  2. Leading wildcards, *.example.com
  3. Trailing wildcards, example.com.*
  4. 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.

WarningThe subdomain is a routing hint, not authorization

$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.