Table of Contents
- Intro
- Install nginx
- Stronger Let's Encrypt certs
- Template conf file
- Bleeding edge modern!
- ECH!
- Security Headers
Intro
Even though this post was originally published in 2017, I've been nerding out over Apache and Nginx TLS crypto for over 15 years. This post simply aims to document the current best settings for nginx transport cryptograhy that I use for the many sites that I maintain.
OpenSSL 3.5.x - 4.0-beta
Tested working on nginx 1.29.x
Tested working on Debian 13.x (and should work on Ubuntu 26.04)
Tested working on Nginx Proxy Manager (NPM), but i've stopped using NPM.
Install nginx
Per the instructions for mainline nginx on Debian:
sudo apt install curl gnupg2 ca-certificates lsb-release debian-archive-keyring certbot
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
| sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/nullecho "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
https://nginx.org/packages/mainline/debian `lsb_release -cs` nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.listsudo apt update && sudo apt install nginx -V
Stronger Let's Encrypt certs
Instead of the default RSA-2048, choose RSA-4096. I strongly advise RSA-4096 because of the report made by the German BSI (PDF):
At comparable classical security levels … elliptic curves appear to require less resources than factoring an RSA modulus with Shor’s approach.
Also see Dan Goodin's ArsTechnica article: Quantum computers need vastly fewer resources than thought to break vital encryption
To issue shortlived + RSA-4096:
sudo certbot certonly -d yawnbox.eu --key-type rsa --rsa-key-size 4096 --required-profile shortlived
Short-lived certs
Let's Encrypt now offers short-lived, 6-day certificates. Read about them here and here.
Why? Per Let's Encrypt:
- If a certificate's private key is compromised, that compromise can't last as long.
- With shorter life spans for the certificates, automation is encouraged. Which facilitates robust security of web servers.
- Certificate revocation is historically flaky. Lifetimes 10 days and under prevent the need to invoke the revocation process and deal with continued usage of a compromised key.
In other words, this buys us a little bit more time before quantumn supremecy, and we can all finally stop using CRL and OCSP. Combined with ML-KEM -prioritized or ML-KEM -only configurations, this is the best that we have right now (from OpenSSL).
When certbot is installed via 'apt', it's critical to automate renewal with, for example, a systemd service and timer. Create a systemd service + timer for renewal automation for every 4 days.
sudo tee /etc/systemd/system/certbot.service << 'EOF' > /dev/null
[Unit]
Description=Automatically renew Let's Encrypt short-lived certificates
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot certonly -d yawnbox.eu --key-type rsa --rsa-key-size 4096 --required-profile shortlived --keep --quiet --non-interactive
ExecStartPost=/usr/bin/systemctl reload nginx
EOF
sudo tee /etc/systemd/system/certbot.timer << 'EOF' > /dev/null
[Unit]
Description=Renew Let's Encrypt short-lived certificates every 4 days
[Timer]
OnCalendar=*-*-1/4 03:00:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now certbot.timerTemplate conf file
Adapt this as a secure starting point for your nginx conf file. Note that my blog is a static site, so I don't need any PHP or extra proxy configs.
sudo tee /etc/nginx/conf.d/default.conf << 'EOF' > /dev/null
server {
server_name yawnbox.eu;
root /var/www/public;
# no logs
access_log off;
# hsts and hsts-preload including sub-domains
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# certs
ssl_certificate /etc/letsencrypt/live/yawnbox.eu/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yawnbox.eu/privkey.pem;
# http/3
listen [::]:443 quic reuseport;
listen 443 quic reuseport;
http3 on;
quic_gso on;
quic_retry on;
add_header Alt-Svc 'h3=":443"; ma=86400';
# http/2
listen [::]:443 ssl;
listen 443 ssl;
http2 on;
# TLS 1.3 only, hybrid key exchange groups are prioritized w/ strong legacy groups, no AES-128 cipher suite
ssl_protocols TLSv1.3;
ssl_conf_command Groups X25519MLKEM768:SecP384r1MLKEM1024:X25519:secp384r1;
ssl_conf_command Options +ServerPreference;
ssl_conf_command Options +PrioritizeChaCha;
ssl_conf_command Ciphersuites TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384;
# security over performance
ssl_early_data off;
ssl_session_tickets off;
ssl_session_cache off;
}
EOF
sudo nginx -tIf all looks good, restart nginx:
sudo service nginx restart
Bleeding edge modern!
The next phase in modern nginx cryptography is to remove the legacy key exchange groups (remove ':X25519:secp384r1'). All mainstream desktop borwsers in 2026 support hybrid/ ML-KEM groups, but mobile browsers and systems with older cryptographic libraries will fail negotiation.
ssl_conf_command Groups X25519MLKEM768:SecP384r1MLKEM1024;Note that legacy-only tools like Qualys SSL Labs will fail connections if legacy groups are disabled.
ECH!
TBD
Security Headers
Unrelated, but don't forget all of your other security headers. Be sure to change them based on your needs... watch for errors in Firefox > Tools > Browser Tools > Console. Use https://securityheaders.com for more testing.
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data:; font-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; form-action 'self'; frame-src 'none'; manifest-src 'self'; worker-src 'self'; upgrade-insecure-requests" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), bluetooth=(), interest-cohort=()" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;