Web Servers & Reverse Proxies

TLS Termination & HTTPS Config

18 min Lesson 5 of 28

TLS Termination & HTTPS Config

Running plaintext HTTP in production is not a configuration choice — it is a security liability. TLS is the cryptographic layer that authenticates your server to clients and encrypts every byte in transit. In a modern DevOps stack, Nginx almost always acts as the TLS termination point: it decrypts incoming HTTPS traffic, then forwards plain HTTP (or Unix-socket traffic) to your application backends running on localhost or an internal network where encryption would be redundant overhead.

This lesson goes beyond "drop in a ssl_certificate line." You will learn how to obtain certificates, write a production-grade Nginx TLS block, enforce redirects, configure HSTS, tune cipher suites, and avoid the failure modes that silently degrade security or cause client connection errors at 3 a.m.

How TLS Termination Works

TLS termination request flow Client Browser / curl HTTPS :443 encrypted Nginx TLS Termination • Decrypts TLS • Validates cert • Forwards plain HTTP HTTP :8080 plaintext (LAN) App Backend Node / Gunicorn / PHP-FPM App Backend Node / Gunicorn / PHP-FPM Certificate Store cert.pem + key.pem
TLS terminates at Nginx; backends receive unencrypted traffic on a trusted internal path.

Obtaining Certificates with Certbot (Let's Encrypt)

Let's Encrypt issues free, 90-day DV (Domain Validated) certificates that are trusted by all major browsers. certbot automates issuance and renewal. For a fresh server:

# Install certbot and the Nginx plugin (Debian/Ubuntu) sudo apt install -y certbot python3-certbot-nginx # Issue a cert and let certbot auto-edit your Nginx config sudo certbot --nginx -d example.com -d www.example.com # Verify auto-renewal (runs twice daily via systemd timer or cron) sudo certbot renew --dry-run # Renewal timer status sudo systemctl status certbot.timer
Production tip — wildcard certs: For many subdomains, use a wildcard cert (*.example.com) via the DNS-01 challenge instead of HTTP-01. This requires a DNS provider plugin (e.g. certbot-dns-cloudflare) but eliminates the need for a publicly reachable HTTP server during renewal, which matters for internal services. Store wildcard certs centrally and distribute them to all Nginx nodes via a secrets manager or a dedicated certificate tool like step-ca.

A Production-Grade Nginx TLS Block

Never accept the bare-minimum config that "just works." Every setting below has a concrete reason.

# /etc/nginx/sites-available/example.com # 1. Redirect all HTTP to HTTPS — no exceptions server { listen 80; listen [::]:80; server_name example.com www.example.com; # Redirect with 301 (permanent) so browsers cache and stop sending HTTP return 301 https://$host$request_uri; } # 2. HTTPS server block server { listen 443 ssl; listen [::]:443 ssl; http2 on; # HTTP/2 multiplexing (Nginx >= 1.25.1 syntax) server_name example.com www.example.com; # --- Certificate paths (managed by certbot) --- ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # --- Protocol versions: TLS 1.2 minimum, 1.3 preferred --- ssl_protocols TLSv1.2 TLSv1.3; # --- Cipher suites: forward-secret only; disable RC4, 3DES, NULL --- 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; # TLS 1.3 ignores this; good default for 1.2 # --- Session resumption: reduce handshake overhead --- ssl_session_cache shared:SSL:10m; # ~40 000 sessions; shared across workers ssl_session_timeout 1d; ssl_session_tickets off; # Tickets weaken forward secrecy; disable # --- OCSP Stapling: serve cert revocation status inline --- ssl_stapling on; ssl_stapling_verify on; resolver 1.1.1.1 8.8.8.8 valid=300s; resolver_timeout 5s; # --- HSTS: tell browsers to always use HTTPS for 1 year --- add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; # --- Additional security headers --- add_header X-Frame-Options DENY always; add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; root /var/www/example.com/public; index index.html; location / { try_files $uri $uri/ =404; } }
Why ssl_session_tickets off? Session tickets encrypt the session state with a server-side key. If that key is ever compromised, an attacker can decrypt all past traffic (no forward secrecy). Disabling tickets forces Nginx to use its in-memory session cache, which is per-worker-set and rotates naturally. Most production configs at Google, Cloudflare, and Mozilla disable tickets for this reason.

HTTP Strict Transport Security (HSTS)

HSTS is an HTTP response header that instructs browsers to never connect to your domain over plain HTTP — not even for the very first request after the max-age expires. Once a browser has seen the HSTS header, it will internally redirect http:// to https:// before sending any bytes on the wire, eliminating the window for SSL-stripping attacks.

  • max-age=31536000 — one year; browsers remember this for 365 days after the last response.
  • includeSubDomains — extends the policy to every subdomain. Only add this after every subdomain has a valid certificate.
  • preload — opts your domain into browser-vendor HSTS preload lists (ships inside Chrome, Firefox, Safari). Submit at hstspreload.org. This is essentially permanent — removal takes months.
HSTS preload is a one-way door. If you add preload and submit to the list, then later need to move back to HTTP (e.g. for a staging domain), browsers will refuse to connect for up to a year. Only enable preload on domains you are certain will serve HTTPS forever.

Validating Your TLS Configuration

After applying config, test before assuming anything is correct. Two essential checks:

# 1. Nginx config syntax check — always run before reload sudo nginx -t # 2. Reload (zero-downtime) after a clean test sudo systemctl reload nginx # 3. Test TLS handshake from the command line openssl s_client -connect example.com:443 -servername example.com < /dev/null # 4. Check which protocol and cipher were negotiated curl -vI https://example.com 2>&1 | grep -E "SSL|TLSv|cipher" # 5. Grade your config externally (run from a developer machine) # Visit: https://www.ssllabs.com/ssltest/analyze.html?d=example.com # Target: A+ rating. Common reasons for losing points: # - TLS 1.0/1.1 still enabled → remove from ssl_protocols # - Session tickets enabled → add ssl_session_tickets off # - Missing HSTS → add the header # - OCSP stapling not working → check resolver + ssl_stapling_verify

Common Failure Modes

  • Mixed content warnings — your HTML loads over HTTPS but embeds http:// resource URLs. The browser blocks or warns. Fix: ensure all asset URLs use https:// or protocol-relative //. Configure your app framework to generate HTTPS URLs when behind a proxy (APP_URL=https://..., FORCE_HTTPS=true, or Laravel's URL::forceScheme('https')).
  • Certificate chain incomplete — servers must send the full chain (leaf + intermediates). Use fullchain.pem, not cert.pem. Mobile clients in particular fail silently when the intermediate is missing.
  • Expired certificate — Let's Encrypt certs expire after 90 days. Certbot's systemd timer renews at 60 days, but if the timer stops (system reboot with failed services), you get a 60-day runway to notice. Monitor expiry: certbot certificates or an external monitor like UptimeRobot's SSL check.
  • Wrong server_name — Nginx serves the first matching server block if no server_name matches, which can serve the wrong certificate. Always set an explicit default server or verify the block ordering.
Automate certificate monitoring in CI/CD: Add a post-deploy step that runs openssl s_client -connect $HOST:443 < /dev/null 2>&1 | openssl x509 -noout -dates and alerts if notAfter is within 30 days. This catches renewal failures before users do.