Tested on: Ubuntu 24.04 LTS, Nginx 1.26.x (from
nginx.orgstable repository), Let’s Encrypt viacertbot2.x. The same config works on RHEL 9 / AlmaLinux 9 with path adjustments noted inline.
Why this matters
Most Nginx TLS configurations on the internet were copy-pasted from a tutorial written three to seven years ago. That means they typically have one or more of the following problems:
- TLS 1.0 and 1.1 still enabled “for compatibility” with browsers that no longer exist.
- A hand-rolled cipher list that excludes modern AEAD suites or includes long-deprecated ones (3DES, RC4, CBC-mode without AEAD).
ssl_prefer_server_ciphers on— which was correct advice once but is now the wrong default for TLS 1.3.- No OCSP stapling, so every visitor’s browser does a side-channel OCSP lookup to the CA on first connection.
- HSTS missing, or HSTS set without
includeSubDomainson a domain that has subdomains people forgot about. - HTTP-only
Strict-Transport-Securityheader (it must be served over HTTPS to be honoured at all).
This guide gives you a single, opinionated baseline that addresses all of the above. It targets the Mozilla “Intermediate” profile — broad client compatibility without enabling anything embarrassing.
1. Get a certificate (ECDSA preferred)
ECDSA P-256 certificates are smaller, faster, and supported everywhere that matters in 2026. Use them as your default; only issue a dual RSA + ECDSA pair if you have evidence of clients that genuinely need RSA.
1# Ubuntu 24.04 — install certbot from snap (or the nginx-plugin apt package).
2sudo apt update && sudo apt install -y certbot python3-certbot-nginx
3
4# Request an ECDSA certificate. --key-type ecdsa is the certbot 2.x default
5# but state it explicitly so the choice is visible in change history.
6sudo certbot certonly --nginx \
7 --key-type ecdsa \
8 --elliptic-curve secp256r1 \
9 -d example.com -d www.example.com
On RHEL 9 / AlmaLinux 9 the package is also certbot + python3-certbot-nginx
from EPEL, and certificate paths under /etc/letsencrypt/live/ are identical.
2. The baseline server block
Drop this in /etc/nginx/conf.d/example.com.conf (or
/etc/nginx/sites-available/example.com on Debian-flavoured layouts):
1# Redirect plain HTTP to HTTPS, including ACME challenges.
2server {
3 listen 80;
4 listen [::]:80;
5 server_name example.com www.example.com;
6
7 # Let certbot's webroot challenges through unredirected.
8 location /.well-known/acme-challenge/ {
9 root /var/www/_letsencrypt;
10 }
11
12 location / {
13 return 301 https://$host$request_uri;
14 }
15}
16
17server {
18 listen 443 ssl;
19 listen [::]:443 ssl;
20 http2 on;
21 server_name example.com www.example.com;
22
23 # ---- Certificate ----
24 ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
25 ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
26
27 # ---- Protocols & ciphers (Mozilla Intermediate, 2026) ----
28 ssl_protocols TLSv1.2 TLSv1.3;
29 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;
30 ssl_prefer_server_ciphers off; # See "Gotchas" below.
31 ssl_ecdh_curve X25519:secp384r1:secp256r1;
32
33 # ---- Session resumption ----
34 ssl_session_cache shared:SSL:10m; # ~40k sessions
35 ssl_session_timeout 1d;
36 ssl_session_tickets off; # Forward-secrecy hygiene.
37
38 # ---- OCSP stapling ----
39 ssl_stapling on;
40 ssl_stapling_verify on;
41 ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
42 resolver 1.1.1.1 9.9.9.9 valid=300s;
43 resolver_timeout 5s;
44
45 # ---- Headers (TLS-relevant subset) ----
46 # 2 years; submit to hstspreload.org once you're confident.
47 add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
48 add_header X-Content-Type-Options "nosniff" always;
49 add_header Referrer-Policy "strict-origin-when-cross-origin" always;
50
51 # ---- App proxy goes here ----
52 location / {
53 proxy_pass http://unix:/run/yourapp/yourapp.sock;
54 proxy_set_header Host $host;
55 proxy_set_header X-Real-IP $remote_addr;
56 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
57 proxy_set_header X-Forwarded-Proto $scheme;
58 }
59}
3. Apply and verify
1sudo nginx -t && sudo systemctl reload nginx
2
3# Confirm only TLS 1.2 and 1.3 are offered.
4nmap --script ssl-enum-ciphers -p 443 example.com | grep -E 'TLSv|cipher'
5
6# Confirm OCSP stapling is working (look for "OCSP response: ... successful").
7echo | openssl s_client -connect example.com:443 -servername example.com -status 2>/dev/null \
8 | grep -A 5 'OCSP response:'
9
10# Full external scan.
11# https://www.ssllabs.com/ssltest/analyze.html?d=example.com
12# Target: A+. Anything below A indicates a real misconfiguration.
Gotchas
ssl_prefer_server_ciphers off is intentional
Old guides set this to on. With TLS 1.3 the server has no cipher
preference to express — the suites are fixed and equivalent in strength.
With TLS 1.2 and a modern client list, the client’s preference is usually
the better choice (it knows whether it has hardware AES). Setting it on
forces server-side ordering, which today does nothing useful and obscures
intent.
HSTS preload is one-way
Adding preload to the header and submitting your domain to
hstspreload.org hard-codes HTTPS into browsers for years. Removing the
domain takes months and only works if browser vendors agree. Do not
add preload until you are certain every subdomain — current and future —
is HTTPS-capable. The config above deliberately omits preload; add it
after a few months of clean operation.
includeSubDomains covers subdomains you’ve forgotten
If staging.example.com or legacy.example.com is still HTTP-only,
includeSubDomains will break it as soon as a browser sees the parent
HSTS header. Audit your DNS before enabling.
ssl_session_tickets off trades CPU for forward secrecy
Session tickets are encrypted with a key that, by default, never rotates.
A compromise of that key retroactively breaks forward secrecy for every
resumed session. The reconnect cost without tickets is small for most
sites; if you serve very high-RPS clients, see Nginx’s
ssl_session_ticket_key rotation for a middle ground.
OCSP stapling needs a working resolver
resolver must be a DNS server Nginx can actually reach. 127.0.0.53 (the
systemd-resolved stub) does not always work for stapling — use a real
upstream as shown.
Companion artefacts
- The TLS config generator at tools.stackharden.com produces this config for a given domain and backend (planned).
- The same baseline runs in front of the tools subdomain itself.
What this guide deliberately does not cover
- Rate limiting, request size limits, and WAF —
guides/nginx-ratelimit(planned). - mTLS for service-to-service traffic —
guides/nginx-mtls(planned). - HTTP/3 / QUIC — stable in 1.25+ but operationally distinct enough to warrant its own writeup.