HTTPS and TLS done right on Linux servers

Updated November 2026

A good TLS setup in 2026 is short: allow only TLS 1.2 and 1.3, use the published “intermediate” cipher list, renew certificates automatically, and test from outside. Most problems come from old copied configs, HSTS turned on too fast, OCSP stapling that no longer has anything to staple, and services never restarted after an OpenSSL update.

For years admins copied their settings from Mozilla’s SSL Configuration Generator and its “Server Side TLS” guidelines. Both have moved. The generator at ssl-config.mozilla.org now sends you to the TLSRef Configurator, and the Mozilla wiki page points to the guidelines at docs.tlsref.org. The content is the same lineage; the current guideline is version 6.0, which removed the old “Old” profile.

Two profiles remain:

Intermediate (recommended)
TLS 1.2 and 1.3. Works with nearly every client from the last five or more years, back to Chrome 49, Firefox 31.3, Safari 9, Android 4.4.2, IE 11 on Windows 10 and Java 8u161. Use this for public websites.
Modern
TLS 1.3 only. Oldest clients are Chrome 70, Firefox 63, Safari 12.1, Android 10, Java 11 and OpenSSL 1.1.1. Good for APIs and internal services where you control the clients.

In both profiles every cipher is forward secret and authenticated, and the client chooses the cipher (ssl_prefer_server_ciphers off). The guideline recommends ECDSA P-256 certificates, or RSA 2048 if you need RSA. Certbot has created ECDSA P-256 keys by default since version 2.0.

nginx: intermediate config

This is the configurator’s intermediate output for current nginx with OpenSSL 3.5 or later, with Let’s Encrypt paths filled in. Put the ssl_* lines in the http block so every site gets them:

# /etc/nginx/conf.d/tls.conf  (inside http {})
ssl_protocols TLSv1.2 TLSv1.3;
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;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;   # about 40000 sessions

# one site
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}

# send plain HTTP to HTTPS
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    return 308 https://$host$request_uri;
}

Check your library with openssl version. X25519MLKEM768 is a post-quantum hybrid key exchange and needs OpenSSL 3.5 or later. On older OpenSSL, the configurator leaves it out; use ssl_ecdh_curve X25519:prime256v1:secp384r1;. The http2 on; directive needs nginx 1.25.1 or later. Let’s Encrypt follows the redirect, so HTTP-01 renewals keep working if the HTTPS site serves /.well-known/acme-challenge/ from the webroot Certbot uses. Since nginx 1.23.2 the shared session cache also rotates session ticket keys for you, so you no longer need ssl_session_tickets off.

Apache: intermediate config

# mod_ssl, server-wide (e.g. /etc/apache2/conf-available/tls.conf)
SSLProtocol             -all +TLSv1.2 +TLSv1.3
SSLOpenSSLConfCmd       Curves X25519MLKEM768:X25519:prime256v1:secp384r1
SSLCipherSuite          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
SSLCipherSuite          TLSv1.3 TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
SSLHonorCipherOrder     off
SSLSessionTickets       off

<VirtualHost *:443>
    ServerName example.com
    SSLEngine on
    SSLCertificateFile      /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile   /etc/letsencrypt/live/example.com/privkey.pem
    Protocols h2 http/1.1
</VirtualHost>

Set SSLProtocol yourself. Apache’s built-in default is all -SSLv3, which still names TLS 1.0 and 1.1. Apache turns session tickets on by default, and its manual warns that tickets weaken forward secrecy unless the server restarts often, so the configurator turns them off. As with nginx, drop X25519MLKEM768 on OpenSSL older than 3.5. On Debian and Ubuntu enable the pieces with sudo a2enmod ssl http2 headers and sudo a2enconf tls.

Test, then apply. A reload is enough for config and certificate changes:

sudo nginx -t && sudo systemctl reload nginx
sudo apachectl configtest && sudo systemctl reload apache2   # httpd on RHEL family

HSTS, with care

HTTP Strict Transport Security tells browsers to use only HTTPS for your domain for max-age seconds. It stops downgrade attacks, but you cannot take it back: a browser that has seen the header refuses plain HTTP until the time runs out. With includeSubDomains that covers every subdomain, including old intranet or printer names that have no certificate.

Roll it out in steps, as hstspreload.org advises, and wait the full max-age at each step while you watch for broken pages:

  1. max-age=300 (5 minutes)
  2. max-age=604800 (1 week)
  3. max-age=2592000 (1 month)
  4. max-age=63072000 (2 years, the TLSRef value), adding includeSubDomains only when every subdomain works on HTTPS
# nginx, in each HTTPS server block
add_header Strict-Transport-Security "max-age=300" always;
# Apache, in the :443 VirtualHost (mod_headers)
Header always set Strict-Transport-Security "max-age=300"

In nginx, a location block with its own add_header drops the headers inherited from the server block, so HSTS can silently vanish on some paths. Repeat it there, or use add_header_inherit merge; on nginx 1.29.3 and later. Only add preload and submit to the browser preload list if you are sure: that list needs at least one year, includeSubDomains, and removal takes months.

OCSP stapling after Let’s Encrypt

OCSP stapling lets the server attach a fresh “not revoked” answer from the CA. Let’s Encrypt has left OCSP behind: it removed OCSP URLs from new certificates on May 7, 2025, rejected Must-Staple requests from that date, and switched its OCSP responders off on August 6, 2025. It now publishes revocation only through CRLs.

So for Let’s Encrypt certificates, stapling has nothing to fetch. Remove ssl_stapling, ssl_stapling_verify and the related resolver lines from nginx, and SSLUseStapling from Apache (it is off by default). Do not use Certbot’s --must-staple or --staple-ocsp options with Let’s Encrypt. Certificates from other CAs may still carry an OCSP URL; check before you decide:

openssl x509 -in /path/to/cert.pem -noout -ocsp_uri   # empty output = no OCSP

Automatic renewal and shorter lifetimes

Let’s Encrypt certificates still last 90 days by default (the “classic” profile), but that is shrinking:

Let’s Encrypt default lifetime plan, as of November 2026
FromClassic profile lifetimeAuthorization reuse
Now90 days30 days
February 10, 202764 days10 days
February 16, 202845 days7 hours

The opt-in tlsserver profile has issued 45-day certificates since May 13, 2026, and a shortlived profile gives about 6 days. Industry rules cap all public certificates at 100 days from March 15, 2027 and 47 days from March 15, 2029.

What to do now:

Test from outside

Quick check with OpenSSL (use the real host name so SNI picks the right site):

openssl s_client -connect example.com:443 -servername example.com -brief </dev/null
# should FAIL: old protocol refused
openssl s_client -connect example.com:443 -servername example.com -tls1_1 </dev/null
# mail server with STARTTLS
openssl s_client -connect mail.example.com:587 -starttls smtp -brief </dev/null

-brief prints the protocol, cipher and verification result. A failed TLS 1.1 test proves little on its own, because a recent OpenSSL client may refuse TLS 1.1 itself. The free testssl.sh tool checks properly, because it does not depend on what the local OpenSSL allows:

git clone --depth 1 https://github.com/testssl/testssl.sh.git
cd testssl.sh
./testssl.sh example.com                        # full report
./testssl.sh -p -U example.com                  # protocols + known vulnerabilities only
./testssl.sh --starttls smtp mail.example.com:25
./testssl.sh --jsonfile result.json example.com # for scripts and records

It also runs as a container: docker run --rm -it ghcr.io/testssl/testssl.sh example.com. Run it after every config change, and on a schedule, so drift shows up.

Restart after OpenSSL updates

A new certificate needs only a reload. A new OpenSSL library needs a restart: running nginx, Apache, PHP-FPM, Postfix, Dovecot and sshd keep the old libssl in memory until they restart, so a patched server can still serve the vulnerable code. After an OpenSSL update, run sudo needrestart (Debian/Ubuntu) or sudo dnf needs-restarting -s (RHEL family) and restart what it lists. This briefly drops connections. See restarting after an update and OpenSSL for supported branches.

Sources