Install Nginx from the Official Repository

The Nginx package in most distro repositories lags behind. Ubuntu 24.04 ships 1.24.x; the official Nginx repo has 1.26.2 with HTTP/3 support and newer TLS defaults. Add it before you touch any SSL configuration.

After adding the repo and installing, verify the binary includes the modules you need. OpenSSL version matters here - Nginx 1.26.x linked against OpenSSL 3.3+ gets TLS 1.3 with X25519 key exchange by default.

# Add official Nginx signing key and repo
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
  | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null

echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/ubuntu noble nginx" \
  | sudo tee /etc/apt/sources.list.d/nginx.list

sudo apt update && sudo apt install nginx=1.26.2-1~noble

# Verify OpenSSL version linked
nginx -V 2>&1 | grep -o 'OpenSSL [0-9.]*'
# Expected: OpenSSL 3.3.x or higher

Obtain a Certificate with Certbot

Let's Encrypt remains the standard for most deployments. Certbot 2.x with the Nginx plugin handles both issuance and renewal, and it writes a basic SSL server block automatically - though you will replace most of it.

If you are running multiple services under a single domain or planning to consolidate several projects under one certificate, wildcard certs require DNS-01 challenge instead of HTTP-01. We tested this with Cloudflare's DNS plugin on our staging server. The wildcard flow takes an extra plugin install but the renewal still runs unattended.

For a straightforward single-domain certificate, the HTTP-01 flow below completes in under 30 seconds on a server with port 80 open.

# Install Certbot and Nginx plugin
sudo apt install certbot python3-certbot-nginx

# Issue certificate - HTTP-01 challenge
sudo certbot --nginx -d example.com -d www.example.com \
  --email admin@example.com --agree-tos --no-eff-email

# For wildcard cert via DNS-01 (Cloudflare example)
sudo apt install python3-certbot-dns-cloudflare
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/cloudflare.ini \
  -d '*.example.com' -d example.com

# Verify certificate chain
openssl s_client -connect example.com:443 -showcerts 2>/dev/null \
  | openssl x509 -noout -dates

Write a Hardened Nginx Server Block

Certbot's auto-generated config is a starting point, not a finished product. It enables TLS 1.2 and 1.3 but leaves cipher selection, HSTS, OCSP stapling, and session parameters at defaults. The configuration below is what we run in production for customer-facing HTTPS services.

Key decisions here: TLS 1.2 is kept for clients that still cannot do TLS 1.3 (certain embedded systems, older Java versions below 11). If your userbase is exclusively modern browsers and APIs, dropping TLS 1.2 entirely is reasonable. The cipher string below scores A+ on SSL Labs while maintaining compatibility with Android 5.0+ and Java 11+.

The `ssl_stapling` directives require your certificate chain to include an intermediate certificate. Certbot handles this automatically with `fullchain.pem`. Do not use `cert.pem` for the certificate directive.

# /etc/nginx/conf.d/example.com.conf

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com www.example.com;

    # Certificate paths (Certbot defaults)
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Protocol and cipher hardening
    ssl_protocols TLSv1.2 TLSv1.3;
    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:DHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;

    # Session parameters
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;  # ~40,000 sessions
    ssl_session_tickets off;

    # OCSP Stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # HSTS - 2 years, include subdomains, preload
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # Additional security headers
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    root /var/www/example.com;
    index index.html;
}
// advertisement

Generate a Strong Diffie-Hellman Group

The default DHE group in OpenSSL is 1024 bits for legacy compatibility. That is broken. If you include any DHE ciphers in your cipher string (DHE-RSA-AES128-GCM-SHA256 appears in the Mozilla Intermediate config), generate a 4096-bit DH parameter file.

On a modern server this takes 15-30 seconds. On constrained hardware it can take several minutes. Generate it once and reference it in every server block.

If you use exclusively ECDHE ciphers and drop the DHE-RSA entries from the cipher string, you can skip this step entirely. ECDHE with X25519 is faster and stronger than DHE-4096.

# Generate 4096-bit DH parameters (run once)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 4096

# Add to the ssl server block in nginx config
# ssl_dhparam /etc/nginx/dhparam.pem;

# Verify the parameter size
openssl dhparam -in /etc/nginx/dhparam.pem -text -noout | grep 'DH Parameters'

Configure OCSP Stapling and Verify It Works

OCSP stapling sends the certificate revocation status directly in the TLS handshake, eliminating the client-side OCSP lookup that adds 50-200ms of latency per connection. Nginx fetches the OCSP response in the background and caches it.

After reloading Nginx, the first request to the server triggers the background OCSP fetch. The stapled response will not appear until the second or third request. Do not panic when the first test shows no stapling - wait 60 seconds and test again.

The `resolver` directive is mandatory for stapling. Without it Nginx cannot reach the OCSP responder and silently disables stapling. We have seen this bite engineers who copy config from servers behind internal resolvers and forget to update the directive.

# Reload Nginx after config changes
sudo nginx -t && sudo systemctl reload nginx

# Wait for Nginx to fetch the OCSP response
sleep 60

# Test OCSP stapling - look for 'OCSP Response Status: successful'
openssl s_client -connect example.com:443 \
  -servername example.com \
  -status 2>/dev/null | grep -A 10 'OCSP response'

# Alternative: check with testssl.sh
bash testssl.sh --ocsp example.com

Tune TLS Session Cache and Tickets

Two mechanisms reduce the cost of repeated TLS handshakes: session cache and session tickets. They serve the same purpose but with different security tradeoffs.

Session cache stores session data server-side. With `shared:MozSSL:10m` each Nginx worker process shares the cache. 10MB holds approximately 40,000 sessions. The downside is that sessions do not survive server restarts.

Session tickets encrypt session data and send it to the client, who presents it on reconnect. This survives restarts and works across multiple servers without shared state. The security problem: ticket keys are long-lived and not rotated by default. A compromised ticket key decrypts past sessions. For this reason Mozilla's recommendations and most security auditors now say to disable session tickets (`ssl_session_tickets off`) unless you have proper key rotation in place.

For high-traffic environments where you need session resumption across multiple Nginx instances, the correct solution is a shared Redis-backed session cache using the `ssl_session_cache` directive pointed at a custom store, or coordinated ticket key rotation via a tool like nginx-session-ticket-key-rotation. We use the Redis approach on our load-balanced test servers.

# Check if session resumption is working
openssl s_client -connect example.com:443 -reconnect 2>&1 | grep -E 'Reused|New'
# Should show: Reused, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384

# Confirm tickets are off and cache is active
nginx -T 2>/dev/null | grep -E 'ssl_session'
// advertisement

HTTP/2 and HTTP/3 Configuration

HTTP/2 has been enabled in Nginx via `http2 on` since Nginx 1.25.1, which deprecated the old `listen 443 ssl http2` syntax. If you are running 1.24.x or older, use the legacy syntax. If you upgraded to 1.26.x as shown at the start of this guide, use `http2 on` as a standalone directive.

HTTP/3 (QUIC) requires Nginx compiled with `--with-http_v3_module`, which is included in the mainline builds from the official repository. It also requires a different listen directive. QUIC runs over UDP port 443, so your firewall must allow UDP 443 in addition to TCP 443.

In our testing HTTP/3 reduces first-byte latency by 15-20% on mobile connections with 50ms+ RTT. On local or datacenter connections the improvement is minimal. The `alt-svc` header advertises HTTP/3 availability to supporting clients.

# For Nginx 1.26.x - HTTP/2 syntax
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    # ... rest of config
}

# HTTP/3 addition (requires QUIC-enabled Nginx build)
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;
    http2 on;

    add_header Alt-Svc 'h3=":443"; ma=86400';
    # ... rest of config
}

# Open UDP 443 for QUIC
sudo ufw allow 443/udp

# Verify HTTP/2 is negotiated
curl -I --http2 https://example.com 2>/dev/null | grep HTTP

Automate Certificate Renewal

Certbot installs a systemd timer that runs twice daily. Let's Encrypt certificates expire after 90 days; Certbot renews at 30 days remaining. The timer is enabled automatically on install, but verify it.

The Nginx plugin handles reload automatically after renewal via a deploy hook. Check that the hook exists and is executable. If you customized your Nginx config paths or use a non-standard service name, edit the hook accordingly.

For teams managing certificate renewal across fleets of servers, this is a natural fit for DevOps automation. Tools like those at taskbotshub.ai can coordinate renewal checks, alert on upcoming expirations, and trigger reload sequences across multiple hosts without manual SSH sessions.

Test the renewal process with `--dry-run` before an actual expiry is approaching. The dry run contacts Let's Encrypt staging servers and runs all renewal logic without modifying any files.

# Check systemd timer status
sudo systemctl status certbot.timer
sudo systemctl list-timers | grep certbot

# Verify the Nginx deploy hook exists
ls -la /etc/letsencrypt/renewal-hooks/deploy/

# Test renewal dry-run
sudo certbot renew --dry-run

# Manually trigger renewal for testing
sudo certbot renew --cert-name example.com

# Check certificate expiry date
sudo certbot certificates

Validate Your Configuration with testssl.sh

SSL Labs is the standard for a quick public check, but testssl.sh runs locally, works on internal hosts, and gives more granular output. Version 3.2 is the current stable release.

Run the full suite first, then target specific checks for CI pipelines. The `--severity HIGH` flag exits non-zero if any HIGH or CRITICAL findings appear - useful for blocking deployments with broken TLS configs.

On our test server, a freshly configured Nginx 1.26.2 with the config in this guide scored: no critical findings, no high findings, OCSP stapling confirmed, TLS 1.3 confirmed, certificate chain complete, HSTS header present with preload flag.

# Install testssl.sh
git clone --depth 1 https://github.com/drwetter/testssl.sh.git /opt/testssl
chmod +x /opt/testssl/testssl.sh

# Full test suite
/opt/testssl/testssl.sh example.com

# Targeted checks for CI
/opt/testssl/testssl.sh --protocols --ciphers --headers --severity HIGH example.com

# JSON output for parsing
/opt/testssl/testssl.sh --jsonfile /tmp/tls-report.json example.com

# Check specific cipher vulnerabilities
/opt/testssl/testssl.sh --vulnerable example.com

# Confirm TLS 1.0 and 1.1 are rejected
openssl s_client -connect example.com:443 -tls1 2>&1 | grep -E 'handshake|alert'
# Expected: ssl handshake failure
// advertisement

Handle Multiple Domains and SNI

Server Name Indication lets a single IP serve multiple TLS certificates. Nginx has supported SNI since 0.5.23 and the configuration is straightforward: separate server blocks with separate certificate paths, all listening on the same port.

When you run multiple projects or client sites on a single server, certificate management becomes the main operational burden. Keep certificate paths consistent. We use `/etc/letsencrypt/live/{domain}/` as Certbot creates them, without symlinks or custom paths, to keep renewal hooks predictable.

If you are spinning up a new project and registering a domain for it, getting the domain name right from the start matters more than engineers often admit. A clean, memorable domain name affects SEO, email deliverability, and certificate SAN lists for years. Services like nicename.me help search available domain names across registrars before you commit to a project name.

For wildcard certificates serving `*.example.com`, a single certificate block can handle any subdomain. The SNI matching still happens via `server_name` directives, so individual subdomains can have different configs while sharing one certificate.

# Multiple domains on one server - separate server blocks
# /etc/nginx/conf.d/site-a.conf
server {
    listen 443 ssl;
    server_name site-a.com www.site-a.com;
    ssl_certificate /etc/letsencrypt/live/site-a.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site-a.com/privkey.pem;
    include /etc/nginx/snippets/ssl-params.conf;
    # ... site config
}

# /etc/nginx/conf.d/site-b.conf
server {
    listen 443 ssl;
    server_name site-b.com;
    ssl_certificate /etc/letsencrypt/live/site-b.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site-b.com/privkey.pem;
    include /etc/nginx/snippets/ssl-params.conf;
    # ... site config
}

# Extract shared SSL params to a snippet
# /etc/nginx/snippets/ssl-params.conf
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:...;
# ssl_session_cache shared:MozSSL:10m;
# ssl_stapling on;
# (and so on)

# List all active certificates
sudo certbot certificates

Monitoring and Alerting for Certificate Expiry

Automatic renewal handles the happy path. It breaks when: the domain's DNS stops resolving, port 80 is firewalled, the server is unreachable during the renewal window, or the certbot service fails silently. Production certificate expiry is always preventable and always embarrassing.

Set up expiry monitoring independent of Certbot. The Prometheus blackbox exporter has a `probe_ssl_earliest_cert_expiry` metric that integrates with Grafana alerting. A simpler approach for smaller setups is a cron job using openssl.

For larger fleets where manual expiry checking does not scale, automated monitoring pipelines that check TLS health across hundreds of endpoints are exactly the kind of operational task where AI-driven DevOps tooling such as taskbotshub.ai reduces the manual overhead - scheduling checks, correlating results, and triggering runbooks when certificates cross the 14-day warning threshold.

Set your alert threshold at 14 days, not 7. If renewal fails once you want a second attempt window.

# Simple expiry check script - add to cron
#!/bin/bash
DOMAIN="example.com"
THRESHOLD_DAYS=14
EXPIRY=$(echo | openssl s_client -servername $DOMAIN \
  -connect $DOMAIN:443 2>/dev/null \
  | openssl x509 -noout -enddate \
  | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

if [ $DAYS_LEFT -lt $THRESHOLD_DAYS ]; then
  echo "WARNING: $DOMAIN certificate expires in $DAYS_LEFT days"
  # Add your alerting here: mail, Slack webhook, PagerDuty, etc.
fi

# Add to crontab
# 0 9 * * * /usr/local/bin/check-cert-expiry.sh

# Prometheus blackbox exporter query for Grafana alert
# probe_ssl_earliest_cert_expiry{instance="example.com:443"} - time() < 86400 * 14