Tested on: Ubuntu 24.04 LTS, Nginx 1.26.x (from nginx.org stable repository), Let’s Encrypt via certbot 2.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 includeSubDomains on a domain that has subdomains people forgot about.
  • HTTP-only Strict-Transport-Security header (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 WAFguides/nginx-ratelimit (planned).
  • mTLS for service-to-service trafficguides/nginx-mtls (planned).
  • HTTP/3 / QUIC — stable in 1.25+ but operationally distinct enough to warrant its own writeup.