Installing SSL Certificates on Nginx

How to configure SSL/TLS on nginx with proper cipher suites, HSTS, and security headers. Includes a production-ready config and testing instructions.


Before You Start

You need two files to enable SSL on nginx:

  • Certificate file (ssl_certificate) — your server certificate, optionally concatenated with intermediate CA certificates
  • Private key file (ssl_certificate_key) — the private key that matches the certificate

If you don't have these yet, generate a test certificate at getaCert.com to follow along. For production, use a certificate from Let's Encrypt, your organization's CA, or a commercial provider.

Basic SSL Configuration

The minimum viable SSL config for nginx:

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/nginx/ssl/certificate.crt;
    ssl_certificate_key /etc/nginx/ssl/private.key;

    location / {
        root /var/www/html;
        index index.html;
    }
}

This works but uses nginx's default protocol and cipher settings, which may include outdated options. The sections below harden it for production.

Production-Ready Configuration

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

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

    # Certificate and key
    ssl_certificate     /etc/nginx/ssl/certificate.crt;
    ssl_certificate_key /etc/nginx/ssl/private.key;

    # Protocols — TLS 1.2 and 1.3 only
    ssl_protocols TLSv1.2 TLSv1.3;

    # Ciphers — server preference, strong suites only
    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;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/nginx/ssl/ca-chain.crt;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # Session settings
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;

    # DH parameters (generate with: openssl dhparam -out dhparam.pem 2048)
    ssl_dhparam /etc/nginx/ssl/dhparam.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        root /var/www/html;
        index index.html;
    }
}

Directive-by-Directive Breakdown

ssl_protocols

ssl_protocols TLSv1.2 TLSv1.3;

TLS 1.0 and 1.1 are deprecated (RFC 8996, March 2021). Every modern browser and HTTP client supports TLS 1.2. TLS 1.3 is faster (one fewer round trip) and removes legacy cipher suites entirely. There is no reason to enable anything older.

ssl_ciphers

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;

This list includes only AEAD ciphers with forward secrecy (ECDHE key exchange). AES-GCM is fast on hardware with AES-NI (most servers). ChaCha20-Poly1305 is fast on devices without AES-NI (mobile, ARM).

Note: TLS 1.3 ciphers are configured separately by OpenSSL and are always strong. The ssl_ciphers directive only affects TLS 1.2 negotiation.

ssl_prefer_server_ciphers

ssl_prefer_server_ciphers off;

With TLS 1.3 and the cipher list above, client preference is fine. All the listed ciphers are strong; letting the client choose allows it to pick the fastest option for its hardware.

HSTS (HTTP Strict Transport Security)

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

HSTS tells browsers to only connect over HTTPS for the specified duration (here, two years). Once a browser sees this header, it will refuse HTTP connections to your domain entirely — even if the user types http://.

Parameter Effect
max-age=63072000 Browser remembers HTTPS-only for 2 years
includeSubDomains Applies to all subdomains
preload Eligible for the HSTS preload list (hardcoded in browsers)

Warning: Only enable includeSubDomains if every subdomain supports HTTPS. Only add preload if you are committed to HTTPS permanently — removal from the preload list takes months.

Session Configuration

ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
  • ssl_session_cache shared:SSL:10m — a 10 MB shared cache across all worker processes. This holds roughly 40,000 sessions and allows clients to resume previous connections without a full handshake.
  • ssl_session_tickets off — session tickets can weaken forward secrecy if the ticket key is compromised. With TLS 1.3's built-in 0-RTT resumption, tickets are less necessary.

OCSP Stapling

ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/ca-chain.crt;

Without OCSP stapling, the client's browser contacts the CA's OCSP responder to check if your certificate has been revoked. This adds latency and leaks browsing data to the CA. With stapling, nginx fetches the OCSP response itself and includes it in the TLS handshake.

ssl_trusted_certificate must point to the full CA chain (intermediate + root) for OCSP verification to work. This is not the same as ssl_certificate.

DH Parameters

ssl_dhparam /etc/nginx/ssl/dhparam.pem;

Generate custom DH parameters:

openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048

This takes a minute or two. Custom DH parameters prevent attacks against the default 1024-bit parameters that ship with some OpenSSL versions. With the cipher list above (ECDHE only), DHE ciphers are not negotiated, so this is a defense-in-depth measure.

Certificate Chain Order

The ssl_certificate file must contain certificates in the correct order:

-----BEGIN CERTIFICATE-----
(Your server certificate)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(Intermediate CA certificate)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(Root CA certificate — optional, clients usually have this)
-----END CERTIFICATE-----

Concatenate them:

cat server.crt intermediate.crt > certificate-chain.crt

If the chain is wrong, browsers may show errors even though the certificate itself is valid. Use the test below to diagnose chain issues.

Testing Your Configuration

Syntax Check

Always test the nginx config before reloading:

nginx -t

Reload nginx

sudo systemctl reload nginx
# or
sudo nginx -s reload

Use reload, not restart. A reload applies the new config without dropping existing connections.

Test with openssl s_client

The most useful tool for debugging SSL issues:

openssl s_client -connect example.com:443 -servername example.com

Check for:

  • Certificate chain: verify that intermediate certificates are present
  • Protocol: should show TLSv1.2 or TLSv1.3
  • Cipher: should be one of your configured AEAD ciphers
  • Verify return code: 0 (ok) means the chain is valid

Test Specific Protocol Versions

# Should succeed
openssl s_client -connect example.com:443 -tls1_2

# Should succeed
openssl s_client -connect example.com:443 -tls1_3

# Should fail (disabled)
openssl s_client -connect example.com:443 -tls1_1

Test with curl

curl -vI https://example.com 2>&1 | grep -E 'SSL|TLS|certificate|subject|issuer'

Online Tools

Common Issues

"ssl_certificate does not match ssl_certificate_key"

The certificate and key don't form a pair. Verify they match:

openssl x509 -noout -modulus -in certificate.crt | openssl md5
openssl rsa -noout -modulus -in private.key | openssl md5

Both MD5 hashes must be identical.

"no common SSL cipher"

Your ssl_ciphers list does not intersect with the client's supported ciphers. This usually means you disabled too many cipher suites, or the client is very old.

HSTS causing localhost issues

If you accidentally set HSTS on localhost or a dev domain, your browser will refuse HTTP connections to it. Clear the HSTS entry in your browser (in Chrome: chrome://net-internals/#hsts) or use a different hostname.

Certificate expired

Check expiry:

echo | openssl s_client -connect example.com:443 2>/dev/null | \
  openssl x509 -noout -dates

Quick Start for Local Development

To try this configuration locally without a real domain:

  1. Generate a test certificate at getaCert.com with Common Name set to localhost
  2. Save the cert as /etc/nginx/ssl/certificate.crt and the key as /etc/nginx/ssl/private.key
  3. Use the production config above, changing server_name to localhost
  4. Comment out the OCSP stapling lines (self-signed certs don't have OCSP responders)
  5. Reload nginx and visit https://localhost

Your browser will warn about the untrusted certificate — that is expected with self-signed certs. Accept the warning or import the certificate into your OS trust store.


More in Guides