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.com