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.pem contains 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_ciphers only affects TLS 1.2 and below.
  • ssl_session_tickets off avoids 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
ChallengeNeedsWorks with
HTTP-01Port 80 reachableAny server, no DNS API
DNS-01A DNS API tokenWildcards, internal servers
TLS-ALPN-01Port 443 with no proxyServers that cannot expose port 80
WildcardDNS-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.

Load balancing and upstreams Rate limiting, access control and security headers

Last refreshed 2026-09-18.