Nginx handles over 30% of all web traffic. If your TLS is misconfigured, you’re exposing millions of connections to downgrade attacks, weak ciphers, or certificate errors that silently break trust. Most Nginx TLS guides online are outdated — still recommending TLS 1.0 fallbacks or cipher suites that were deprecated years ago.
This guide gives you a production-ready Nginx TLS configuration for 2026: TLS 1.3 with strong fallback, OCSP stapling, HSTS, security headers, and an A+ SSL Labs rating. Copy-paste ready, with explanations for every directive.
The Complete Production Configuration
Here’s the full config. We’ll break down every section below.
# /etc/nginx/conf.d/ssl-hardening.conf
# Production TLS configuration — 2026 best practices
# SSL session settings (performance)
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off; # Disable for perfect forward secrecy
# Protocol versions — TLS 1.2 and 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher suites — server preference, strong AEAD only
ssl_prefer_server_ciphers on;
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 cipher suites (configured separately in Nginx 1.19.4+)
ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256;
# ECDH curve
ssl_ecdh_curve X25519:secp384r1:secp256r1;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# DH parameters (for TLS 1.2 DHE suites — generate with: openssl dhparam -out /etc/nginx/dhparam.pem 4096)
ssl_dhparam /etc/nginx/dhparam.pem;
And the server block:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.example.com;
# Certificate and key
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
# Trusted CA for OCSP stapling verification
ssl_trusted_certificate /etc/letsencrypt/live/api.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 X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# ... your application config
}
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
Breaking Down Every Directive
Protocol Versions
ssl_protocols TLSv1.2 TLSv1.3;
- TLS 1.3: Mandatory. Faster handshake (1-RTT), no legacy cipher negotiation, all suites are AEAD.
- TLS 1.2: Keep for compatibility with older clients (Android 4.x, Java 7, older curl versions). Remove only if you control all clients.
- TLS 1.0/1.1: Never. Deprecated by RFC 8996. Vulnerable to BEAST, POODLE.
Cipher Suites
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';
Every cipher in this list provides:
- ECDHE — Ephemeral key exchange (perfect forward secrecy)
- AES-GCM or ChaCha20-Poly1305 — Authenticated encryption (AEAD)
- No CBC mode — Eliminates padding oracle attacks
- No RSA key exchange — Eliminates lack of forward secrecy
- No 3DES, RC4, DES — All broken
Why ChaCha20? It’s faster than AES on devices without AES-NI hardware acceleration (mobile, ARM). Nginx will negotiate it for clients that prefer it.
Session Management
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
- Session cache: Allows TLS session resumption without full handshake.
50m= ~200,000 sessions. - Session tickets OFF: Session tickets reuse a single key across all connections. If that key is compromised, all past sessions can be decrypted. Disabling tickets preserves perfect forward secrecy.
- Timeout 1d: Sessions expire after 24 hours, forcing re-authentication.
OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
Without OCSP stapling, every client must contact the CA’s OCSP responder to check if your certificate is revoked. This adds latency and leaks browsing data to the CA.
With stapling, Nginx fetches the OCSP response and includes it in the TLS handshake — faster for clients, more private.
Common issue: OCSP stapling fails silently if:
ssl_trusted_certificateis missing or wrong- DNS resolver is not configured
- Firewall blocks outbound HTTPS to CA’s OCSP responder
Verify it’s working:
openssl s_client -connect api.example.com:443 -servername api.example.com -status 2>/dev/null | grep -A 3 "OCSP Response"
# Should show: "OCSP Response Status: successful"
HSTS (HTTP Strict Transport Security)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Tells browsers: “Never connect to this domain over HTTP. Always use HTTPS.” Once set:
- Prevents SSL stripping attacks
- Eliminates HTTP → HTTPS redirect latency on subsequent visits
max-age=63072000= 2 yearsincludeSubDomains= applies to all subdomainspreload= eligible for Chrome’s HSTS preload list
Warning: Once you enable HSTS with a long max-age, you CANNOT go back to HTTP without breaking access for cached clients. Only enable when you’re certain HTTPS works correctly everywhere.
TLS 1.3 Specific Configuration
TLS 1.3 in Nginx (1.13.0+) is mostly automatic, but you can fine-tune:
# Prefer specific TLS 1.3 cipher suites (Nginx 1.19.4+)
ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256;
# Enable 0-RTT (early data) — USE WITH CAUTION
# ssl_early_data on;
# proxy_set_header Early-Data $ssl_early_data;
0-RTT warning: TLS 1.3 early data (0-RTT) allows the client to send data in the first flight, eliminating one round trip. But it’s vulnerable to replay attacks. Only enable for idempotent requests (GET), never for state-changing operations (POST, PUT, DELETE).
Certificate Configuration
Single Domain
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
Critical: Use fullchain.pem (cert + intermediate), NOT just the leaf certificate. Missing intermediates cause “unable to verify” errors on some clients.
Multiple Certificates (RSA + ECDSA Dual Stack)
# Serve both RSA and ECDSA — Nginx picks based on client capability
ssl_certificate /etc/ssl/certs/example.com-ecdsa.pem;
ssl_certificate_key /etc/ssl/private/example.com-ecdsa.key;
ssl_certificate /etc/ssl/certs/example.com-rsa.pem;
ssl_certificate_key /etc/ssl/private/example.com-rsa.key;
Modern clients get ECDSA (faster handshake). Legacy clients fall back to RSA. Best of both worlds.
Wildcard Certificate
server {
listen 443 ssl http2;
server_name *.example.com;
ssl_certificate /etc/ssl/certs/wildcard.example.com.pem;
ssl_certificate_key /etc/ssl/private/wildcard.example.com.key;
}
mTLS (Client Certificate Authentication)
For APIs or internal services requiring mutual authentication:
server {
listen 443 ssl http2;
server_name api-internal.example.com;
ssl_certificate /etc/ssl/certs/server.pem;
ssl_certificate_key /etc/ssl/private/server.key;
# Client certificate verification
ssl_client_certificate /etc/ssl/certs/client-ca.pem; # CA that signed client certs
ssl_verify_client on; # Require client certificate
# ssl_verify_client optional; # Request but don't require (for mixed endpoints)
ssl_verify_depth 2; # Max chain depth for client certs
# Pass client cert info to backend
proxy_set_header X-Client-Cert-DN $ssl_client_s_dn;
proxy_set_header X-Client-Cert-Serial $ssl_client_serial;
proxy_set_header X-Client-Cert-Verify $ssl_client_verify;
}
For a complete mTLS implementation guide, see mTLS in Production.
Troubleshooting Common Nginx TLS Errors
”SSL_ERROR_SYSCALL” / Connection Reset
Causes:
- Mismatched key and certificate
- Certificate file corrupted
- Nginx worker doesn’t have read permission on key file
Fix:
# Verify key matches certificate
openssl x509 -noout -modulus -in /etc/ssl/certs/server.pem | openssl md5
openssl rsa -noout -modulus -in /etc/ssl/private/server.key | openssl md5
# Both must match
# Check file permissions
ls -la /etc/ssl/private/server.key
# Should be: -rw------- root root (or nginx user)
# Test Nginx config
nginx -t
“no suitable key share” (TLS 1.3)
Cause: Client and server don’t share a common elliptic curve group.
Fix:
ssl_ecdh_curve X25519:secp384r1:secp256r1;
Certificate Chain Incomplete
Symptom: Works in Chrome but fails in curl, Java, or mobile apps.
Cause: Using leaf certificate only, missing intermediate CA.
Fix:
# Create fullchain (correct order: leaf first, then intermediates)
cat server.crt intermediate.crt > fullchain.pem
# Verify chain
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt fullchain.pem
OCSP Stapling Not Working
Symptom: openssl s_client -status shows no OCSP response.
Fix:
# Ensure trusted certificate is the ISSUER's cert (not your cert)
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
# Ensure resolver is configured
resolver 1.1.1.1 8.8.8.8 valid=300s;
# Restart Nginx (stapling fetches on first request after restart)
nginx -s reload
# Wait 5 seconds, then test
sleep 5 && openssl s_client -connect example.com:443 -servername example.com -status
“PEM_read_bio_X509” Error
Cause: Certificate file is DER format, not PEM. Or file has Windows line endings.
Fix:
# Convert DER to PEM
openssl x509 -in cert.der -inform der -out cert.pem -outform pem
# Fix line endings
dos2unix cert.pem
Security Headers Deep Dive
# Prevent MIME type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Prevent clickjacking
add_header X-Frame-Options "DENY" always;
# Or allow same-origin framing: "SAMEORIGIN"
# Disable XSS auditor (modern browsers don't need it, can cause issues)
add_header X-XSS-Protection "0" always;
# Control referrer information
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Restrict browser features
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# Content Security Policy (customize per application)
# add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
The always keyword ensures headers are sent even on error responses (4xx, 5xx). Without it, a 404 page could be missing security headers.
Performance Optimization
# Enable HTTP/2 (already in listen directive)
listen 443 ssl http2;
# Buffer sizes for SSL
ssl_buffer_size 4k; # Smaller = lower TTFB for small responses
# Default 16k is better for large file downloads
# Keepalive for upstream connections
upstream backend {
server 127.0.0.1:8080;
keepalive 32;
}
Performance Impact of TLS
| Configuration | Handshake Time | Requests/sec Impact |
|---|---|---|
| RSA 2048 + TLS 1.2 | ~15ms | Baseline |
| RSA 4096 + TLS 1.2 | ~25ms | -10% |
| ECDSA P-256 + TLS 1.2 | ~5ms | +15% |
| ECDSA P-256 + TLS 1.3 | ~3ms | +25% |
| Session resumption | ~1ms | +40% |
ECDSA + TLS 1.3 with session resumption is 5-10x faster than RSA + TLS 1.2 without resumption.
Automated Certificate Renewal (Let’s Encrypt + Certbot)
# Install Certbot with Nginx plugin
apt install certbot python3-certbot-nginx
# Obtain and install certificate
certbot --nginx -d example.com -d www.example.com
# Test renewal
certbot renew --dry-run
# Certbot auto-renewal (systemd timer runs twice daily)
systemctl status certbot.timer
For enterprise-scale certificate management across hundreds of Nginx instances, manual Certbot doesn’t scale. See How to Automate Certificate Renewal with ACME for production automation patterns.
Testing Your Configuration
SSL Labs
# Online test (public-facing servers only)
# https://www.ssllabs.com/ssltest/analyze.html?d=example.com
# CLI alternative using testssl.sh
git clone https://github.com/drwetter/testssl.sh.git
./testssl.sh/testssl.sh https://example.com
Quick Local Tests
# Verify TLS 1.3 works
openssl s_client -connect example.com:443 -tls1_3
# Verify TLS 1.0 is DISABLED (should fail)
openssl s_client -connect example.com:443 -tls1
# Verify weak ciphers are DISABLED (should fail)
openssl s_client -connect example.com:443 -cipher 'RC4:DES:3DES:NULL'
# Check HSTS header
curl -sI https://example.com | grep -i strict-transport
# Check all security headers
curl -sI https://example.com | grep -iE "strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy"
Complete Hardening Checklist
| # | Check | Status |
|---|---|---|
| 1 | TLS 1.0 and 1.1 disabled | ☐ |
| 2 | TLS 1.3 enabled | ☐ |
| 3 | Only AEAD cipher suites (GCM, ChaCha20) | ☐ |
| 4 | Perfect forward secrecy (ECDHE only) | ☐ |
| 5 | OCSP stapling enabled and verified | ☐ |
| 6 | HSTS header with long max-age | ☐ |
| 7 | HTTP → HTTPS redirect (301) | ☐ |
| 8 | Full certificate chain (not just leaf) | ☐ |
| 9 | Session tickets disabled | ☐ |
| 10 | DH parameters ≥ 2048 bits | ☐ |
| 11 | X-Content-Type-Options: nosniff | ☐ |
| 12 | X-Frame-Options: DENY | ☐ |
| 13 | Referrer-Policy set | ☐ |
| 14 | Permissions-Policy set | ☐ |
| 15 | SSL Labs A+ rating confirmed | ☐ |
FAQ
Q: Should I still support TLS 1.2 or go TLS 1.3 only?
Keep TLS 1.2 unless you control all clients. As of 2026, ~5% of traffic still comes from clients that don’t support TLS 1.3 (older Android, corporate proxies, legacy Java). The cipher suite configuration above ensures TLS 1.2 connections are still secure (ECDHE + AEAD only).
Q: Do I need to generate DH parameters?
Only if you include DHE cipher suites (not ECDHE). The configuration above uses ECDHE exclusively, so ssl_dhparam is technically optional. But including a strong DH parameter file is defense-in-depth — it prevents accidental DHE negotiation with weak defaults.
openssl dhparam -out /etc/nginx/dhparam.pem 4096
# Takes 5-30 minutes. Do it once.
Q: What’s the difference between ssl_certificate and ssl_trusted_certificate?
ssl_certificate— Your server’s certificate + intermediate chain (sent to clients)ssl_trusted_certificate— The CA certificate used to verify OCSP responses (NOT sent to clients)
Q: How do I serve different certificates for different domains on the same IP?
SNI (Server Name Indication) handles this automatically. Each server block has its own ssl_certificate directive. Nginx selects the right certificate based on the hostname the client requests.
Q: Will this configuration work with HTTP/3 (QUIC)?
HTTP/3 uses QUIC which has its own TLS 1.3 implementation. Nginx has experimental QUIC support. The TLS configuration above applies to TCP-based connections. For QUIC, add:
listen 443 quic reuseport;
add_header Alt-Svc 'h3=":443"; ma=86400';
Related Reading: