Introduction
Obtaining a TLS certificate and enabling HTTPS on Nginx is only the beginning. A default or minimally configured TLS setup often supports deprecated protocol versions like TLS 1.0 and TLS 1.1, weak cipher suites, and missing features like OCSP stapling — all of which leave your server exposed to downgrade attacks, protocol exploits, and unnecessarily slow handshakes.
This guide walks through a complete, production-grade SSL/TLS hardening process for Nginx on Ubuntu. You will disable legacy protocols, configure modern cipher suites, generate custom Diffie-Hellman parameters, enable OCSP stapling, and tune session resumption — finishing with a configuration that scores A+ on SSL Labs and passes a testssl.sh audit.
All examples assume your domain is solvethenetwork.com, your server hostname is sw-infrarunbook-01, and your certificates are managed by Let's Encrypt. The TLS hardening directives apply equally to certificates from any certificate authority.
Prerequisites
- Nginx 1.18 or later installed on Ubuntu 22.04
- A valid TLS certificate for solvethenetwork.com (e.g., from Let's Encrypt)
- Root or sudo access on sw-infrarunbook-01
- Port 443 open in your firewall
- sw-infrarunbook-01 able to reach the internet for OCSP resolution
Understanding the Default Nginx TLS Posture
Out of the box, or after a basic Certbot installation, your Nginx server block likely contains something like this:
ssl_certificate /etc/letsencrypt/live/solvethenetwork.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/solvethenetwork.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;The
options-ssl-nginx.confprovided by Certbot is reasonable but does not represent the most aggressive hardening available. It may still permit cipher suites that are technically valid but suboptimal, and it does not configure OCSP stapling, session ticket behavior, or buffer size tuning. This guide replaces that include with a fully explicit, tuned configuration you control entirely.
Step 1: Restrict TLS Protocol Versions
TLS 1.0 and TLS 1.1 are deprecated by RFC 8996 and must never be offered on a production server. TLS 1.2 remains widely supported and is acceptable when combined with strong ciphers. TLS 1.3 is the preferred protocol and should always be enabled.
Open your virtual host configuration on sw-infrarunbook-01:
sudo nano /etc/nginx/sites-available/solvethenetwork.comSet the
ssl_protocolsdirective to allow only TLS 1.2 and TLS 1.3:
ssl_protocols TLSv1.2 TLSv1.3;This single directive eliminates SSLv3, TLS 1.0, and TLS 1.1 entirely. Any client that cannot negotiate TLS 1.2 or higher will receive a handshake failure. This is an acceptable trade-off given that all modern browsers have supported TLS 1.2 since at least 2013, and TLS 1.3 is supported in Chrome 70+, Firefox 63+, Safari 12.1+, and Edge 79+.
Step 2: Configure Strong Cipher Suites
Cipher suite selection determines the algorithms used for key exchange, authentication, bulk encryption, and message authentication. Weak ciphers enable attacks including SWEET32 (against 3DES), BEAST (against CBC in TLS 1.0), POODLE (against SSLv3), and ROBOT (against RSA key exchange). The hardened cipher list below restricts all TLS 1.2 connections to ECDHE-based key exchange (providing forward secrecy) with AES-GCM or ChaCha20-Poly1305 for bulk encryption.
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;
ssl_prefer_server_ciphers off;Note that
ssl_prefer_server_ciphers offis intentional. TLS 1.3 manages its own cipher selection independently of this directive — setting it to
offallows clients to choose their preferred cipher from the server's approved list, which matters for mobile clients that handle ChaCha20-Poly1305 more efficiently than AES-GCM in the absence of hardware acceleration. Since your cipher list is already restricted to strong algorithms only, allowing the client preference is safe and improves performance.
For TLS 1.3, Nginx inherits cipher suites from the underlying OpenSSL library. The three TLS 1.3 cipher suites that OpenSSL supports —
TLS_AES_128_GCM_SHA256,
TLS_AES_256_GCM_SHA384, and
TLS_CHACHA20_POLY1305_SHA256— are all considered secure, so no additional restriction is needed.
Step 3: Generate Custom Diffie-Hellman Parameters
DHE key exchange requires a set of DH parameters. Default parameters on some distributions can be 1024-bit, which are considered weak. Generating your own 2048-bit DH parameters on sw-infrarunbook-01 is a hardening best practice and ensures the parameters were not shared with other installations.
sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048This command takes one to two minutes to complete. The output file should be protected:
sudo chmod 600 /etc/nginx/dhparam.pem
sudo chown root:root /etc/nginx/dhparam.pemReference the generated file in your Nginx configuration:
ssl_dhparam /etc/nginx/dhparam.pem;NIST SP 800-131A considers 2048-bit DH parameters secure through at least 2030. If you are operating in a high-security environment, you can generate 4096-bit parameters, but this increases TLS handshake CPU cost — 2048-bit is the correct default for most production web servers.
Step 4: Enable OCSP Stapling
Online Certificate Status Protocol (OCSP) is how browsers verify that your certificate has not been revoked. Without OCSP stapling, each client browser must independently contact your certificate authority's OCSP responder during the TLS handshake. This adds latency, creates a privacy concern (the CA learns which sites users visit), and introduces a single point of failure if the CA's OCSP service is slow or unavailable.
With OCSP stapling, Nginx periodically fetches the signed OCSP response from the CA and caches it in memory. During the TLS handshake, Nginx attaches (staples) this response, eliminating the client's need to contact the CA directly. The response is cryptographically signed by the CA, so clients can verify it without trusting Nginx.
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/solvethenetwork.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;The
ssl_trusted_certificatedirective must point to the CA's certificate chain — for Let's Encrypt this is
chain.pem, which contains the intermediate certificate. Do not use
fullchain.pemhere, as that file includes your end-entity certificate and will cause Nginx to fail OCSP response verification. The
resolverdirective specifies DNS servers that Nginx uses to resolve the OCSP responder hostname. If sw-infrarunbook-01 is behind a firewall with a local DNS resolver at 192.168.1.53, you can specify that instead, provided it can resolve public hostnames.
Step 5: Configure SSL Session Cache and Disable Session Tickets
Session resumption allows returning clients to reconnect without a full TLS handshake, reducing latency significantly. Nginx supports two mechanisms: server-side session cache and client-side session tickets. They serve the same purpose but have different security trade-offs.
Session Cache (Recommended)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;The
shared:SSL:10mvalue creates a 10 MB shared memory zone named SSL, accessible across all Nginx worker processes. This stores approximately 40,000 sessions simultaneously. The one-day timeout matches typical browser reconnect patterns for returning users.
Session Tickets (Disabled)
ssl_session_tickets off;Session tickets use a server-side encryption key to encode session state that is sent to and stored by the client. The client presents this ticket on reconnect, and the server decrypts it to resume the session. The problem is that if the ticket encryption key is compromised — or if it is never rotated — all past sessions encrypted with that key can be decrypted, violating forward secrecy. Unless you implement proper ticket key rotation with an external key management system, disabling session tickets is the correct production default.
Step 6: Tune ssl_buffer_size for Lower Latency
The default
ssl_buffer_sizein Nginx is 16 KB, which is optimized for bulk throughput. The server buffers up to 16 KB of response data before sending the first TLS record. For small API responses, small HTML pages, or JSON payloads, this inflates Time to First Byte (TTFB) unnecessarily.
ssl_buffer_size 4k;Setting it to 4 KB means small responses are transmitted in fewer, earlier TLS records. For a file download server where large files dominate, 16 KB is correct. For a typical web application or API on solvethenetwork.com, 4 KB reduces perceived latency without meaningful throughput cost.
Step 7: The Complete Hardened Server Block
Assemble all directives into the complete production configuration for sw-infrarunbook-01. Edit
/etc/nginx/sites-available/solvethenetwork.com:
server {
listen 80;
server_name solvethenetwork.com www.solvethenetwork.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name solvethenetwork.com www.solvethenetwork.com;
# Certificate paths
ssl_certificate /etc/letsencrypt/live/solvethenetwork.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/solvethenetwork.com/privkey.pem;
# Protocol restriction
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher suites (TLS 1.2 explicit; TLS 1.3 managed by OpenSSL)
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;
ssl_prefer_server_ciphers off;
# Custom DH parameters
ssl_dhparam /etc/nginx/dhparam.pem;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/solvethenetwork.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Session resumption
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# Buffer tuning
ssl_buffer_size 4k;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
root /var/www/solvethenetwork.com/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}Test and reload Nginx:
sudo nginx -t && sudo systemctl reload nginxStep 8: Extract a Reusable ssl-params.conf Snippet
When sw-infrarunbook-01 hosts multiple virtual hosts, duplicating TLS hardening directives in every server block creates configuration drift. Extract the shared directives into a snippet file:
sudo nano /etc/nginx/snippets/ssl-params.conf# /etc/nginx/snippets/ssl-params.conf
ssl_protocols TLSv1.2 TLSv1.3;
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;
ssl_prefer_server_ciphers off;
ssl_dhparam /etc/nginx/dhparam.pem;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_buffer_size 4k;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;Each server block then only needs the certificate-specific lines plus the include:
server {
listen 443 ssl;
http2 on;
server_name solvethenetwork.com;
ssl_certificate /etc/letsencrypt/live/solvethenetwork.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/solvethenetwork.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/solvethenetwork.com/chain.pem;
include snippets/ssl-params.conf;
# ... application-specific directives
}Step 9: Verifying the Hardened Configuration
Test Protocol Restriction with OpenSSL
From a separate host, confirm that legacy protocol versions are rejected by sw-infrarunbook-01:
# Should fail with handshake error
openssl s_client -connect solvethenetwork.com:443 -tls1
# Should also fail
openssl s_client -connect solvethenetwork.com:443 -tls1_1
# Should succeed and show negotiated cipher
openssl s_client -connect solvethenetwork.com:443 -tls1_2
# Should succeed and show TLSv1.3 in output
openssl s_client -connect solvethenetwork.com:443 -tls1_3Verify OCSP Stapling
openssl s_client -connect solvethenetwork.com:443 -status 2>&1 | grep -A 10 'OCSP response'A working OCSP staple produces output containing
OCSP Response Status: successful (0x0). If you see
no response sentimmediately after a reload, wait 30 to 60 seconds — Nginx fetches the OCSP response asynchronously on the first post-reload request. If it never populates, check Nginx error logs:
sudo tail -f /var/log/nginx/error.log | grep -i ocspThe most common error is
ssl_stapling_verify: certificate not found, which means
ssl_trusted_certificateis pointing to
fullchain.peminstead of
chain.pem.
Run a Full Audit with testssl.sh
Install testssl.sh on sw-infrarunbook-01 for a comprehensive local audit:
git clone --depth 1 https://github.com/drwetter/testssl.sh.git /opt/testssl.sh
/opt/testssl.sh/testssl.sh solvethenetwork.com:443After applying the configuration in this guide, the output should show:
- SSLv2, SSLv3, TLS 1.0, TLS 1.1: not offered
- TLS 1.2, TLS 1.3: offered
- OCSP stapling: offered
- Forward Secrecy: offered (all server ciphers)
- BEAST, POODLE, SWEET32, ROBOT, CRIME: all not vulnerable
Step 10: Log TLS Protocol and Cipher per Request
Add
$ssl_protocoland
$ssl_cipherto your Nginx access log to monitor what clients are actually negotiating. Edit
/etc/nginx/nginx.confand add a custom log format inside the
httpblock:
log_format tls_detail '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'tls=$ssl_protocol cipher=$ssl_cipher';Then reference it in the server block:
access_log /var/log/nginx/access.log tls_detail;This produces log lines like:
192.168.10.45 - - [26/Mar/2026:09:14:33 +0000] "GET / HTTP/2.0" 200 4823 "-" "Mozilla/5.0" tls=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384You can then monitor for any remaining TLS 1.0 or 1.1 clients from legacy internal systems using a simple grep against the log file:
sudo grep 'tls=TLSv1 ' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rnStep 11: Certificate Expiry Monitoring
A hardened TLS configuration is only as strong as the certificate behind it. Add a simple expiry check script on sw-infrarunbook-01:
sudo nano /usr/local/bin/check-cert-expiry.sh#!/bin/bash
DOMAIN="solvethenetwork.com"
WARN_DAYS=30
EXPIRY=$(echo | openssl s_client -servername "$DOMAIN" -connect "$DOMAIN:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
if [ "$DAYS_LEFT" -lt "$WARN_DAYS" ]; then
echo "WARNING: Certificate for $DOMAIN expires in $DAYS_LEFT days"
exit 1
fi
echo "OK: Certificate for $DOMAIN expires in $DAYS_LEFT days"
exit 0sudo chmod +x /usr/local/bin/check-cert-expiry.shSchedule it via cron to run daily and alert infrarunbook-admin:
sudo crontab -e# Daily certificate expiry check at 08:00
0 8 * * * /usr/local/bin/check-cert-expiry.sh | mail -s "Cert Expiry: solvethenetwork.com" infrarunbook-admin@solvethenetwork.comFrequently Asked Questions
Q: Why is ssl_prefer_server_ciphers set to off instead of on?
A: Setting
ssl_prefer_server_ciphers onwas a best practice when TLS 1.0 and 1.1 were still in use, because it forced the server's stronger cipher ordering over potentially weaker client preferences. For TLS 1.3, the directive has no effect — TLS 1.3 manages cipher selection independently. For TLS 1.2, since you are already restricting the cipher list to only strong ECDHE ciphers, the ordering matters less. Setting it to
offallows clients to select their preferred cipher from your approved list, which improves performance on mobile clients that handle ChaCha20-Poly1305 more efficiently than AES-GCM in software.
Q: Should I generate 4096-bit DH parameters instead of 2048-bit?
A: NIST SP 800-131A considers 2048-bit DH parameters secure through 2030. 4096-bit parameters provide a larger security margin but significantly increase TLS handshake CPU cost, particularly on servers without hardware acceleration. For most production web servers, 2048-bit is the correct balance of security and performance. If you are operating in a high-assurance environment such as financial services, healthcare systems, or government infrastructure, 4096-bit is worth the trade-off.
Q: My OCSP stapling shows no response in the OpenSSL output right after a reload. Is something broken?
A: No, this is expected behavior. Nginx fetches the OCSP response asynchronously after the first HTTPS request following a reload. Until that asynchronous fetch completes and the response is cached, Nginx does not staple anything. Wait 30 to 60 seconds after your first HTTPS request, then run the OpenSSL status check again. If the response never appears after several minutes, check that your
resolverdirective is configured correctly and that sw-infrarunbook-01 can reach the CA's OCSP responder hostname over the internet on port 80.
Q: Can I use ssl_session_tickets if I rotate the keys properly?
A: Yes. Session tickets are safe when the ticket encryption key is rotated regularly — typically every 24 hours — and old keys are retired after a grace period to allow in-flight sessions to complete. OpenSSL supports configuring multiple ticket keys to enable graceful rotation. The practical challenge is that key rotation requires external tooling or custom scripting to manage key generation, distribution to all workers, and expiry. Because the default Nginx behavior does not rotate keys automatically, disabling session tickets is the safer and simpler default. For high-traffic deployments where session resumption performance is critical, implementing proper key rotation and enabling tickets is a valid choice.
Q: Does restricting to TLS 1.2 and 1.3 break compatibility with older Android or iOS devices?
A: Android 5.0 (2014) and later, and iOS 9 (2015) and later, both support TLS 1.2 with the cipher suites in this guide. Android 4.x and below will fail to connect, but these represent a negligible fraction of global traffic. If you have a documented requirement to support legacy Android 4.x devices, you can add
ECDHE-RSA-AES128-SHAto your cipher list as a fallback. This is rarely appropriate for new deployments and weakens your overall cipher posture.
Q: What is the difference between ssl_certificate and ssl_trusted_certificate?
A:
ssl_certificatespecifies the certificate chain presented to clients during the TLS handshake. It should point to
fullchain.pem, which contains your end-entity certificate plus any intermediate certificates required to chain up to the root CA.
ssl_trusted_certificateis used exclusively for OCSP stapling response verification and should point to only the CA chain — for Let's Encrypt this is
chain.pem, containing the intermediate certificate. Including your end-entity certificate in
ssl_trusted_certificatecauses OCSP verification to fail.
Q: Is TLS 1.3 automatically enabled when I specify ssl_protocols TLSv1.2 TLSv1.3?
A: Only if your underlying OpenSSL version supports TLS 1.3. OpenSSL 1.1.1 (shipped with Ubuntu 18.04+) and OpenSSL 3.x (Ubuntu 22.04+) both support TLS 1.3. Run
openssl versionon sw-infrarunbook-01 to confirm. If you are on an older distribution with OpenSSL 1.0.x, the TLSv1.3 entry in
ssl_protocolswill be silently ignored and only TLS 1.2 will be offered. Ubuntu 22.04 ships with OpenSSL 3.0.x, so TLS 1.3 is available by default.
Q: Should I set ssl_ecdh_curve explicitly?
A: Modern Nginx and OpenSSL automatically negotiate the best elliptic curve from a default list that includes X25519, P-256, and P-384 in priority order. You only need to set
ssl_ecdh_curveexplicitly if you want to restrict or reorder that list. For example,
ssl_ecdh_curve X25519:P-256:P-384;deprioritizes P-384 which has a larger key size and slower operations on some hardware. The defaults in Nginx 1.18+ are already appropriate for production — explicit configuration is only needed if your security policy mandates specific curve restrictions.
Q: What SSL Labs score does this configuration achieve?
A: This configuration consistently achieves A+ on the Qualys SSL Labs server test. The contributing factors are: TLS 1.0 and 1.1 disabled (removes protocol score penalty), cipher suites restricted to forward-secret ECDHE families (eliminates cipher weakness flags), OCSP stapling enabled (positive trust indicator), HSTS header with a long max-age and includeSubDomains (required for A+), and no weak DH parameters. The A+ designation specifically requires an HSTS max-age of at least 180 days; the 63072000-second value (approximately 2 years) used in this guide satisfies that requirement.
Q: Why does ssl_buffer_size affect Time to First Byte?
A: Nginx writes TLS records using
ssl_buffer_sizeas the maximum record size. With the default 16 KB, Nginx buffers up to 16 KB of response data before it sends the first TLS record across the wire. For a 3 KB API JSON response, Nginx will fill the 16 KB buffer partially before deciding to transmit, introducing unnecessary delay. Reducing
ssl_buffer_sizeto 4 KB means small responses are packaged into fewer, earlier TLS records. The trade-off is slightly higher per-record overhead for large file transfers, which is why 16 KB is the default. For mixed workloads dominated by small page loads and API calls, 4 KB reduces perceived latency without meaningful impact on throughput.
Q: Do I need to regenerate DH parameters when I renew my TLS certificate?
A: No. DH parameters are independent of the certificate and its associated key pair. The certificate contains your public key used for authentication; the DH parameters are used for key exchange to establish the session encryption keys. Your DH parameters at
/etc/nginx/dhparam.pemremain valid indefinitely and do not need to be regenerated on certificate renewal. You might choose to regenerate them periodically as part of a security hygiene rotation policy, but this is not required and is infrequently done in practice.
Q: How do I verify which cipher was negotiated for a specific connection in Nginx logs?
A: Add the
$ssl_protocoland
$ssl_ciphervariables to a custom log format in
/etc/nginx/nginx.confas shown in Step 10 of this guide. Once the format is active, you can inspect the access log to see exactly which protocol version and cipher suite each client negotiated. This is particularly useful for identifying legacy internal clients that may be connecting with TLS 1.2 after you have deployed TLS 1.3, or for confirming that ChaCha20-Poly1305 is being selected by mobile clients as expected.
