We have by far the largest RPM repository with NGINX module packages and VMODs for Varnish. If you want to install NGINX, Varnish, and lots of useful performance/security software with smooth
Active subscription is required.
yum upgrades for production use, this is the repository for you.
Active subscription is required.
📅 Updated: September 2, 2026 (Originally published: January 21, 2026)
NGINX TLS 1.3 correctly is essential for achieving an A+ rating on SSL Labs while maximizing both security and performance. This guide walks you through every step of hardening your NGINX TLS configuration, from the cipher suite up to post-quantum key exchange.
Why TLS 1.3 Matters for Your NGINX Server
TLS 1.3 isn’t just an incremental update; it’s a fundamental improvement in how encrypted connections work. Here’s what makes it essential:- Faster handshakes: TLS 1.3 reduces the handshake from two round-trips to just one, improving Time to First Byte (TTFB)
- Stronger security: Removed support for legacy algorithms like RSA key exchange, RC4, SHA-1, and CBC mode ciphers
- Forward secrecy by default: Every connection uses ephemeral keys, so compromising your private key doesn’t expose past traffic
- Simplified cipher suites: Only five secure cipher suites, eliminating configuration mistakes
Prerequisites
Before hardening NGINX TLS 1.3, ensure your system meets these requirements:- NGINX 1.13.0+ (for TLS 1.3 support; 1.20+ recommended)
- OpenSSL 1.1.1+ (the minimum version with TLS 1.3 support)
- A valid SSL certificate (from Let’s Encrypt or a commercial CA)
nginx -v
openssl version
On RHEL 9, Rocky Linux 9, or AlmaLinux 9, the default packages meet these requirements:
dnf install nginx openssl
For the latest NGINX mainline (1.28.x) with all the newest features and optimizations, use the GetPageSpeed repository:
dnf install https://extras.getpagespeed.com/release-latest.rpm
dnf install nginx
Understanding Mozilla’s SSL Configurations
Mozilla maintains the definitive SSL Configuration Generator that provides three security profiles:| Profile | TLS Versions | Use Case |
|---|---|---|
| Modern | TLS 1.3 only | Maximum security, modern clients only |
| Intermediate | TLS 1.2 + 1.3 | Balanced security and compatibility |
| Old | TLS 1.0+ | Legacy compatibility (avoid if possible) |
Step 1: Create the SSL Hardening Configuration
Create a dedicated configuration file for your TLS settings. This keeps your SSL configuration modular and easy to maintain.Modern Configuration (TLS 1.3 Only)
For maximum security when you don’t need legacy client support:# /etc/nginx/conf.d/ssl-hardening.conf
# Mozilla Modern Configuration - TLS 1.3 Only
server_tokens off;
ssl_protocols TLSv1.3;
# The leading "?" makes an unsupported group non-fatal. See "Post-Quantum
# Key Exchange" below before you copy this - it is not optional syntax.
ssl_ecdh_curve ?X25519MLKEM768:X25519:prime256v1:secp384r1;
ssl_prefer_server_ciphers off;
# OCSP Stapling (for CAs that support it - see note below)
ssl_stapling on;
ssl_stapling_verify on;
# 127.0.0.1 = a caching resolver running on this host (unbound, dnsmasq).
# Do NOT copy 127.0.0.53 onto RHEL/Rocky/AlmaLinux - see "Resolver Security
# Considerations" below.
resolver 127.0.0.1 valid=300s;
resolver_timeout 5s;
With TLS 1.3, you don’t need to specify ssl_ciphers because the protocol only supports secure AEAD ciphers. The ssl_prefer_server_ciphers off directive is correct here, because TLS 1.3 clients are trusted to choose appropriate ciphers.
Intermediate Configuration (TLS 1.2 + 1.3)
For broader compatibility while maintaining strong security:# /etc/nginx/conf.d/ssl-hardening.conf
# Mozilla Intermediate Configuration - TLS 1.2 + 1.3
server_tokens off;
ssl_protocols TLSv1.2 TLSv1.3;
# See "Post-Quantum Key Exchange" below before copying this line.
ssl_ecdh_curve ?X25519MLKEM768:X25519:prime256v1:secp384r1;
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:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
# Session settings (required for TLS 1.2)
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# DH parameters for DHE ciphers
ssl_dhparam /etc/nginx/dhparam.pem;
# OCSP Stapling (for CAs that support it - see note below)
ssl_stapling on;
ssl_stapling_verify on;
# See "Resolver Security Considerations" - 127.0.0.53 is a systemd-resolved
# address and does not exist on RHEL, Rocky Linux or AlmaLinux.
resolver 127.0.0.1 valid=300s;
resolver_timeout 5s;
Post-Quantum Key Exchange: X25519MLKEM768
A recorded TLS session that is safe today is not safe forever. An adversary can capture your encrypted traffic now and decrypt it once a cryptographically relevant quantum computer exists. That is the “harvest now, decrypt later” attack. Classical key exchange such as X25519 is exactly what that attack targets, and it is the one part of your TLS stack that needs fixing before the threat arrives, not after. The answer is a hybrid group. X25519MLKEM768 combines classical X25519 with ML-KEM-768, the NIST-standardized post-quantum KEM (FIPS 203). Hybrid means an attacker must break both to recover the session key, so you lose nothing if either one later disappoints. Chrome and Firefox enable it by default, so a meaningful share of your visitors already offer it. In NGINX it is one directive:ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;
Warning: This Line Can Stop NGINX From Starting
Do not paste that directly into a production server.ssl_ecdh_curve is passed straight to OpenSSL’s SSL_CTX_set1_curves_list(), and OpenSSL rejects the entire list if it does not recognize a single one of the groups. NGINX treats that rejection as NGX_LOG_EMERG (see src/event/ngx_event_openssl.c), so this is not a warning you can ignore. The master process refuses to start:
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 failed
ML-KEM groups need OpenSSL 3.5 or newer. Anything older (OpenSSL 3.0 on Debian 12 and Ubuntu 24.04, 3.0/3.2 on RHEL 9, or a QUIC-patched OpenSSL fork) does not know the name and takes the whole server down with it. Most post-quantum NGINX tutorials publish that bare line with no version gate at all.
Check the Right OpenSSL Version
This is the trap that catches careful administrators.openssl version reports the OpenSSL your shell uses, which has nothing to do with the library NGINX is linked against. On a stock Rocky Linux 10 box the two disagree completely:
# The system OpenSSL - looks perfectly modern
openssl version
# OpenSSL 3.5.1 1 Jul 2025
# What NGINX actually uses - three major versions behind
nginx -V 2>&1 | grep -o "built with OpenSSL[^)]*"
# built with OpenSSL 3.1.7+quic 3 Sep 2024
That server would report OpenSSL 3.5.1 and still fail to start with the directive above. Only the nginx -V output matters.
The Safe Form: the ? Prefix
OpenSSL 3.3 added a ? prefix that marks a group as optional, so an unrecognized name is skipped instead of failing the list:
ssl_ecdh_curve ?X25519MLKEM768:X25519:prime256v1:secp384r1;
This is the form used throughout this guide, because it starts on every build. But understand precisely what you bought: the ? prefix prevents an outage, it does not give you post-quantum protection. On a build without ML-KEM the group is dropped in complete silence. nginx -t emits zero warnings, the error log stays clean, and you serve classical-only key exchange while believing you are protected. A client that offers only X25519MLKEM768 simply gets a handshake failure:
SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure:SSL alert number 40
Negotiated TLS1.3 group: <NULL>
The ? prefix itself needs OpenSSL 3.3+. On OpenSSL 3.0 or 3.2 it is not a rescue either: there, the only safe configuration is to leave ML-KEM out of the list entirely.
Group names are case-insensitive, so x25519mlkem768 and X25519MLKEM768 behave identically. Do not waste time on that.
Verify That It Is Actually On
Never assume. Ask the server which group it negotiated, using a client that is itself OpenSSL 3.5+:openssl s_client -connect example.com:443 -tls1_3 < /dev/null 2>/dev/null \
| grep "Negotiated TLS1.3 group"
Negotiated TLS1.3 group: X25519MLKEM768
If that line is absent or names X25519, post-quantum key exchange is not active, regardless of what your configuration file says. Our free SSL/TLS test runs the same probe from the outside and reports the negotiated group along with the rest of your TLS posture.
Getting an OpenSSL 3.5 NGINX
Post-quantum key exchange is the one part of NGINX TLS 1.3 hardening you cannot solve inside the configuration file, because it depends on what your binary was linked against. No enterprise distribution ships an NGINX linked against OpenSSL 3.5 yet. Our packages do:dnf install https://extras.getpagespeed.com/release-latest.rpm
dnf install nginx
nginx -V 2>&1 | grep -o "built with OpenSSL[^)]*"
# built with OpenSSL 3.5.8+gps 25 Aug 2026
With that build the unprefixed list is safe and the handshake really does negotiate X25519MLKEM768. The same OpenSSL 3.5 builds are now available for Debian and Ubuntu, and they are what makes Encrypted Client Hello possible on the same server.
Step 2: Generate DH Parameters (Intermediate Configuration Only)
If you’re using the Intermediate configuration with DHE ciphers, you need Diffie-Hellman parameters. Mozilla provides pre-generated, safe parameters:curl https://ssl-config.mozilla.org/ffdhe2048.txt -o /etc/nginx/dhparam.pem
Using Mozilla’s pre-generated parameters is recommended over generating your own because:
- They’re cryptographically verified safe primes
- No risk of weak parameters from poor entropy during generation
- Consistent across deployments
openssl dhparam -out /etc/nginx/dhparam.pem 2048
Step 3: Configure Your Server Block
Apply the SSL configuration to your server block. The syntax differs depending on your NGINX version:NGINX 1.20.x – 1.24.x (RHEL 9 default)
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
server_tokens off;
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;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
root /var/www/example.com;
index index.html;
}
# HTTP to HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
NGINX 1.25.1+ (GetPageSpeed repo)
In newer NGINX versions, HTTP/2 is enabled with a separate directive:server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com www.example.com;
server_tokens off;
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;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
root /var/www/example.com;
index index.html;
}
# HTTP to HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Important Notes on the Configuration
Theserver_tokens off directive prevents NGINX from disclosing its version number in error pages and the Server response header. This is a basic security hardening measure that reduces information disclosure to potential attackers.
The ssl_trusted_certificate directive is required for OCSP stapling to work with CAs that support OCSP. It should point to the certificate chain file (intermediate certificates).
If you’re interested in the latest protocols, you can also enable HTTP/3 (QUIC) on NGINX for even better performance.
Step 4: Configure HSTS (HTTP Strict Transport Security)
HSTS tells browsers to only connect to your site over HTTPS, preventing downgrade attacks and SSL stripping:add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
The parameters explained:
| Parameter | Value | Meaning |
|---|---|---|
max-age |
63072000 | Browser remembers HTTPS-only for 2 years |
includeSubDomains |
– | Applies to all subdomains |
preload |
– | Eligible for browser preload lists |
always |
– | Send header even on error responses |
preload if you’re certain all subdomains support HTTPS, as removal from preload lists takes months.
Step 5: Configure OCSP Stapling
OCSP stapling improves performance by having your server fetch and cache certificate validity status, rather than forcing each client to query the CA:ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 127.0.0.1 valid=300s;
resolver_timeout 5s;
Important: Let’s Encrypt No Longer Supports OCSP
As of 2025, Let’s Encrypt has discontinued OCSP support. Their OCSP responders were shut down and new certificates no longer include OCSP URLs. If you’re using Let’s Encrypt certificates, thessl_stapling directives will have no effect, and NGINX will simply skip OCSP stapling silently.
This change reflects the industry’s move toward:
– Shorter certificate lifetimes (90 days) that limit exposure from compromised certificates
– Certificate Transparency logs for public monitoring of certificate issuance
– CRLite and other emerging revocation checking mechanisms
For Let’s Encrypt users: You can safely remove the OCSP stapling directives from your configuration, or leave them in place (they won’t cause errors, just won’t do anything).
For other CAs: Commercial Certificate Authorities like DigiCert, Sectigo, and GlobalSign still support OCSP. If you use certificates from these providers, keep OCSP stapling enabled for the performance and privacy benefits.
Certificate Lifetimes Are Collapsing: Automate Renewal Now
The move away from OCSP is one half of a larger shift. The other half is that certificates are getting dramatically shorter. CA/Browser Forum ballot SC-081v3, passed in April 2025, puts every public TLS certificate on this schedule:| From | Maximum validity | Renewals per year |
|---|---|---|
| Before 15 March 2026 | 398 days | 1 |
| 15 March 2026 (in force now) | 200 days | 2 |
| 15 March 2027 | 100 days | 4 |
| 15 March 2029 | 47 days | 8 |
Resolver Security Considerations
Theresolver directive specifies DNS servers for OCSP queries. Using external resolvers like 8.8.8.8 or 1.1.1.1 introduces a security risk, because a DNS spoofing attack could poison the resolver cache. That argument still holds. What most guides get wrong is the address they suggest instead.
Do not copy 127.0.0.53 onto a RHEL-family server. That address belongs to the systemd-resolved stub listener, which is a Debian and Ubuntu default. On RHEL 9, Rocky Linux and AlmaLinux, systemd-resolved is not enabled by default, and on Rocky Linux 10 the package is not even installed:
systemctl is-enabled systemd-resolved
# not-found
dig @127.0.0.53 ocsp.digicert.com
# ;; communications error to 127.0.0.53#53: connection refused
Nothing listens there, so every OCSP fetch fails. Because NGINX skips stapling silently when the lookup fails, you get no error at all, just a server that quietly never staples.
Pick a resolver that actually exists on your host:
- Run a local caching resolver and point at it:
unboundordnsmasqon127.0.0.1. This is the best option, because it caches and you control DNSSEC validation. - Cloud environments: use the provider’s internal DNS (
169.254.169.253on AWS,169.254.169.254on GCP and Azure). - Debian and Ubuntu only:
127.0.0.53is correct there, because systemd-resolved is enabled by default. - If you must use an external resolver: enable DNSSEC validation, and accept the spoofing risk.
dig @127.0.0.1 ocsp.digicert.com +short
Step 6: Test Your Configuration
Syntax Validation
Always test your NGINX configuration before reloading:nginx -t
Apply the Configuration
systemctl reload nginx
Test with SSL Labs
The industry-standard test for SSL configuration is Qualys SSL Labs Server Test. Enter your domain and verify you receive an A+ rating. Key metrics to check:- Protocol Support: Only TLS 1.2 and 1.3 (or TLS 1.3 only for Modern)
- Key Exchange: ECDHE or DHE with strong parameters
- Cipher Strength: 128-bit or higher AEAD ciphers
- Certificate: Valid chain, strong signature algorithm
- HSTS: Enabled with long max-age
Test with OpenSSL
Verify TLS 1.3 is working from the command line:openssl s_client -connect example.com:443 -tls1_3 < /dev/null 2>&1 | grep "Protocol"
Expected output:
Protocol : TLSv1.3
Step 7: Validate with Gixy
Gixy is a powerful NGINX configuration analyzer that detects security misconfigurations automatically. It checks for TLS issues, header problems, and many other security concerns. Install Gixy on RHEL-based systems:dnf install https://extras.getpagespeed.com/release-latest.rpm
dnf install gixy
Run the analysis:
gixy /etc/nginx/nginx.conf
Gixy will report issues like:
- Weak SSL/TLS protocols enabled
- Missing
server_tokens off - External DNS resolvers
- Missing security headers
- And many other security issues
Understanding the ssl_prefer_server_ciphers Warning
Gixy may report a MEDIUM severity warning aboutssl_prefer_server_ciphers off. This warning can be safely ignored when using Mozilla’s Intermediate or Modern configurations. Here’s why:
The disagreement explained:
Traditional security advice (including SSL Labs’ best practices) recommends ssl_prefer_server_ciphers on to force the server to choose ciphers, preventing clients from negotiating weak options.
However, Mozilla’s reasoning is different: when all ciphers in your list are secure (as they are in Mozilla’s curated configurations), there’s no weak cipher for a client to choose. In this case, letting the client choose (off) allows them to optimize for their hardware: mobile devices without AES-NI may perform better with ChaCha20-Poly1305, while servers with hardware acceleration benefit from AES-GCM.
When to use each setting:
| Cipher Configuration | ssl_prefer_server_ciphers |
Reason |
|---|---|---|
| Mozilla Modern/Intermediate | off |
All ciphers are secure; let clients optimize for their hardware |
| Custom list with mixed ciphers | on |
Force server to choose strongest cipher |
| Legacy compatibility (weak ciphers) | on |
Essential to prevent weak cipher negotiation |
ssl_prefer_server_ciphers off is the correct choice for optimal client performance without sacrificing security.
If You Must Keep ssl_prefer_server_ciphers on
Some compliance regimes and internal security policies simply require server-side cipher preference, and no amount of Mozilla reasoning will change the auditor’s checklist. Turning iton normally costs your mobile visitors the ChaCha20 optimization: the server picks AES-GCM from its own ordered list, even for a phone with no AES-NI acceleration, where ChaCha20-Poly1305 would be considerably faster.
OpenSSL has an escape hatch for exactly this, and NGINX exposes it through ssl_conf_command:
ssl_prefer_server_ciphers on;
ssl_conf_command Options PrioritizeChaCha;
With PrioritizeChaCha, the server keeps its own cipher ordering, satisfying the policy, but moves ChaCha20 to the front whenever the client put ChaCha20 first in its own list. Clients that prefer AES still get AES. You get server preference and the mobile optimization at the same time.
ssl_conf_command requires NGINX 1.19.4+ and OpenSSL 1.1.1+. The option only affects TLS 1.2 cipher selection; TLS 1.3 ciphersuites are negotiated separately and are all AEAD regardless.
Common Mistakes and How to Avoid Them
Mistake 1: Missing ssl_trusted_certificate
Without this directive, OCSP stapling fails silently (for CAs that support OCSP):nginx: [warn] "ssl_stapling" ignored, issuer certificate not found
Always specify the certificate chain:
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
Mistake 2: Forgetting the always Parameter on HSTS
Withoutalways, NGINX won’t send the HSTS header on error responses (4xx, 5xx), leaving a potential security gap:
# Wrong
add_header Strict-Transport-Security "max-age=63072000";
# Correct
add_header Strict-Transport-Security "max-age=63072000" always;
Mistake 3: Using Session Tickets Without Key Rotation
If you enablessl_session_tickets on, you must implement key rotation, otherwise the session ticket key could be compromised:
# Either disable session tickets
ssl_session_tickets off;
# Or implement key rotation with ssl_session_ticket_key
ssl_session_ticket_key /etc/nginx/ticket.key;
For a single server, simply disabling session tickets is the safest approach. On a fleet, it is not, and that trade-off is almost never spelled out.
Turning tickets off does not disable resumption. It moves resumption to the stateful path, where the session state lives in the ssl_session_cache shared memory zone. That zone is shared between worker processes on one host, and nowhere else. Put four NGINX nodes behind a load balancer and a returning visitor only resumes when they happen to land on the same node they used last time. Everyone else pays a full handshake, an extra round trip on every mismatch, which is precisely the cost TLS 1.3 was designed to remove.
So choose deliberately:
- Single server:
ssl_session_tickets off;gives forward secrecy with no key management, and the local cache handles resumption. - Multiple servers: keep tickets on and distribute a rotating
ssl_session_ticket_keyto every node. Rotate at least daily, keep the previous key listed so in-flight tickets still validate, and never store the key on disk unencrypted. A stale, never-rotated ticket key is genuinely worse than no tickets; a rotated one is fine.
Mistake 4: Using External DNS Resolvers
External resolvers like8.8.8.8 can be vulnerable to DNS spoofing. Always prefer local resolvers for OCSP queries.
Mistake 5: A Single add_header in a location Drops Every Server Header
This is the single most common reason a configuration that visibly “has HSTS” still misses an A+ rating, and it is a property of NGINX itself rather than a typo. It will quietly undo otherwise correct NGINX TLS 1.3 hardening.add_header directives are inherited from the enclosing block only if the child block declares none of its own. Add one header anywhere in a location, and every header from the server block silently disappears for that location, HSTS included:
server {
listen 443 ssl;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
location /api/ {
# This one directive drops BOTH headers above for /api/*
add_header Cache-Control "no-store" always;
}
}
Nothing warns you. nginx -t passes, the server block still contains a correct HSTS line, and curl -I https://example.com/ on the homepage shows the header exactly as expected. Only the sub-path is unprotected:
curl -sI https://example.com/ | grep -i strict-transport
# strict-transport-security: max-age=63072000; includeSubDomains
curl -sI https://example.com/api/v1 | grep -i strict-transport
# (nothing)
On NGINX 1.29.3 and newer, one directive fixes it properly:
location /api/ {
add_header_inherit merge;
add_header Cache-Control "no-store" always;
}
Note the value carefully. add_header_inherit takes on, off or merge, and on is the default, meaning the historical replace behavior and does not fix anything. Only merge appends the parent’s headers to the child’s. Setting add_header_inherit on; here changes nothing at all.
On older NGINX, the only remedy is to repeat every inherited header in every location that declares one, or to use ngx_headers_more, whose more_set_headers is not subject to this inheritance rule.
Gixy catches it as add_header_redefinition (MEDIUM):
>> Problem: [add_header_redefinition] Nested "add_header" drops parent headers.
Because this affects HSTS more than anything else, our NGINX HSTS guide covers the same trap from the HSTS side.
Performance Considerations
TLS 1.3 Performance Benefits
TLS 1.3 inherently improves performance:- 1-RTT handshakes: Standard connections complete in one round-trip
- 0-RTT resumption: Returning clients can send data immediately (with security tradeoffs)
Session Cache Sizing
Thessl_session_cache shared:SSL:10m directive allocates 10 MB of shared memory for the session cache. Each megabyte stores approximately 4,000 sessions:
# 10m = ~40,000 sessions
ssl_session_cache shared:SSL:10m;
# For high-traffic sites
ssl_session_cache shared:SSL:50m;
CPU Considerations
TLS 1.3’s preferred X25519 key exchange is faster than traditional ECDHE-P256, reducing CPU overhead. The cipher suiteCHACHA20-POLY1305 is particularly efficient on servers without AES-NI hardware acceleration.
Complete Configuration Example
Here’s a production-ready complete configuration combining all elements:# /etc/nginx/conf.d/ssl-hardening.conf
# Mozilla Intermediate Configuration for NGINX, with post-quantum key exchange
server_tokens off;
ssl_protocols TLSv1.2 TLSv1.3;
# "?" keeps NGINX starting on OpenSSL < 3.5. Drop the "?" once
# `nginx -V` reports OpenSSL 3.5+, so a missing group is loud, not silent.
ssl_ecdh_curve ?X25519MLKEM768:X25519:prime256v1:secp384r1;
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:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_dhparam /etc/nginx/dhparam.pem;
# OCSP Stapling (works with commercial CAs; no effect with Let's Encrypt)
ssl_stapling on;
ssl_stapling_verify on;
# Point this at a resolver that actually runs on this host.
resolver 127.0.0.1 valid=300s;
resolver_timeout 5s;
# /etc/nginx/conf.d/example.com.conf
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
server_tokens off;
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;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
root /var/www/example.com;
index index.html;
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
location / {
try_files $uri $uri/ =404;
}
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
A Note on X-XSS-Protection
If you are comparing this against an older hardening guide, you will notice one header missing:X-XSS-Protection "1; mode=block". Leave it out deliberately.
The header controlled Chrome's XSS Auditor, which Chrome removed in 2019 after it was shown to introduce vulnerabilities of its own: the filter could be manipulated into leaking cross-origin data and into disabling legitimate script. Edge dropped it, Firefox never implemented it. No current browser does anything useful with the header. Current guidance is to omit it entirely, or send X-XSS-Protection: 0 if some scanner insists the header be present.
Real cross-site scripting defense is a Content-Security-Policy, which is a substantially larger topic than one add_header line.
Summary
Hardening NGINX TLS 1.3 requires attention to several interconnected settings:- Use TLS 1.2+ at minimum, preferably TLS 1.3-only for modern deployments
- Follow Mozilla's guidelines for cipher suites and protocol settings
- Add post-quantum key exchange with
ssl_ecdh_curve, after confirming fromnginx -Vthat your build has OpenSSL 3.5+ - Enable OCSP stapling for performance and revocation checking (note: does not apply to Let's Encrypt certificates)
- Point
resolverat DNS that exists on your host, since127.0.0.53resolves nothing on RHEL-family systems - Configure HSTS with a long max-age and the
alwaysparameter - Watch
add_headerinheritance, since one header in alocationdrops every server-level header, HSTS included - Automate certificate renewal before the 100-day and 47-day validity caps arrive
- Disable server_tokens to prevent information disclosure
- Use Gixy to automatically detect configuration issues
- Test with SSL Labs to verify your A+ rating
Further Reading
Hardening plus continuous monitoring. An A+ rating today is no guarantee of an A+ rating after the next config change.ssl_protocols and ssl_ciphers directives drift across server blocks more often than you would think. GetPageSpeed Amplify runs scheduled gixy scans across every host and ties findings to live NGINX runtime metrics. Drop-in compatible with the deprecated nginx-amplify-agent (EOL January 2026).
]]>
