Getting a certificate is the easy part. The real work is configuring Nginx so that your TLS actually protects users: modern cipher suites, OCSP stapling that does not silently break, HSTS that does not lock you out, and HTTP/3 for the performance win. This guide walks through a production-ready setup step by step.
We are using Nginx 1.26+ on Ubuntu 24.04 LTS or later. The same principles apply to RHEL/AlmaLinux/Rocky with minor path differences. If you are on an older Nginx, check your version's module support before enabling HTTP/3.
1. Get your certificate with Certbot
Let's Encrypt remains the standard for free, automated certificates. Install Certbot and obtain a cert:
# Ubuntu/Debian sudo apt install certbot python3-certbot-nginx # RHEL/AlmaLinux/Rocky sudo dnf install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.comCertbot will modify your Nginx config to add the certificate paths. Verify that auto-renewal is working:
sudo certbot renew --dry-run The renewal timer (systemctl list-timers | grep certbot) should already be active. If it is not, enable certbot.timer manually.
2. TLS protocol and cipher configuration
TLS 1.2 and 1.3 are the only protocols you should have enabled in 2026. TLS 1.0 and 1.1 have been formally deprecated since RFC 8996 (2021), and every major browser dropped support years ago.
Create a shared TLS snippet at /etc/nginx/snippets/tls-params.conf:
ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; # TLS 1.2 ciphers — AEAD only, no CBC 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; # TLS 1.3 ciphers are configured in OpenSSL, not here. # Nginx uses OpenSSL defaults (AES-256-GCM, AES-128-GCM, ChaCha20) which are correct. ssl_session_timeout 1d; ssl_session_cache shared:TLS:10m; ssl_session_tickets off; # Use a strong DH group for TLS 1.2 key exchange ssl_dhparam /etc/nginx/dhparam.pem; ssl_ecdh_curve X25519:secp256r1:secp384r1;
Generate the DH parameters file (this takes a minute):
sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048Why ssl_prefer_server_ciphers off? With TLS 1.3, cipher preference is always the client's. For TLS 1.2, modern clients negotiate sensible ciphers, and letting them choose allows hardware-accelerated AES-GCM on clients that support it. If you have a strong reason to override client preference (legacy compliance requirements), set it to on.
Why disable session tickets? Session tickets reuse a server-side key that, if compromised, breaks forward secrecy for all sessions encrypted with that key. If you need session resumption at scale, rotate ticket keys frequently via a cron job or use TLS 1.3's built-in PSK mechanism.
3. OCSP stapling
Without OCSP stapling, every visitor's browser has to contact the CA's OCSP responder to check if your certificate is revoked. This adds latency and leaks your visitors' browsing habits to the CA. Stapling lets Nginx fetch the OCSP response and serve it during the TLS handshake.
Add to your TLS snippet or server block:
ssl_stapling on; ssl_stapling_verify on; # The full chain file that includes your CA's intermediate cert ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem; # Nginx needs a resolver for OCSP fetches resolver 1.1.1.1 8.8.8.8 valid=300s; resolver_timeout 5s;
Common mistake: pointing ssl_trusted_certificate at fullchain.pem instead of chain.pem. The ssl_trusted_certificate directive needs the CA chain (intermediates + root), not your server cert. With Let's Encrypt, chain.pem is the correct file.
Common mistake: forgetting the resolver directive. Without it, Nginx cannot resolve the CA's OCSP responder hostname and stapling silently fails. You will see no error in the logs — just no stapled response.
Test stapling after reloading Nginx:
# Reload first sudo nginx -t && sudo systemctl reload nginx # Wait a few seconds for Nginx to fetch the OCSP response, then test openssl s_client -connect example.com:443 -status < /dev/null 2>&1 | grep -A 2 "OCSP Response"
You should see OCSP Response Status: successful. If it says no response sent, check your resolver and trusted certificate path.
Ref: man openssl-s_client, Nginx docs for ngx_http_ssl_module
4. HSTS (HTTP Strict Transport Security)
HSTS tells browsers to only connect over HTTPS for a specified duration. This prevents SSL-stripping attacks and eliminates the HTTP-to-HTTPS redirect round trip after the first visit.
# Add to your HTTPS server block add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Be careful with this. Once a browser receives this header, it will refuse to connect over plain HTTP for the entire max-age duration (two years in this example). If you later lose your certificate or need to revert to HTTP, visitors will be locked out until the max-age expires or they manually clear the HSTS entry.
Recommended rollout:
- Start with a short
max-age(e.g. 300 seconds / 5 minutes) and test. - Increase to a day (86400), then a week (604800), then a month.
- Once stable, set to two years (63072000) and add
includeSubDomains. - Only add
preloadafter you are certain. Submit tohstspreload.org— removal from the preload list takes months.
Test your HSTS header:
curl -sI https://example.com | grep -i strict5. HTTP/2
HTTP/2 gives you multiplexed streams, header compression, and server push over a single connection. On Nginx 1.26+, enabling it is a one-liner:
listen 443 ssl; http2 on;
Note: older guides show listen 443 ssl http2; — this syntax was deprecated in Nginx 1.25.1. Use the separate http2 on; directive instead.
Verify HTTP/2 is working:
curl -sI --http2 https://example.com | head -1 You should see HTTP/2 200. If you see HTTP/1.1, check that your Nginx was compiled with HTTP/2 support (nginx -V 2>&1 | grep http_v2).
6. HTTP/3 (QUIC)
HTTP/3 runs over QUIC (UDP), eliminating TCP head-of-line blocking and reducing connection setup time. Nginx 1.25+ includes experimental HTTP/3 support. On Ubuntu 24.04+, the nginx package is built with QUIC support via the quictls fork of OpenSSL.
Add a QUIC listener alongside your existing TCP listener:
listen 443 ssl; listen 443 quic reuseport; http2 on; http3 on; # Tell browsers that HTTP/3 is available add_header Alt-Svc 'h3=":443"; ma=86400' always;
Important: the reuseport parameter should only appear on one server block per port. If you have multiple virtual hosts on port 443, only the default server gets reuseport.
Firewall: QUIC uses UDP port 443. You must allow this in your firewall:
# ufw sudo ufw allow 443/udp comment 'QUIC/HTTP3' # firewalld sudo firewall-cmd --add-port=443/udp --permanent sudo firewall-cmd --reload
Common mistake: forgetting to open UDP 443 in the firewall and then wondering why HTTP/3 does not work. Browsers fall back to HTTP/2 silently, so you may not notice for weeks.
Test HTTP/3 with curl (7.88+ with HTTP/3 support):
curl -sI --http3-only https://example.com | head -1 If your curl does not support --http3-only, use browser DevTools: open the Network tab, enable the Protocol column, and look for h3.
Ref: Nginx QUIC docs, man curl
7. HTTP-to-HTTPS redirect
Always redirect plain HTTP to HTTPS. A separate server block keeps it clean:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}Use 301 (permanent), not 302 (temporary). A 301 tells browsers and search engines that the HTTPS URL is canonical.
8. Putting it all together
Here is a complete server block that includes everything covered above:
server {
listen 443 ssl;
listen [::]:443 ssl;
listen 443 quic reuseport;
listen [::]:443 quic reuseport;
http2 on;
http3 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include snippets/tls-params.conf;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
root /var/www/example.com/public;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}Test and reload:
sudo nginx -t sudo systemctl reload nginx
9. Testing your configuration
Do not trust your config until you verify it from the outside. Run these checks after every TLS change:
Check certificate chain and expiry:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates -subject -issuer
Check supported protocols and ciphers:
# Using nmap's ssl-enum-ciphers script nmap --script ssl-enum-ciphers -p 443 example.com
Check OCSP stapling:
echo | openssl s_client -connect example.com:443 -status 2>/dev/null | grep "OCSP Response Status"
Check HSTS header:
curl -sI https://example.com | grep -i strict-transportCheck HTTP/2 and HTTP/3:
# HTTP/2 curl -sI --http2 https://example.com | head -1 # HTTP/3 (requires curl 7.88+ with QUIC) curl -sI --http3-only https://example.com | head -1
For a comprehensive external audit, use SSL Labs (ssllabs.com/ssltest). Aim for an A+ rating. If you have HSTS with a long max-age, you will get it with this configuration.
10. Common mistakes and troubleshooting
- Mixed content warnings after enabling HTTPS: your HTML references HTTP resources (images, scripts, stylesheets). Audit your site with
grep -r 'http://' /var/www/or use browser DevTools console. Use protocol-relative URLs or just switch tohttps://. - OCSP stapling fails intermittently: Nginx fetches the OCSP response lazily on the first client connection after a reload. The first visitor may not get a stapled response. This is normal behavior. For high-traffic sites, it resolves within seconds.
- "SSL routines:ssl3_read_bytes:tlsv1 alert protocol version" errors: a client is trying TLS 1.0 or 1.1, which you have correctly disabled. This is expected. If you need to support very old clients (you almost certainly do not), check your access logs before making exceptions.
- Certificate renewal fails silently: test renewal with
certbot renew --dry-runand monitor expiry dates. Set up a simple cron alert:echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -enddate - HTTP/3 not working despite correct config: check that UDP 443 is open in your firewall and at your cloud provider's security group / network ACL level. Cloud firewalls are separate from
ufworfirewalld.
Where to go from here
This setup gives you a solid, modern TLS configuration. For a deeper walkthrough on SSL hardening — including certificate transparency monitoring, CAA DNS records, and advanced cipher tuning — the guide at letsecure.me covers the full hardening picture.
Revisit your TLS configuration at least once a year. Cipher recommendations evolve, protocols get deprecated, and new attacks surface. Subscribe to the Nginx security advisories and your distro's security mailing list to stay ahead of it.
This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License .