Technology

Nginx HTTPS: TLS 1.3, OCSP Stapling and Brotli

A site can feel slow even when the server has spare capacity. DNS resolution, connection setup, and response transfer can delay a first page view even after application and database tuning.

This guide explains Nginx TLS 1.3 settings, Brotli compression with gzip fallback, and how to check certificate-status support. The configuration examples assume existing Let’s Encrypt certificates and omit OCSP because that CA retired the service. For other issuers, the OCSP section explains prerequisites and verification; it is not a complete alternate-CA installation recipe.

Connection Setup and First-Response Latency

The first request can involve DNS resolution, TCP and TLS setup, then application processing and response transfer. A slow first page view does not identify which stage is responsible. Measure the connection and application stages separately before choosing a change.

TLS 1.3 can reduce handshake round trips compared with a full TLS 1.2 handshake. When the certificate and CA support OCSP, stapling can supply a signed status response without a separate lookup by clients that use it. Brotli can reduce text response sizes; its benefit depends on content and settings.

Use the examples below as configuration fragments to adapt to your installed modules, certificates and application. Compare connection timing, response sizes and CPU usage before and after each change.

Configure TLS 1.3 Alongside TLS 1.2

TLS 1.3 reduces handshake round trips compared with a full TLS 1.2 handshake and uses modern cipher suites. In Nginx, ssl_protocols enables TLS 1.3 alongside TLS 1.2 for compatibility; ssl_ciphers selects the TLS 1.2 suites, not the TLS 1.3 suites.

A minimal TLS configuration

Here’s a trimmed starting example. It assumes you’ve got a valid certificate and chain (fullchain) and the private key. Use consistent domain and certificate paths: example.com and www.example.com are placeholders, and the certificate must cover the configured names. For an internationalized domain, use its ASCII/Punycode form in server_name and in SNI test commands. This requires a TLS 1.3-capable Nginx/TLS-library build, the Nginx HTTP/2 module, and ALPN support in the TLS library. Check the installed build with nginx -V. HTTP/2 can also use TLS 1.2; TLS 1.3 is not its prerequisite. The examples retain the older listen 443 ssl http2 syntax. On Nginx 1.25.1 and later, use listen 443 ssl; with http2 on; in the same server block. These are alternative syntaxes to select for the installed version. The HSTS header below commits the host and all subdomains to HTTPS for a year: use it only after confirming they all support HTTPS. The preload token alone does not enroll a domain in browser preload lists.

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # TLS versions: keep TLSv1.2 for compatibility, use TLSv1.3 for speed
    ssl_protocols TLSv1.2 TLSv1.3;

    # Reasonable TLSv1.2 ciphers; TLS 1.3 ciphers are not configured here (they're implicit)
    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';

    # Curve preference for ECDHE
    ssl_ecdh_curve X25519:secp384r1;

    # Session settings (resumption helps)
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    ssl_session_tickets off;

    # Security headers (adapt HSTS for your policy)
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options DENY;
    add_header Referrer-Policy no-referrer-when-downgrade;

    # ...your location blocks...
}

That’s the starter. If you’re wondering about HTTP/3 and QUIC, that’s a separate Nginx build or a newer package with the quic module. It’s great when you’re ready for it, but you don’t need it to benefit from TLS 1.3. Start with HTTP/2, ensure stability, then move up when your stack is ready.

Session tickets, caches, and key rotation

Session resumption can speed up repeat connections, not first visits. TLS 1.2 supports session IDs and tickets; TLS 1.3 resumption uses tickets, which can carry stateful references or encrypted state. The effect of disabling tickets depends on the TLS library and protocol version. Nginx 1.23.2 and later can automatically rotate ticket keys with a shared session cache unless keys are explicitly configured. If managing keys yourself, rotate and protect them.

About 0‑RTT (early data)

0‑RTT lets resumed connections send application data before the handshake completes, but that data can be replayed. A GET method alone does not establish replay safety. Leave early data disabled unless the application explicitly handles replay and rejects early data on unsafe routes, including checkout and sensitive writes.

When browsers still negotiate TLS 1.2

You’ll still see TLS 1.2 in the logs. That’s normal. Older clients and certain corporate environments can lag a bit. Keep TLS 1.2 around for now with a clean cipher list. As usage shifts, you can revisit and simplify further. Balance compatibility against security policy before disabling TLS 1.2, especially where critical clients still depend on it.

OCSP stapling: certificate-status responses where supported

Some clients query a CA’s OCSP responder for certificate revocation status, which can add latency. Browser revocation mechanisms vary, so this is not a lookup every browser performs.

OCSP stapling flips that around. Your server fetches the fresh OCSP response from the CA and “staples” it to the TLS handshake. A client that supports stapling can use that signed status response without a separate OCSP lookup; it still checks the certificate’s chain, hostname, and validity period.

Nginx stapling prerequisites and the Let’s Encrypt exception

First confirm that your CA supports OCSP. Let’s Encrypt ended its OCSP service on August 6, 2025; current certificates contain no OCSP URL, and revocation information is published through CRLs. The example below therefore keeps the Let’s Encrypt certificate paths but omits OCSP stapling. It is an alternative fragment for the existing server, not another server block to add with the same name; retain the TLS and application settings needed by that server. For a different, OCSP-capable CA, Nginx needs the issuer certificate and a working resolver for the responder’s hostname. Enabling ssl_stapling_verify also requires ssl_trusted_certificate to reference a PEM trust bundle containing the issuer, root, and all intermediate certificates; ssl_certificate fullchain alone is not that trust configuration.

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Current Let's Encrypt certificates do not support OCSP stapling.
    # No stapling or OCSP resolver directives are needed here.

    # TLS versions/ciphers as above...
}

If stapling is absent, check CA support, responder reachability, chain configuration, and Nginx error logs. Nginx may need an initial connection to trigger fetching, so retry after allowing time for the response to arrive.

Verifying stapling with OpenSSL

OpenSSL can provide a quick check. This command will show you if the server stapled an OCSP response:

openssl s_client -connect example.com:443 -servername example.com -status < /dev/null | sed -n "/OCSP response:/,/^$/p"

Look for “OCSP Response Status: successful” and “Cert Status: good”, and inspect the response timestamps. Printed status alone does not prove signature validity. An absent staple is expected for current Let’s Encrypt certificates. For an OCSP-capable CA, check the chain and logs if it is missing, and recheck after certificate changes.

Brotli: smaller responses and compression trade-offs

Brotli can reduce HTML, CSS, and JavaScript transfer sizes compared with gzip, depending on content and compression settings. Dynamic compression costs CPU; precompressing static assets moves that work out of the request path. Already-compressed images usually benefit little.

Brotli requires a compatible additional Nginx module. Use a supported distribution package or a reproducible module build matched to your Nginx installation, then enable it before applying Brotli directives.

Installing the Brotli module

On some systems, you’ll find a package like nginx-module-brotli or similar. If not, you can build the module from source. The upstream module lives here: Google’s ngx_brotli repository. If your distro provides a compatible package, that avoids a manual build. Otherwise, keep the module build reproducible and compatible with your installed Nginx version; an Nginx upgrade may require rebuilding the dynamic module. For dynamic modules, load_module belongs in the main configuration context, outside http. Use the installed module paths for ngx_http_brotli_filter_module.so and ngx_http_brotli_static_module.so; do not duplicate loaders already supplied by the package. A file included from inside http is still in that context and cannot contain load_module. Merge the http settings below into the existing http block rather than creating a second one.

Brotli configuration with gzip fallback

You can set Brotli in the http block and let it cascade to servers unless you need overrides. Keep gzip enabled as a fallback for clients that support gzip but not Brotli.

http {
    # Brotli on, gzip as fallback
    brotli on;
    brotli_comp_level 5;           # start at 4-6; higher is slower but smaller
    brotli_static on;              # serve pre-compressed .br files if present
    brotli_min_length 1024;        # skip tiny responses
    brotli_types text/plain text/css text/xml application/javascript 
                 application/json application/xml application/rss+xml 
                 image/svg+xml application/font-woff2;

    gzip on;
    gzip_vary on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml application/javascript 
               application/json application/xml application/rss+xml 
               image/svg+xml;

    # ... rest of your http config and server blocks ...
}

If you build assets during deployment, consider pre-compressing them to .br and .gz and letting Nginx serve those directly via brotli_static and gzip_static. That moves compression work out of the request path. The example above enables brotli_static, but serving .gz files also requires the gzip_static module to be available and enabled. Generate compressed variants from the same build as their source files and publish them together so stale compressed copies cannot serve different content. For dynamic pages, measure transfer savings against CPU cost rather than assuming runtime compression will help. Responses combining secrets with attacker-controlled text can expose compression side channels; assess both gzip and Brotli policies before compressing such sensitive responses.

Check Brotli Response Headers

A quick header check uses curl with an Accept-Encoding header:

curl -I -H 'Accept-Encoding: br' https://example.com/

Look for a Content-Encoding: br response. This command sends HEAD, so also check a GET response for a compressible resource of sufficient size before concluding Brotli is broken. With only br advertised, gzip is not a valid fallback; investigate the origin, proxy, or cache if it appears. If compression is absent, check module loading, MIME type, response size, and whether a proxy strips Accept-Encoding.

Both examples set gzip_vary on: ngx_brotli’s runtime filter and brotli_static on use Nginx’s gzip vary machinery, and this setting enables Vary: Accept-Encoding for eligible responses. Keep brotli_static on rather than always so client encoding support is respected. For a compressible resource, check GET responses with Accept-Encoding set separately to br, gzip, and identity. Inspect actual Content-Encoding and Vary headers; identity should have no Content-Encoding. Repeat through any CDN and verify that its cache keeps encoding variants separate and never serves br or gzip to a client that does not accept it. Check precompressed assets as well as runtime-compressed responses, including .gz assets if gzip_static is enabled.

Combine TLS and Compression Settings

Let’s combine the pieces into an example to adapt, not a drop-in configuration. Merge these settings into your existing http block after loading the Brotli modules. Tweak domains, paths, and policy headers. Do not adopt the year-long HSTS policy with includeSubDomains unless every affected subdomain can remain HTTPS-only. OCSP settings apply only when your certificate issuer supports OCSP.

# In /etc/nginx/nginx.conf (http block)
http {
    # Logging, timeouts, etc.
    sendfile on;
    keepalive_timeout 65;

    # Brotli + gzip
    brotli on;
    brotli_comp_level 5;
    brotli_static on;
    brotli_min_length 1024;
    brotli_types text/plain text/css text/xml application/javascript 
                 application/json application/xml application/rss+xml 
                 image/svg+xml application/font-woff2;

    gzip on;
    gzip_vary on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/xml application/javascript 
               application/json application/xml application/rss+xml 
               image/svg+xml;

    # Server block(s)
    server {
        listen 443 ssl http2;
        server_name example.com www.example.com;

        ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

        # TLS versions
        ssl_protocols TLSv1.2 TLSv1.3;

        # TLSv1.2 ciphers (TLS 1.3 ciphers are implicit)
        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_ecdh_curve X25519:secp384r1;

        ssl_session_timeout 1d;
        ssl_session_cache shared:SSL:50m;
        ssl_session_tickets off;

        # No OCSP stapling for current Let's Encrypt certificates:
        # the CA retired its OCSP service on August 6, 2025.

        # Security headers
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
        add_header X-Content-Type-Options nosniff;
        add_header X-Frame-Options DENY;
        add_header Referrer-Policy no-referrer-when-downgrade;

        root /var/www/example.com/public;
        index index.html index.htm;

        location / {
            try_files $uri $uri/ =404;
        }

        # Health and ACME challenges
        location ^~ /.well-known/acme-challenge/ {
            root /var/www/letsencrypt;
        }
    }

    # HTTP to HTTPS redirect
    server {
        listen 80;
        server_name example.com www.example.com;
        return 301 https://$host$request_uri;
    }
}

For organization, split TLS and compression snippets into separate include files and include them in the appropriate http or server context. The example retains Let’s Encrypt certificate paths but omits OCSP settings: Let’s Encrypt retired OCSP on August 6, 2025 and now publishes revocation information through CRLs. For another CA that supports OCSP, enable stapling only with that issuer’s requirements satisfied. Nginx ssl_stapling_verify requires ssl_trusted_certificate to point to a PEM trust bundle containing the issuer, root, and all intermediate certificates; ssl_certificate fullchain alone does not provide that verification trust configuration. The server must also be able to resolve and reach the certificate’s OCSP responder.

Validate TLS, Compression and CDN Behavior

Check the configuration in three passes: local command-line checks, an external scanner, and live connection logs once traffic flows.

Local Protocol Checks

First, verify protocol coverage. Test TLS 1.3 explicitly:

openssl s_client -connect example.com:443 -servername example.com -tls1_3 < /dev/null | sed -n '/Protocol/ p; /Cipher/ p'

You should see TLSv1.3 and a modern cipher such as TLS_AES_128_GCM_SHA256 or TLS_CHACHA20_POLY1305_SHA256. Then confirm stapling if your issuer supports OCSP, as shown earlier, and check Brotli:

curl -I -H 'Accept-Encoding: br' https://example.com/

Interpret this HEAD response using the checks in “Check Brotli Response Headers”; confirm with GET before diagnosing missing compression.

External validation

After local checks, use two complementary resources. For a recommended baseline, use the Mozilla SSL Configuration Generator as a quick cross‑check of the proposed configuration. For a live public scan of your domain’s setup, SSL Labs’ Server Test is the classic. The live scan can flag certificate chain issues and old protocols; the generator supplies a configuration baseline rather than inspecting your deployment.

Interpret Origin and Edge Logs

Use Nginx error logs and access logs with configured TLS variables to inspect connections reaching the origin. If a CDN terminates browser TLS, these logs describe the CDN-to-origin hop; use edge telemetry for browser support. If direct clients frequently negotiate TLS 1.2, identify compatibility requirements before disabling it. Brotli support is negotiated separately through HTTP Accept-Encoding, so check it independently of the TLS version.

OCSP and Brotli troubleshooting checks

For OCSP stapling, the Nginx server must be able to resolve and reach the certificate’s OCSP responder, where one is provided; the staging domain itself need not be publicly resolvable for that fetch. Also check the issuer chain and verification trust configuration. For uncompressed SVG files, check that the response MIME type is image/svg+xml and is included in brotli_types.

Do TLS 1.3, OCSP, and Brotli play nicely with CDNs and WAFs?

Mostly, yes. If you terminate TLS at a CDN, your origin’s TLS settings matter for the CDN‑to‑origin hop, while the CDN’s edge settings control what users see. For certificates from OCSP-supporting CAs, check the CDN’s stapling behavior separately; current Let’s Encrypt certificates do not use OCSP. For security layers, your TLS config is one part of the stack; you may also want smart rules against abuse. For more on these layers, see the guide to WAF and bot protection with Cloudflare, ModSecurity, and Fail2ban. It pairs nicely with a tight TLS setup.

Compression and HTTPS Policy Checks

Compression levels above 6 sound tempting but can cost CPU on busy nodes. Start at 4 or 5. Leave 0‑RTT off unless every request accepted as early data is safe to replay; GET alone does not guarantee that. Keep TLS 1.2 on for compatibility unless you’re certain your audience doesn’t need it. With HSTS includeSubDomains, every affected subdomain must support HTTPS for the policy’s lifetime. The preload token alone does not enroll a domain in browser preload lists; submission and acceptance are separate, and removal can take time.

Version-control nonsecret configuration and record why each setting is present. Keep private keys and credentials out of that repository. Test changes in staging and validate the configuration before reloading with the service manager appropriate to your installation. A configuration test alone does not verify live TLS or HTTP behavior: inspect reload errors and test new connections afterward. Document the previous working configuration and a tested reload or rollback procedure. Include certificate renewal in maintenance checks and confirm that Nginx serves the renewed certificate, with the expected names and chain.

Final Validation Checklist

Before rollout, confirm each required behavior:

Verify TLS 1.3 and required TLS 1.2 compatibility. For an OCSP-capable certificate and CA, inspect stapling, responder reachability and verification trust; expect no staple for current Let’s Encrypt certificates. Check br, gzip and identity response variants through the origin and CDN. Confirm that HSTS, framing and other headers fit every affected service. Review errors and CPU usage under representative load.

When those boxes are ticked, the configuration is ready for measurement—not a guarantee of faster pages or fewer support requests. Compare connection timing, response sizes, and page-load performance before and after the changes.

Measure Results and Maintain the Configuration

TLS configuration and compression address connection overhead and transfer size. They do not eliminate application or database bottlenecks. Compare measured results for the clients, content and network paths your service actually uses.

Use the Mozilla SSL Configuration Generator for a current configuration baseline and SSL Labs’ Server Test for an external assessment. Recheck after certificate, Nginx, module or CDN changes, and investigate functional failures even when a scanner gives a good score.

Keep the measured baseline, selected settings and recovery steps together so later changes can be evaluated against known behavior.

Frequently Asked Questions

Not necessarily. Some distributions offer a compatible Brotli module package. Otherwise, you can build ngx_brotli as a dynamic module and load it into Nginx. Keep the build compatible with your installed Nginx version; upgrades may require rebuilding the module.

Keep TLS 1.2 enabled with an appropriate cipher list while clients still depend on it. Enabling TLS 1.3 does not require disabling TLS 1.2. Review client usage and security policy before removing compatibility. If a CDN terminates browser TLS, use edge telemetry rather than origin logs to assess browser support.

First confirm that your certificate and CA support OCSP; current Let’s Encrypt certificates do not. Run openssl s_client -connect example.com:443 -servername example.com -status < /dev/null. Look for a successful OCSP response, good certificate status, and appropriate timestamps; printed status alone does not verify the signature. If a supported staple is missing, check responder resolution and reachability, the issuer chain, verification trust configuration, and Nginx logs. Allow time for the initial fetch, then retry.
↑