<!-- https://pkizerotohero.com/guides/tls-configuration/ | Markdown mirror of the HTML page | PKI Zero to Hero, by Duyutech BV -->

> Configure TLS on nginx or Apache with the intermediate profile: TLS 1.2 and 1.3, strong ciphers, post-quantum groups, OCSP stapling, HSTS and testing.

Source: https://pkizerotohero.com/guides/tls-configuration/ · Last reviewed: 2026-10-20

How-to guide · Harden and secure

# How to configure TLS securely (versions, ciphers, HSTS)

**In one sentence:**

You will have a web server that offers only TLS 1.2 and 1.3 with forward-secret ciphers, staples OCSP only where it still applies, sends HSTS safely, and passes testing.

Task

[Harden and secure](https://pkizerotohero.com/guides/#task-secure)

Platforms

- nginx
- Apache
- Linux
- Windows

Who it is for

- [IT ops](https://pkizerotohero.com/start/#itops)
- [Developer](https://pkizerotohero.com/start/#developer)
- [Expert](https://pkizerotohero.com/start/#expert)

Level

Practitioner · 6 steps

## Before you start

- Administrator access to the server or load balancer that terminates [TLS](https://pkizerotohero.com/handbook/glossary/#tls). If a load balancer or CDN terminates TLS, configure it there.
- A working certificate served with its full chain (see [How to install a certificate in nginx](https://pkizerotohero.com/guides/install-certificate-nginx/)).
- The versions of your server software and of the OpenSSL library it uses.
- `openssl` for testing, and optionally [testssl.sh](https://github.com/testssl/testssl.sh), a free command-line scanner.

Why this matters: old defaults still allow protocol versions and ciphers that clients no longer need. Every one you remove is one less weakness to defend, and testing before you reload avoids surprise outages for older clients.

## Steps

### 1. Choose the intermediate profile

Mozilla’s Server Side TLS guidelines have long been the standard reference for server configuration. They are now maintained by the TLSRef community working group, and this guide follows the **intermediate** profile of version 6.0 (April 2026), “the recommended configuration for the vast majority of services”:

| Setting | Intermediate profile |
| --- | --- |
| Protocols | TLS 1.2 and TLS 1.3 |
| TLS 1.3 cipher suites | `TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256` (the defaults) |
| TLS 1.2 ciphers | ECDHE key exchange with AES-GCM or ChaCha20-Poly1305 only |
| Key exchange groups | `X25519MLKEM768` (OpenSSL 3.5 or later), `X25519`, `prime256v1`, `secp384r1` |
| Certificate | ECDSA P-256 (recommended) or RSA 2048 |
| Cipher order | The client chooses |
| HSTS | `max-age=63072000` (two years) |

The “modern” profile drops TLS 1.2 and suits services whose clients all support TLS 1.3, such as APIs used only by your own up-to-date software. Version 6.0 removed the “old” profile entirely. TLS 1.0 and 1.1 are gone from both, and the IETF agrees: RFC 8996 formally deprecated TLS 1.0 and 1.1 (March 2021).

For your exact server and versions, use the [TLSRef Configurator](https://configurator.tlsref.org/), the continuation of the Mozilla SSL Configuration Generator. The old address, `ssl-config.mozilla.org`, now forwards there.

### 2. Check which OpenSSL your server uses

The library decides what is possible. Post-quantum [hybrid key exchange](https://pkizerotohero.com/handbook/glossary/#hybrid-key-exchange) needs OpenSSL 3.5 or later:

```bash
nginx -V 2>&1 | grep -i openssl
openssl version
```

```text
built with OpenSSL 3.0.2 15 Mar 2022
OpenSSL 3.0.2 15 Mar 2022
```

This is our lab server on Ubuntu 22.04: nginx 1.18 with OpenSSL 3.0, so it must leave `X25519MLKEM768` out of the group list.

### 3. Set protocols, ciphers and groups

#### nginx

Put the shared settings in the `http` block, so every `server` block inherits them:

```text
http {
    # intermediate configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;   # OpenSSL 3.5+; else drop the first entry
    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

    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        http2 on;                           # nginx 1.25.1+; older: "listen 443 ssl http2;"
        server_name www.example.com;
        ssl_certificate     /etc/ssl/www.example.com/fullchain.pem;
        ssl_certificate_key /etc/ssl/www.example.com/privkey.pem;
    }
}
```

- `ssl_ciphers` applies to TLS 1.2 only. All TLS 1.3 suites are strong, and OpenSSL’s defaults match the profile.
- Every TLS 1.2 cipher in the list uses [ECDHE](https://pkizerotohero.com/handbook/glossary/#ecdhe), so every connection has [forward secrecy](https://pkizerotohero.com/handbook/glossary/#forward-secrecy), and an AEAD cipher.
- `ssl_prefer_server_ciphers off` lets the client pick: a phone without AES hardware can choose ChaCha20.
- The list holds ciphers for both ECDSA and RSA certificates, so it works with either.

#### Apache httpd

The same profile for Apache 2.4 with mod\_ssl, outside your `VirtualHost` blocks:

```text
# intermediate configuration
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
SSLHonorCipherOrder     off
SSLSessionTickets       off
```

Inside each `<VirtualHost *:443>`, point `SSLCertificateFile` at the certificate plus intermediates and `SSLCertificateKeyFile` at the key. As with nginx, drop `X25519MLKEM768` when Apache’s OpenSSL is older than 3.5.

#### Other servers

The configurator covers HAProxy, Caddy, Traefik, Tomcat, Postfix and many more. On Windows, IIS uses the operating system’s Schannel library: protocols and cipher suites are set for the whole machine through Group Policy or the registry, as described in Microsoft’s [TLS registry settings](https://learn.microsoft.com/en-us/windows-server/security/tls/tls-registry-settings).

### 4. Decide about OCSP stapling

[OCSP stapling](https://pkizerotohero.com/handbook/glossary/#ocsp-stapling) lets the server send its certificate’s revocation status in the handshake. It only works when the certificate names an OCSP responder, and that is no longer a given: OCSP became optional and CRLs became mandatory for publicly trusted TLS certificates (15 March 2024), and Let's Encrypt has shut down its OCSP service (since 6 August 2025). Check your own certificate:

```bash
openssl x509 -in fullchain.pem -noout -ocsp_uri
```

Run against two live sites in October 2026, the command printed `http://ocsp.digicert.com` for [www.digicert.com](http://www.digicert.com) and nothing at all for letsencrypt.org.

- **An OCSP URL is printed:** turn stapling on. In nginx, add `ssl_stapling on;`, `ssl_stapling_verify on;`, `ssl_trusted_certificate` pointing at the intermediate and root, and a `resolver` nginx can use to reach the responder. In Apache, `SSLUseStapling On` and an `SSLStaplingCache`.
- **Nothing is printed:** leave stapling off. There is nothing to staple, and nginx logs a warning and ignores the setting.

### 5. Add HSTS, carefully

[HTTP Strict Transport Security](https://pkizerotohero.com/handbook/glossary/#hsts) tells browsers to use only HTTPS for your site, for `max-age` seconds. Browsers accept the header only over HTTPS ([RFC 6797 section 8.1](https://www.rfc-editor.org/rfc/rfc6797#section-8.1)), so redirect plain HTTP first:

```text
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    return 308 https://$host$request_uri;
}
```

Then raise `max-age` in stages, so a mistake costs minutes rather than years: start with `max-age=300` (five minutes) while you test, then a week, then a month, then the profile’s two years. The final line in the HTTPS `server` block:

```text
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
```

In Apache: `Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"`.

- **`includeSubDomains`** covers every subdomain, including internal ones that browsers reach. Add it only when every one of them serves valid HTTPS.
- **`preload`** asks browsers to build your domain into their HSTS list. The [preload list](https://hstspreload.org/) requires `includeSubDomains`, `preload` and a `max-age` of at least 31536000 seconds (one year), and warns that “inclusion in the preload list cannot easily be undone”. Removal takes months to reach users. Never add `preload` as a default in a shared template.

### 6. Test the configuration, then reload

```bash
sudo nginx -t && sudo systemctl reload nginx
sudo apachectl configtest && sudo systemctl reload apache2
```

A reload keeps existing connections; a failed test leaves the running configuration untouched.

## Check it worked

These are real results from our lab nginx with the configuration above (OpenSSL 3.0 as the client). TLS 1.3 and TLS 1.2 succeed, and TLS 1.2 uses ECDHE:

```bash
openssl s_client -connect 127.0.0.1:8443 -servername localhost -CAfile ca.crt -verify_hostname localhost -tls1_2 -brief </dev/null
```

```text
CONNECTION ESTABLISHED
Protocol version: TLSv1.2
Ciphersuite: ECDHE-ECDSA-AES256-GCM-SHA384
Peer certificate: CN = localhost
Hash used: SHA256
Signature type: ECDSA
Verification: OK
Verified peername: localhost
Supported Elliptic Curve Point Formats: uncompressed:ansiX962_compressed_prime:ansiX962_compressed_char2
Server Temp Key: X25519, 253 bits
```

To prove the **server** refuses TLS 1.1, lower the client’s own security level first. Without it, OpenSSL 3 refuses locally with `no protocols available`, which tests nothing:

```bash
openssl s_client -connect 127.0.0.1:8443 -servername localhost -tls1_1 -cipher "DEFAULT:@SECLEVEL=0" </dev/null
openssl s_client -connect 127.0.0.1:8443 -servername localhost -tls1_2 -cipher AES128-SHA </dev/null
```

```text
…:error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version:…:SSL alert number 70
…:error:0A000410:SSL routines:ssl3_read_bytes:sslv3 alert handshake failure:…:SSL alert number 40
```

Alert 70 (`protocol_version`) and alert 40 (`handshake_failure`) are the server saying no. Then check the header with `curl -sI https://www.example.com/ | grep -i strict`.

testssl.sh 3.2.4 checks everything at once. The protocol and header sections for the same lab server:

```bash
./testssl.sh -p -h https://localhost:8443
```

```text
 SSLv2      not offered (OK)
 SSLv3      not offered (OK)
 TLS 1      not offered
 TLS 1.1    not offered
 TLS 1.2    offered (OK)
 TLS 1.3    offered (OK): final
...
 Strict Transport Security    730 days=63072000 s, includeSubDomains
```

## If something goes wrong

- **nginx refuses to start with `SSL_CTX_set1_curves_list("X25519MLKEM768:X25519:prime256v1:secp384r1") failed`.** Its OpenSSL is older than 3.5. Remove `X25519MLKEM768` from the list (real message from our lab).
- **Old clients fail with [ERR\_SSL\_VERSION\_OR\_CIPHER\_MISMATCH](https://pkizerotohero.com/errors/chrome-err-ssl-version-or-cipher-mismatch/)** or Java’s [handshake\_failure](https://pkizerotohero.com/errors/java-handshake-failure/). Upgrade the client first. Re-enable an old cipher only for a named, dated exception.
- **`curl: (35)` with `wrong version number`** usually means plain HTTP answered on the TLS port ([curl: (35)](https://pkizerotohero.com/errors/curl-35-ssl-connect-error/)).
- **A subdomain became unreachable after HSTS.** It has no valid HTTPS, and browsers will insist on it until `max-age` runs out. Give it HTTPS; shortening the header only helps browsers that visit again.
- **The settings seem to have no effect.** Another `server` block, an included file or a load balancer in front terminates TLS. Find the real endpoint with `openssl s_client` and the certificate it returns.

Paste any handshake error into the error finder to get its page:

*Interactive tool: Paste your error (runs in the browser at [/tools/error-finder/](https://pkizerotohero.com/tools/error-finder/))*

## Why this works

Each TLS connection starts with a handshake in which the client lists what it supports and the server picks the best common protocol, cipher and key-exchange group ([the TLS 1.3 handshake](https://pkizerotohero.com/handbook/tls-handshake/)). Limiting the server’s lists removes weak options without hurting modern clients. ECDHE gives forward secrecy, and hybrid groups add protection against “harvest now, decrypt later” ([post-quantum cryptography](https://pkizerotohero.com/handbook/post-quantum/)). Stapling and its decline are explained in [revocation](https://pkizerotohero.com/handbook/revocation/).

## Go deeper

- [TLSRef: Server-Side TLS](https://docs.tlsref.org/server-side-tls.html): the profiles, their rationale and version history.
- [TLSRef Configurator](https://configurator.tlsref.org/): ready configurations for dozens of servers.
- [RFC 9325](https://www.rfc-editor.org/rfc/rfc9325) (BCP 195): the IETF’s recommendations for secure use of TLS.
- [RFC 6797](https://www.rfc-editor.org/rfc/rfc6797): HTTP Strict Transport Security.
- [HSTS preload list](https://hstspreload.org/): requirements and removal.
- [nginx ngx\_http\_ssl\_module](https://nginx.org/en/docs/http/ngx_http_ssl_module.html) and [Apache mod\_ssl](https://httpd.apache.org/docs/2.4/mod/mod_ssl.html): every directive used above.

**Early edition.** Checked against the standards; an independent expert review is in progress. Found a mistake? [Tell us](https://pkizerotohero.com/contact/?topic=content-error&page=/guides/tls-configuration/).

Last reviewed 20 October 2026Edition 0.9

## Keep going

### Learn why it works

- [Chapter 15: The TLS 1.3 handshake](https://pkizerotohero.com/handbook/tls-handshake/)
- [Chapter 16: Taking it back: CRLs, OCSP and stapling](https://pkizerotohero.com/handbook/revocation/)
- [Chapter 26: Post-quantum cryptography](https://pkizerotohero.com/handbook/post-quantum/)

### Error pages

- [ERR\_SSL\_VERSION\_OR\_CIPHER\_MISMATCH](https://pkizerotohero.com/errors/chrome-err-ssl-version-or-cipher-mismatch/)
- [Received fatal alert: handshake\_failure](https://pkizerotohero.com/errors/java-handshake-failure/)
- [curl: (35) … wrong version number](https://pkizerotohero.com/errors/curl-35-ssl-connect-error/)

### Tools

- [Paste your error](https://pkizerotohero.com/tools/error-finder/): Paste a certificate error from any tool and get the page that explains it.

### Topics

- [TLS and HTTPS](https://pkizerotohero.com/topics/tls/)
- [Post-quantum cryptography](https://pkizerotohero.com/topics/post-quantum/)

## More guides: Harden and secure

- [How to set CAA records for your domain](https://pkizerotohero.com/guides/caa-records/)
- [How to set up mutual TLS (nginx and curl)](https://pkizerotohero.com/guides/mutual-tls-setup/)
