One Let's Encrypt Cert for Many Subdomains: nginx + certbot SAN Setup
Issue a single Let's Encrypt SAN certificate covering many subdomains with nginx and certbot. Renewal, rate limits, and deploy hooks for solo operators.
One Let's Encrypt Cert for Many Subdomains: nginx + certbot SAN Setup
Running five staging subdomains on a single VPS, each with its own server block, used to mean five separate certificate files, five renewals to monitor, and five chances for something to silently expire on a Sunday. A single Subject Alternative Name (SAN) certificate collapses that to one file, one cron job, one reload. For a solo operator with a polyglot stack on one box, that's a real ops win — fewer moving parts means fewer 3 AM alerts.
This walks through issuing a multi-subdomain SAN cert with certbot, wiring it into nginx, handling renewal cleanly, and avoiding the rate-limit traps that catch first-timers.
SAN cert vs wildcard vs per-domain
Three options exist for covering multiple subdomains, and the right pick depends on how often you add new ones.
A per-domain certificate issues one cert per subdomain. Simple to reason about, but you pay the rate-limit cost every time you add a new site. Renewal also fires once per cert, multiplying nginx reloads.
A wildcard certificate (*.example.com) covers any subdomain under one apex. Convenient, but requires DNS-01 challenge — your DNS provider needs an API certbot can talk to, or you script the TXT record dance manually. Works well if you're spinning up subdomains weekly.
A SAN certificate lists each subdomain explicitly inside one cert. You issue once with -d gamedev.example.com -d python.example.com -d aiagent.example.com, and Let's Encrypt returns a single cert valid for all three. HTTP-01 challenge works fine because each subdomain validates independently. Renewal touches one file. The trade-off: adding a new subdomain means re-issuing the cert with the expanded -d list, which counts against the rate limit.
For a stable set of three to ten subdomains that rarely changes, SAN wins. Above ten, or if subdomains churn weekly, switch to wildcard.
Issuance: the certbot command
Assuming nginx is already serving HTTP-01 challenge files from /var/www/html/.well-known/acme-challenge/, the issuance command looks like this:
sudo certbot certonly \
--webroot \
--webroot-path /var/www/html \
--email you@example.com \
--agree-tos \
--no-eff-email \
-d gamedev.example.com \
-d python.example.com \
-d aiagent.example.com \
-d rust.example.com \
-d typescript.example.com
Use certonly rather than --nginx plugin if you want full control over the nginx config. The plugin rewrites your server blocks, which fights you when you have hand-tuned TLS settings. With certonly, certbot just gets the cert and drops it in /etc/letsencrypt/live/<first-domain>/.
The directory takes its name from the first -d flag. So in the example above, your cert lives at /etc/letsencrypt/live/gamedev.example.com/fullchain.pem even though it covers four other subdomains. Pin a stable "primary" subdomain as the first -d and never reorder them — certbot sometimes creates a new directory with a -0001 suffix on re-issuance if it can't match the existing one.
Before running for real, dry-run with --staging. The staging environment uses a fake CA but exercises the full code path, and it has dramatically looser rate limits.
nginx server blocks pointing at one cert
Each subdomain gets its own server block, all pointing at the same fullchain.pem and privkey.pem:
server {
listen 443 ssl http2;
server_name gamedev.example.com;
ssl_certificate /etc/letsencrypt/live/gamedev.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gamedev.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/gamedev;
index index.html;
}
server {
listen 443 ssl http2;
server_name python.example.com;
ssl_certificate /etc/letsencrypt/live/gamedev.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gamedev.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/python;
index index.html;
}
Yes, both blocks reference the gamedev.example.com directory. That's correct — the cert inside covers both names via SAN. nginx selects the right server block by server_name and Host header; the cert just needs to be valid for whatever the client requested.
A common mistake is using regex server_name (server_name ~^(.+)\.example\.com$;) for all subdomains in one block. That works, but TLS handshake happens before nginx evaluates regex variables, so the cert path can't depend on a captured group. Use static blocks per subdomain even if the body is identical — duplication beats a 3 AM debugging session.
Renewal without surprise downtime
certbot installs a systemd timer (certbot.timer) on most distributions that runs certbot renew twice daily. Renewal triggers when a cert is within 30 days of expiry. Verify the timer:
systemctl list-timers certbot.timer
The renewal itself is silent if nothing's due. When a cert renews, nginx needs to reload to pick up the new file. Add a deploy hook to the cert's renewal config at /etc/letsencrypt/renewal/gamedev.example.com.conf:
[renewalparams]
renew_hook = systemctl reload nginx
Or pass --deploy-hook 'systemctl reload nginx' once during issuance and certbot persists it. Use reload, not restart — reload re-reads config without dropping connections. Restart drops in-flight requests for ~50ms during the bind, which is enough to fail health checks during deploys.
Test the full renewal path without waiting 60 days:
sudo certbot renew --dry-run
This hits the staging CA, validates each subdomain, and runs the deploy hook. If any subdomain's HTTP-01 challenge fails (DNS misconfigured, nginx not serving the well-known path), the dry run catches it.
Rate limits and how to not blow through them
Let's Encrypt's production CA enforces several limits documented at https://letsencrypt.org/docs/rate-limits/. The two that bite SAN-cert users:
- 50 certificates per registered domain per week. A SAN cert with five subdomains counts as one cert against this limit, so you'd need 50 issuances of any cert under
example.comto hit it. - 5 duplicate certificates per week. This one matters: re-issuing the same exact set of subdomains five times in seven days hits the wall. If you're testing your issuance script, use
--staginguntil the script works.
The third limit worth knowing is 300 new orders per account per 3 hours. You'll only hit this if you're scripting issuance in a tight loop without --staging, but it's recoverable in three hours rather than one week.
If you do hit a production limit, switch to staging, fix the issue, then issue once cleanly. Don't retry the same failed issuance — it counts against the limit each time.
Adding a subdomain later
When you add a sixth subdomain, re-run the issuance with the expanded list:
sudo certbot certonly \
--webroot --webroot-path /var/www/html \
--cert-name gamedev.example.com \
-d gamedev.example.com \
-d python.example.com \
-d aiagent.example.com \
-d rust.example.com \
-d typescript.example.com \
-d devops.example.com
The --cert-name flag tells certbot to expand the existing cert rather than create a new one with a -0001 suffix. Without it, you end up with two cert directories and nginx pointing at the stale one. Verify with certbot certificates after — it lists every cert and the domains each covers.
When to walk away from SAN
If you're managing 30+ subdomains, or if subdomains spin up and tear down weekly (preview deploys, per-tenant subdomains), the SAN approach gets painful. Each addition means a full re-issuance, and the cert eventually approaches the 100-domain SAN limit Let's Encrypt enforces. For that scale, wildcard via DNS-01 with a provider like Cloudflare or Route 53 + the corresponding certbot DNS plugin (https://github.com/certbot/certbot/tree/main/certbot-dns-cloudflare) is the right move. You give up explicit subdomain enumeration, but you also stop touching the cert when you add new sites.
For everyone else running a stable handful of staging subdomains on one box, SAN is the calmest path: one file, one renewal, one reload, one thing to monitor.
References:
- https://eff-certbot.readthedocs.io/en/stable/using.html
- https://letsencrypt.org/docs/rate-limits/
- https://nginx.org/en/docs/http/configuring_https_servers.html
- https://github.com/certbot/certbot