Nginx TLS Configuration
The bare-minimum config “just works”; the production config has 8–12 directives that matter for security, performance, and grade. Below is a modern, sensible setup plus the rationale.
For TLS fundamentals (handshake, cert chain, mTLS) see ../../28_networking/12_tls_https_certificates.md. This file is nginx-specific.
Modern config that scores A+
server {
listen 443 ssl http2;
listen [::]:443 ssl http2; # IPv6
server_name api.example.com;
# Cert + key
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
# Protocols — TLS 1.2 and 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;
# Ciphers — modern Mozilla intermediate set
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off; # let client pick (TLS 1.3 ignores this anyway)
# Session resumption
ssl_session_cache shared:SSL:10m; # 10MB ≈ 40k sessions
ssl_session_timeout 1d;
ssl_session_tickets off; # disable; tickets weaken forward secrecy without rotation
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/api.example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# HSTS — force HTTPS, opt into preload list carefully
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Common security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ... your locations ...
}
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
Use Mozilla’s SSL Configuration Generator — picks the right cipher list for your nginx version and target browser support. Don’t hand-curate cipher strings.
What each directive does
ssl_protocols
| Protocol | Status |
|---|---|
| SSLv2, SSLv3 | broken; never enable |
| TLSv1.0, TLSv1.1 | deprecated; PCI/banks already disabled them |
| TLSv1.2 | universally supported, secure |
| TLSv1.3 | faster (1-RTT), better, supported by all modern clients |
TLSv1.2 TLSv1.3 is the modern baseline. Drop TLSv1.2 only if you can guarantee no legacy clients (rare).
Ciphers
In TLS 1.3, the cipher choice is mostly fixed (5 AEAD ciphers, all good). ssl_ciphers only affects TLS 1.2.
For TLS 1.2, prefer ECDHE (forward secrecy) + AES-GCM or ChaCha20-Poly1305 (AEAD). The Mozilla “intermediate” set is the default; use “modern” if you can drop TLS 1.2.
ssl_prefer_server_ciphers
In TLS 1.2, this lets the server pick (client’s preference ignored). Set to off since modern clients have sane preferences and TLS 1.3 ignores it anyway.
Session resumption — speeding up reconnects
Full TLS handshake = 1–2 RTTs. Session resumption skips it:
ssl_session_cache shared:SSL:10m; # store session params, share across workers
ssl_session_timeout 1d;
shared makes the cache visible across all worker processes (one connection’s resumption can be picked up by any worker on the next request).
ssl_session_tickets off — tickets are an alternate resumption mechanism but use long-lived keys that, if leaked, retroactively break forward secrecy. Disable unless you rotate ticket keys. TLS 1.3 has its own resumption that doesn’t have this issue.
OCSP stapling
OCSP (Online Certificate Status Protocol) lets clients check if a cert is revoked. Without stapling, the client makes its own OCSP request to the CA — slow and privacy-leaking.
Stapling: nginx fetches the OCSP response periodically and includes it in the TLS handshake. Client doesn’t need to phone home.
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/.../chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver is required — nginx uses it to look up the OCSP responder hostname.
HSTS — Strict-Transport-Security
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Tells browsers “always use HTTPS for this domain for the next 2 years.”
| Token | Effect |
|---|---|
max-age=63072000 |
2 years (the recommended duration) |
includeSubDomains |
applies to all subdomains too |
preload |
opts into the browser-shipped HSTS list (semi-irreversible — think before adding) |
preload is hard to undo. Once you’re on the list, browsers ship the rule for a long time even if you stop sending the header. Only add when you’re certain HTTPS is working everywhere.
always on add_header
add_header ... always ensures the header is included even on error responses (4xx/5xx). Without always, error pages might miss your security headers — leaving a gap.
Cert renewal — Let’s Encrypt
certbot --nginx -d api.example.com
# certbot edits your nginx config and obtains the cert
# Renew (cron / systemd timer auto-runs daily):
certbot renew
certbot reloads nginx automatically after renewal. The default 90-day cert renews at 60 days. Don’t override the renewal job — let it run.
Multi-domain / SAN / wildcard
For multiple server_names on one cert (SAN):
certbot --nginx -d example.com -d www.example.com -d api.example.com
For wildcards (*.example.com), DNS-01 challenge required:
certbot certonly --manual --preferred-challenges=dns -d "*.example.com"
(Or use certbot-dns-route53 / certbot-dns-cloudflare plugins for automated DNS challenges.)
TLS 1.3 0-RTT (early data)
ssl_early_data on;
proxy_set_header Early-Data $ssl_early_data;
Lets resumed TLS 1.3 connections send data in the first packet (0 RTT). Faster but vulnerable to replay attacks for non-idempotent requests. Only safe for GETs and known-idempotent operations.
SNI — multiple sites, one IP
server {
listen 443 ssl;
server_name a.example.com;
ssl_certificate /etc/ssl/a.pem;
ssl_certificate_key /etc/ssl/a.key;
}
server {
listen 443 ssl;
server_name b.example.com;
ssl_certificate /etc/ssl/b.pem;
ssl_certificate_key /etc/ssl/b.key;
}
Nginx looks at the SNI extension in ClientHello to pick the right cert. Works for all clients since ~2010.
For TLS 1.3 + ECH (Encrypted ClientHello), SNI is hidden — rolling out gradually.
mTLS — client cert auth
ssl_client_certificate /etc/nginx/ca.crt; # CA that signed clients
ssl_verify_client on; # require client cert
# or:
ssl_verify_client optional; # request but don't require
# Make the cert info available to upstream:
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
For service-to-service auth replacing API keys with crypto identity. Cost: cert distribution / rotation. Service meshes (Istio, Linkerd) handle this for you.
Performance: HTTP/2 and HTTP/3
listen 443 ssl http2; # HTTP/2
listen 443 quic reuseport; # HTTP/3 (QUIC over UDP) — needs nginx 1.25+
add_header Alt-Svc 'h3=":443"; ma=86400'; # tell clients HTTP/3 is available
HTTP/2 multiplexes many requests over one connection — big win for browsers loading many resources.
HTTP/3 (over QUIC over UDP) avoids TCP head-of-line blocking — faster on lossy networks (mobile). Requires UDP/443 open in firewalls.
Test your config
# Local syntax test
nginx -t
# Check what's actually negotiated
openssl s_client -connect api.example.com:443 -servername api.example.com
# Comprehensive scan — Qualys SSL Labs
# https://www.ssllabs.com/ssltest/analyze.html?d=api.example.com
SSL Labs grade A+ is the bar. Below A is a config bug.
Common pitfalls
- Listen on 443 without
ssl— connections fail with “wrong version number.” Alwayslisten 443 ssl http2;. - Forgetting
ssl_certificate_key— nginx errors on startup; can’t serve TLS. - Using
ssl_protocols TLSv1 TLSv1.1 TLSv1.2— including deprecated protocols. Drop TLSv1 and TLSv1.1. - No
add_header ... always— security headers missing on 4xx/5xx responses. - Cert chain incomplete —
fullchain.pemincludes intermediate;cert.pemdoesn’t. Always usefullchain.pem(Let’s Encrypt) or concatenate intermediate manually. resolvermissing for OCSP stapling — stapling silently doesn’t work; checknginx -Vand error logs.
Common interview confusions
- “
ssl_prefer_server_ciphers onis more secure.” — TLS 1.3 ignores it; TLS 1.2 modern clients have sane preferences.offis the modern recommendation. - “HSTS preload is reversible.” — technically yes via the form, but it propagates slowly and stays in browsers’ built-in lists for a long time. Treat as semi-permanent.
- “OCSP stapling is automatic.” — requires explicit config (
ssl_stapling on,resolver,ssl_trusted_certificate).
Interview angle
- “How would you set up HTTPS in nginx for a Python app today?” — Let’s Encrypt cert via certbot,
listen 443 ssl http2, TLS 1.2/1.3 only, modern Mozilla cipher list, OCSP stapling, HSTS header. Drop in Mozilla’s SSL config generator output. - “What versions of TLS would you enable?” — TLS 1.2 and 1.3. SSL/TLS <1.2 are deprecated.
- “What’s OCSP stapling and why use it?” — server fetches certificate revocation status periodically and bundles it in the TLS handshake, so clients don’t need to query the CA themselves. Faster and more private.
- “What does
ssl_session_cache shareddo?” — caches TLS session parameters across worker processes so reconnections skip the full handshake. Big latency win for repeat clients. - “Why disable
ssl_session_tickets?” — tickets use long-lived keys; if leaked, past sessions can be decrypted (no forward secrecy unless you rotate ticket keys regularly). TLS 1.3 has a better resumption mechanism that doesn’t need this. - “What’s HSTS’s
preloadand why be careful?” — opts into the browser-shipped HSTS list. Once added, browsers enforce HTTPS for that domain for a long time even if you stop sending the header. Hard to back out — don’t add until you’re sure.