TLS hardening and certificates with Let's Encrypt
Certificate chains, modern protocol and cipher choices, session caching, OCSP stapling, HSTS, and automated issuance and renewal with certbot.
A modern TLS configuration
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # let the client choose for TLS 1.3
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
}fullchain.pemcontains the leaf plus intermediates; serving only the leaf makes some clients fail with an incomplete-chain error that browsers often hide.- TLS 1.3 cipher suites are fixed and not configurable, so
ssl_ciphersonly affects TLS 1.2 and below. ssl_session_tickets offavoids a forward-secrecy trade-off unless you rotate ticket keys.- OCSP stapling needs a working resolver and a chain file, or nginx silently serves without a stapled response.
Issuing and renewing with certbot
# webroot: keep port 80 serving, solve the challenge from a directory
sudo certbot certonly --webroot -w /var/www/acme -d example.com -d www.example.com
# nginx plugin: certbot edits your config for you
sudo certbot --nginx -d example.com -d www.example.com
# dry run the renewal path before you rely on it
sudo certbot renew --dry-run
# what is installed and when does it expire
sudo certbot certificates
# reload nginx only when a certificate actually changed
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# #!/bin/sh
# nginx -t && systemctl reload nginx| Challenge | Needs | Works with |
|---|---|---|
| HTTP-01 | Port 80 reachable | Any server, no DNS API |
| DNS-01 | A DNS API token | Wildcards, internal servers |
| TLS-ALPN-01 | Port 443 with no proxy | Servers that cannot expose port 80 |
| Wildcard | DNS-01 only | *.example.com |
Let's Encrypt certificates last 90 days and renewals run on a timer. The failure mode is not an expired certificate — it is a renewal that has been failing silently for weeks, so alert on the expiry date as well as on the timer.
HSTS and redirect strategy
# redirect http to https in one place
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ { root /var/www/acme; }
location / { return 301 https://example.com$request_uri; }
}
server {
listen 443 ssl;
server_name example.com;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# add preload only once you are certain every subdomain supports https
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}⚠️
HSTS with
preload is effectively irreversible: browsers ship a list that forces HTTPS and refuses to let the user click through. Start with a short max-age, confirm every subdomain works, then extend it.FAQ
Why does curl fail with a certificate error when the browser is fine?
The common cause is a missing intermediate certificate. Browsers cache or fetch intermediates, while command-line clients and many API consumers do not. Serve
fullchain.pem.Should I terminate TLS at nginx or at a load balancer?
Terminating once, as close to the edge as your architecture allows, keeps certificate management in one place. If you terminate twice, make sure the inner hop is also encrypted and that the original scheme is forwarded in a header.
Related
Load balancing and upstreams Rate limiting, access control and security headers
Last refreshed 2026-09-18.