The Grade Never Moved
We ran testssl.sh 3.2 against nginx, Caddy, and Traefik three times each: stock TLS settings, Mozilla-style hardened settings, and hardened settings with one bad curve line. All nine runs scored 93 and an A+. Protocol 100, Key Exchange 90, Cipher Strength 90, every time.
Under that flat grade, a lot changed. Stock nginx offers 25 TLS 1.2 cipher suites, 14 without forward secrecy and 14 using CBC. After hardening it offers 3. The score stayed at 93. The bad curve line did something worse: it removed post-quantum key exchange from all three proxies, and the grade stayed at 93 again. A rating that cannot see either change cannot be your only check.
This is Part 2 of a series. What Your Reverse Proxy Leaks by Default covered banners and catch-all servers. The baseline here is where Part 1 stopped: its hardened configs, TLS untouched.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/security/reverse-proxy-hardening/02-tls
How We Tested
One machine, local Docker, every port bound to 127.0.0.1, one RSA 2048 certificate from a throwaway CA shared by all three proxies. The probes are tls.sh (our openssl and curl script, nine sections, one handshake per suite) and testssl.sh 3.2 for its rating and client simulation. The versions: nginx 1.31.8 on OpenSSL 3.5.7, Caddy 2.11.7 on Go 1.26.8, Traefik v3.7.17 on Go 1.27.3. The client is OpenSSL 3.6.5.
Three runs per proxy:
- Baseline: Part 1’s hardened configs. TLS settings untouched.
- Hardened: the Part 2 changes below.
- Trap: hardened, plus one curve line copied from an older guide.
diff -r hardened/ trap/shows that one curve line per proxy, plus comment changes.
The limits are in the box at the top. The two that matter most: the leaf is RSA, so the three ECDSA suites in Mozilla’s list are configured but never offered, and the testssl.sh rating is its experimental one, not SSL Labs.
The Scorecard
| What we measured | nginx 1.31.8 | Caddy 2.11.7 | Traefik v3.7.17 |
|---|---|---|---|
| testssl.sh rating, all 3 runs | A+ (93) | A+ (93) | A+ (93) |
| TLS 1.0 / 1.1 | Refused | Refused | Refused |
| TLS 1.2 suites, baseline | 25 (14 no-FS, 14 CBC) | 3 | 5 (2 CBC) |
| TLS 1.2 suites, hardened | 3 | 3 | 3 |
| X25519MLKEM768 at baseline | Offered | Offered | Offered |
| X25519MLKEM768 with the old curve list | Refused | Refused | Refused |
| Who picks the TLS 1.2 suite | Client | Server | Server |
| TLS 1.2 tickets at baseline | Yes, 300 s hint | Yes | Yes |
| 0-RTT early data | Off | Off | Off |
| HSTS at baseline | None | None | None |
Read the first row against the rest. The one number that stays still is the grade.
Protocols: Nothing to Fix
All three refuse TLS 1.0 and 1.1 and offer 1.2 and 1.3 out of the box. There is no ssl_protocols line you need to add for safety on a current nginx. We pin TLSv1.2 TLSv1.3 in the hardened config anyway, so the intent is written down where the next person can read it.
Cipher Suites: 25 Down to 3
nginx’s default ssl_ciphers is HIGH:!aNULL:!MD5, from the nginx docs. With our RSA certificate on OpenSSL 3.5.7, that default accepts 25 TLS 1.2 suites. Eleven give forward secrecy. Fourteen do not, meaning an attacker who gets your private key later can decrypt traffic they recorded earlier. Fourteen use CBC mode (CBC suites are the family behind the LUCKY13 timing attack). testssl.sh marks LUCKY13 as “potentially VULNERABLE” on stock nginx and stock Traefik, and “not vulnerable” on Caddy and on all three hardened configs.
Caddy’s defaults already offer 3 suites, all forward secret and all AEAD (GCM or ChaCha20). Traefik offers 5, and 2 of them are CBC.
Mozilla’s intermediate profile (guidelines v6.0, fetched 2026-11-03) lists 6 TLS 1.2 suites: 3 ECDSA and 3 RSA. With an RSA certificate, only the 3 RSA ones can be offered. That is what all three proxies offer after hardening:
ECDHE-RSA-AES256-GCM-SHA384ECDHE-RSA-CHACHA20-POLY1305ECDHE-RSA-AES128-GCM-SHA256An ECDSA certificate swaps which three you see. TLS 1.3 suites are not part of this list. All three proxies already offer the same three, and Go does not let you configure them.
What the cut cost
We diffed the testssl.sh client simulation (35 simulated clients) between baseline and hardened. On nginx and Traefik, three clients went from a connection to “No connection”: IE 11 on Windows 7, IE 11 on Windows 8.1, and IE 11 on Windows Phone 8.1. At baseline they connected with CBC or no-forward-secrecy suites. Caddy already refused those three at baseline. Every client that connected at baseline still connects, including IE 11 on Windows 10 and the Android 7.0 simulation. (IE 8 on Windows 7 and Java 7u25 failed in every run, baseline included.)
If a Windows 7 box is in your user base, you have bigger problems than a cipher list. For a home lab, this is a cost of zero.
Post-Quantum Key Exchange: Already On
All three proxies negotiate X25519MLKEM768 with a default OpenSSL 3.6.5 client at baseline. That is a hybrid key exchange: classical X25519 plus the ML-KEM-768 post-quantum algorithm, so recorded traffic stays protected even if a future quantum computer breaks the classical half. In the testssl.sh client simulation, Chromium 137, Firefox 137, Edge 133, and Android 15 all negotiate it. Older browsers fall back to plain X25519. Traefik also offers SecP256r1MLKEM768 (testssl.sh lists it under “KEMs offered”).
You do not have to do anything to get this.
The Trap: One Curve Line
Older hardening guides carry a curve list from before post-quantum groups existed. Mozilla’s guidelines v5.7 is the last version without X25519MLKEM768, and its list is X25519:prime256v1:secp384r1. We copied that into each proxy’s config, one line each:
nginx: ssl_ecdh_curve X25519:prime256v1:secp384r1;Caddy: curves x25519 secp256r1 secp384r1Traefik: curvePreferences: [X25519, CurveP256, CurveP384]Results, same on all three:
tls.shreports X25519MLKEM768 as refused, and a default OpenSSL 3.6.5 client gets plain X25519.- testssl.sh reports “KEMs offered: None”.
- The client simulation shows Chromium 137, Firefox 137, Edge 133, and Android 15 dropping to plain X25519.
- The grade: 93, A+.
Nothing breaks. Every client still connects. The only thing that disappears is a protection you never knew you had, and no scanner grade tells you.
For the Go proxies there is a mechanism behind this. Go 1.24 turned on X25519MLKEM768 by default when Config.CurvePreferences is nil. Go 1.26 added SecP256r1MLKEM768 and SecP384r1MLKEM1024 to the defaults. Setting CurvePreferences to a list that does not name them disables all of them, per the Go release notes. Caddy’s docs list x25519mlkem768 among the allowed curves values and say “It is recommended to not change the defaults.”
The fix differs per proxy:
- nginx: list X25519MLKEM768 first.
- Caddy and Traefik: leave the curve list unset.
One oddity in the stock behavior: Caddy’s default group list is narrower than the others. At baseline it accepts X25519MLKEM768, X25519, and P-256, and refuses P-384 and P-521. Traefik and nginx accept both. Caddy sets its own default list (X25519MLKEM768, X25519, P-256) in its caddytls module, so it skips Go’s newer defaults. That is also why Caddy does not offer SecP256r1MLKEM768 and Traefik does. No client we simulated cares.
nginx and older OpenSSL
The nginx fix has a catch. We ran the hardened curve list on the nginx:1.26-bookworm image, which ships OpenSSL 3.0.15. It fails the config test:
nginx: [emerg] SSL_CTX_set1_curves_list("X25519MLKEM768:X25519:prime256v1:secp384r1") failed (SSL: error:0A080106:SSL routines::passed invalid argument:group 'X25519MLKEM768' cannot be set)nginx: configuration file /etc/nginx/nginx.conf test failednginx needs OpenSSL 3.5 or newer for that group. The nginx 1.31.8 image ships 3.5.7 and starts fine. Run nginx -V and check the “built with OpenSSL” line before you paste the list. Run nginx -t before you reload, always.
nginx: The Hardened TLS Block
These lines sit at http level, so every server block inherits them. Copied from the kit, with the session-cache comment trimmed:
ssl_protocols TLSv1.2 TLSv1.3;# Needs OpenSSL 3.5+ for X25519MLKEM768; nginx refuses to start on older OpenSSL.ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;# TLS 1.2 only. TLS 1.3 suites are set separately and the defaults are fine.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;ssl_prefer_server_ciphers off;ssl_session_cache shared:TLS:10m;ssl_session_timeout 1d;ssl_session_tickets off;And in the app.example.test server block:
add_header Strict-Transport-Security "max-age=63072000" always;The always makes the header ride on error responses too. Without it, nginx only adds the header to success and redirect responses.
Caddy: One Line
Caddy’s TLS defaults already line up with Mozilla intermediate on what matters: 3 AEAD suites, X25519MLKEM768 on, TLS 1.2 and 1.3. The only change is HSTS:
app.example.test { tls /certs/app.crt /certs/app.key header Strict-Transport-Security "max-age=63072000" # ...the Part 1 lines stay as they were}The tls line is there only because the lab uses a local CA. With a public hostname, Caddy gets its own certificate and you drop it.
Traefik: Options and a Middleware
Traefik takes TLS settings from a tls.options block in the dynamic file, and HSTS from a headers middleware:
http: middlewares: hsts: headers: stsSeconds: 63072000
tls: options: default: sniStrict: true minVersion: VersionTLS12 # TLS 1.2 only. Go does not let you configure TLS 1.3 suites. cipherSuites: - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 # Leave curvePreferences unset.The middleware does nothing until a router uses it. In the kit’s Docker labels, the router line becomes:
- traefik.http.routers.app.middlewares=strip-banners@file,hsts@filestrip-banners@file is Part 1’s banner middleware. Skipping the hsts@file half is an easy way to ship a config that looks hardened and sends no header.
Who Picks the Cipher
Part 1 quoted testssl.sh’s “Has server cipher order? no” for nginx. Here is what we measured on this list. tls.sh sends two TLS 1.2 suites in both orders and checks what the server chooses. nginx follows the client’s order, which is what ssl_prefer_server_ciphers off means, and it is also Mozilla’s recommendation for the intermediate profile. Caddy and Traefik pick server-side: ECDHE-RSA-AES128-GCM-SHA256 both times.
We used two AES-GCM suites for that probe on purpose. Go honors a client that lists ChaCha20 first, so a ChaCha20 probe would show the client picking on Go too.
With a list that is all AEAD and all forward secret, who picks does not matter. Every option on the table is a good one. Server preference earned its reputation when lists mixed strong and weak suites.
Session Resumption
Stock nginx issues TLS 1.2 session tickets with a 300 s lifetime hint and has no session cache (ssl_session_cache defaults to none, ssl_session_timeout to 5 minutes). The nginx docs say that without ssl_session_ticket_key, “a randomly generated key is used”. Since nginx 1.23.2, keys rotate automatically only when ssl_session_cache uses shared memory. With a stock config, that does not apply. (testssl.sh printed “session tickets keys seems to be rotated < daily” for stock nginx. That is its guess from the 300 s hint. The nginx docs say a stock config uses one random key.)
Our hardened nginx does this instead:
ssl_session_cache shared:TLS:10m;ssl_session_timeout 1d;ssl_session_tickets off;Result: no TLS 1.2 ticket, TLS 1.2 resumption through the server-side cache (it resumed on reconnect), and TLS 1.3 still resumes with an 86400 s lifetime hint. The trade-off is memory: 10 MB of shared cache (about 40,000 sessions at the nginx docs’ 4,000 per megabyte), each valid for up to one day. Without tickets, a stolen ticket key stops being a thing that exists. (nginx 1.31.7 also fixed CVE-2026-97629, where ticket key rotation left one key uninitialized. We tested 1.31.8.)
Go behaves differently. Caddy and Traefik issue tickets, and the TLS 1.3 lifetime hint is 604800 s, seven days. Go’s docs say the auto-managed ticket keys rotate every day and are dropped after seven days.
One observation we could not explain. testssl.sh 3.2 reported “Tickets no, ID: no” for Caddy and Traefik. Yet openssl s_client resumed both on reconnect, for TLS 1.2 and TLS 1.3. We report that as an observation: testssl.sh and openssl disagree about Go servers, and we did not trace the cause. Trust the reconnect, not the label.
0-RTT Early Data
Off on all three. The tickets allow no early data, per tls.sh. nginx’s ssl_early_data defaults to off, and its docs warn that early data is subject to replay. Leave it off. A replayed POST /transfer behind a proxy only matters if the app behind it is not replay-safe, and from the proxy you cannot know that for every app you host.
HSTS: Off Everywhere, Not Required for the Grade
None of the three sends Strict-Transport-Security by default. testssl.sh still gave A+ with no HSTS at all, so the rating does not require it here. After hardening, testssl.sh reads “730 days=63072000 s, just this domain” on all three. That is Mozilla’s hsts_min_age of two years.
Notice the last words: just this domain. We did not add includeSubDomains or preload, and a home lab should hesitate before doing either. hstspreload.org requires max-age of at least 31536000, includeSubDomains, preload, and HTTPS on all subdomains. Its own note says: “HSTS preloading applies to all subdomains, including internal subdomains that are not publicly accessible.” If nas.home.example.com is a LAN-only service on plain HTTP, includeSubDomains on the parent domain makes browsers refuse to load it.
Start with the plain header. Add the rest only after you have listed every subdomain under the name.
OCSP Stapling: Dead Weight for Let’s Encrypt Certs
Let’s Encrypt removed OCSP URLs from its certificates on May 7, 2025, and shut down its OCSP responders on August 6, 2025. Revocation now goes through CRLs. Mozilla’s guidelines v6.0 JSON still says ocsp_staple: true, so a copy-paste from it carries stapling lines forward.
We checked what happens. ssl_stapling on with a certificate that has no OCSP URL makes nginx print "ssl_stapling" ignored, no OCSP responder URL in the certificate, then keep running. The config test passes. So the lines do no harm. They do nothing at all. Caddy’s docs list ocsp_stapling off as a global option. We did not test Caddy stapling.
The Certificate Key Type
Our lab certificate is RSA 2048, which is why the ECDSA suites never showed up. Real ACME certificates differ by proxy:
- Caddy issues ECDSA P-256 by default (certmagic’s default key generator).
- certbot has issued ECDSA P-256 since 2.0.0.
- Traefik’s ACME
keyTypedefaults toRSA4096, per its docs. SetkeyType: EC256if you want what Mozilla’s intermediate profile recommends.
We did not measure performance and make no claim about it.
Run It Against Your Own Proxy
cd sumguy-examples/security/reverse-proxy-hardening/02-tls./tls.sh 443 your.domain.example 203.0.113.10You need OpenSSL 3.5 or newer on the client. Older versions cannot offer X25519MLKEM768, so that line reads “refused” even when your server accepts it. The script runs one handshake per suite your OpenSSL knows, so expect about a minute on loopback. Part 1’s audit.sh covers banners and catch-all servers. Run both.
The result you want: 3 TLS 1.2 suites, no CBC, X25519MLKEM768 offered, 0-RTT off, an HSTS header, and a default client reporting the hybrid group.
The SumGuy Take
A rating tells you about the worst thing a scanner can see. It says nothing about the best thing you could lose. The practical order for a home lab:
- Check what
tls.shsays today. Caddy owners will find almost nothing to change. - On nginx, replace
ssl_ciphersand set the curve list with X25519MLKEM768 first, after confirming OpenSSL 3.5 or newer. - On Traefik, add the cipher list and leave
curvePreferencesalone. - Add the plain HSTS header on all three.
- If your certificates come from Let’s Encrypt, delete the OCSP stapling lines. They do nothing.
What Comes Next
The rest of the series:
- Part 3: security headers.
- Part 4: rate limits, including real client IPs behind a CDN.
- Part 5: hiding admin paths, including the Traefik dashboard behind auth.
Common Questions
Does Let’s Encrypt still support OCSP stapling?
No. Let’s Encrypt removed OCSP URLs from certificates on May 7, 2025 and shut down its OCSP responders on August 6, 2025. Let’s Encrypt now publishes CRLs for revocation. In our nginx check, ssl_stapling on with such a certificate logs a warning and is ignored.
Does Traefik support post-quantum TLS?
Yes, by default. Traefik v3.7.17 on Go 1.27.3 negotiated X25519MLKEM768 and also offered SecP256r1MLKEM768 in our tests. The trap is setting curvePreferences to an older list, which removes both: our trap run with [X25519, CurveP256, CurveP384] showed “KEMs offered: None”. Leave curvePreferences unset and Traefik keeps Go’s defaults.
Will HSTS break my LAN-only subdomains?
Plain max-age HSTS will not, because it covers only the exact host that sent it. Adding includeSubDomains or preload can, because browsers then require HTTPS on every subdomain, including internal ones served over plain HTTP. List your subdomains before adding either.
Is TLS 1.2 still safe to leave on in 2026?
Yes, with an AEAD-only forward-secret suite list. Our hardened configs offer 3 TLS 1.2 suites, all ECDHE with GCM or ChaCha20, and testssl.sh reports no LUCKY13 exposure. Turning TLS 1.2 off cuts older clients: in our simulation, Android 7.0 and 8.1, Edge 15, IE 11 on Windows 10, and Apple Mail 16.0 negotiate TLS 1.2.
Do I need ssl_session_tickets off on nginx?
Yes, unless you already run a shared session cache. Without one, nginx encrypts tickets with a randomly generated key and only rotates ticket keys when the cache uses shared memory. A stolen key decrypts every recorded TLS 1.2 connection that got a ticket under it, including the first handshake. Tickets off plus a shared cache still resumes sessions.