backend / protocols / nginx / 12_tls_configuration.md

Nginx TLS Configuration

6 interview angles 6 min read source

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.” Always listen 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 incompletefullchain.pem includes intermediate; cert.pem doesn’t. Always use fullchain.pem (Let’s Encrypt) or concatenate intermediate manually.
  • resolver missing for OCSP stapling — stapling silently doesn’t work; check nginx -V and error logs.

Common interview confusions

  • ssl_prefer_server_ciphers on is more secure.” — TLS 1.3 ignores it; TLS 1.2 modern clients have sane preferences. off is 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 shared do?” — 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 preload and 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.