Set Up Let's Encrypt SSL/TLS for Nginx on Debian 12 and Ubuntu 24.04

11 min read·Matthieu|

Obtain and auto-renew free TLS certificates with Certbot for Nginx on Debian 12 or Ubuntu 24.04. Covers DNS setup, Certbot installation, HTTP-to-HTTPS redirect, TLS hardening, HTTP/2, HSTS, and the OCSP discontinuation.

This tutorial walks through obtaining a free TLS certificate from Let's Encrypt using Certbot, configuring Nginx for HTTPS, and setting up automatic renewal. Every step includes a verification command so you can confirm what happened before moving on.

If you haven't installed Nginx yet, start with Install Nginx on Debian 12 and Ubuntu 24.04. For the broader picture of managing Nginx on a VPS, see Nginx Administration on a VPS.

What do you need before requesting a certificate?

Before Certbot can issue a certificate, your domain must point to your server's IP address, and Nginx must be running with a server block for that domain. Let's Encrypt validates domain ownership by sending an HTTP request to your server. If DNS doesn't resolve to your VPS or Nginx isn't listening, the challenge fails.

You need:

  • A VPS running Debian 12 or Ubuntu 24.04 with Nginx installed from the official repository (Install Nginx on Debian 12 and Ubuntu 24.04)
  • A registered domain name (we'll use example.com throughout)
  • An A record pointing example.com to your server's IPv4 address
  • An AAAA record pointing to your IPv6 address (if your server has one)
  • Port 80 open in your firewall (Certbot uses HTTP-01 challenges)
  • A working Nginx server block for your domain (Nginx Server Blocks)

Set up DNS records

Create an A record at your DNS provider:

Type Name Value TTL
A example.com 203.0.113.10 300
AAAA example.com 2001:db8::1 300

Replace the IP addresses with your server's actual addresses. Set TTL low (300 seconds) during setup so changes propagate fast. You can raise it later.

Verify DNS resolution

Wait a few minutes after creating records, then verify from your local machine (not the server):

dig +short example.com A
dig +short example.com AAAA

You should see your server's IP addresses in the output. If you see nothing or a different IP, the record hasn't propagated yet. Wait and try again.

Verify Nginx responds on port 80 from your local machine:

curl -I http://example.com

You should get an HTTP/1.1 200 OK response with Server: nginx. If the connection times out, check your firewall rules.

How do you install Certbot on Debian 12 and Ubuntu 24.04?

Install Certbot and its Nginx plugin from your distribution's package repository using apt. The Nginx plugin lets Certbot automatically modify your server blocks to enable TLS.

sudo apt update
sudo apt install certbot python3-certbot-nginx -y

Verify the installation:

certbot --version

On Debian 12, this installs Certbot 2.1.0. On Ubuntu 24.04, you get Certbot 2.9.0. Both versions work for everything in this tutorial.

Sharp eyes: if you installed Nginx from the official nginx.org repository (as recommended in Install Nginx on Debian 12 and Ubuntu 24.04), the Certbot Nginx plugin will work without any extra configuration. It detects server blocks in /etc/nginx/conf.d/ and /etc/nginx/sites-enabled/.

How do you obtain a Let's Encrypt certificate for Nginx?

Run certbot --nginx with your domain name. Certbot contacts Let's Encrypt, proves you control the domain via an HTTP-01 challenge, obtains the certificate, and edits your Nginx server block to use it. The whole process takes about 30 seconds.

sudo certbot --nginx -d example.com -d www.example.com

Certbot will ask for your email address (for renewal reminders) and agreement to the Terms of Service. It then:

  1. Places an HTTP-01 challenge file in your web root
  2. Asks Let's Encrypt to verify it
  3. Downloads the signed certificate
  4. Modifies your Nginx server block to add TLS directives
  5. Reloads Nginx

Verify the certificate was issued:

sudo ls -la /etc/letsencrypt/live/example.com/

You should see:

lrwxrwxrwx 1 root root  ... cert.pem -> ../../archive/example.com/cert1.pem
lrwxrwxrwx 1 root root  ... chain.pem -> ../../archive/example.com/chain1.pem
lrwxrwxrwx 1 root root  ... fullchain.pem -> ../../archive/example.com/fullchain1.pem
lrwxrwxrwx 1 root root  ... privkey.pem -> ../../archive/example.com/privkey1.pem

These are symlinks. fullchain.pem is your certificate plus the intermediate CA chain. privkey.pem is your private key.

Check that Nginx is running with the new config:

sudo nginx -t && sudo systemctl status nginx

nginx -t tests the configuration syntax. If it prints test is successful, the config is valid.

What does Certbot change in your Nginx configuration?

Certbot adds several lines to your server block. Here's what it inserts (the lines marked # managed by Certbot):

server {
    server_name example.com www.example.com;

    listen 443 ssl;
    listen [::]:443 ssl;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

    # ... your existing location blocks ...
}

The options-ssl-nginx.conf file contains Certbot's default TLS settings. We'll replace these with stronger settings in the hardening section below.

Certbot also creates a second server block to redirect HTTP to HTTPS. We'll improve that redirect in the next section.

You can see exactly what changed by comparing your config:

sudo diff /etc/nginx/conf.d/example.com.conf /etc/nginx/conf.d/example.com.conf.bak 2>/dev/null || echo "No backup found. Certbot modifies in place."

How do you redirect HTTP to HTTPS in Nginx?

All HTTP traffic should redirect to HTTPS with a 301 (permanent) redirect. Certbot may add this automatically, but its default uses an if statement inside the existing server block. That's an anti-pattern in Nginx. A dedicated server block is cleaner and more reliable.

Replace Certbot's redirect with a separate server block. Edit your config file (the path depends on your setup; typically /etc/nginx/conf.d/example.com.conf):

# HTTP -> HTTPS redirect (separate server block, not an if-statement)
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

This goes in the same file as your HTTPS server block, or in a separate file. Make sure you remove any Certbot-generated redirect block to avoid duplicates.

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Verify the redirect from your local machine:

curl -I http://example.com

Expected output:

HTTP/1.1 301 Moved Permanently
Location: https://example.com/

How do you harden TLS settings for a production server?

Certbot's default TLS config (options-ssl-nginx.conf) is intentionally conservative. For a production server, you want tighter settings. We'll follow Mozilla's Intermediate profile from the SSL Configuration Generator, which balances security with client compatibility back to Firefox 27, Chrome 31, and Android 4.4.2.

Create a snippet file that you can include from each server block:

sudo nano /etc/nginx/snippets/tls-params.conf

Add the following:

# TLS protocol versions — TLS 1.2 and 1.3 only
# TLS 1.0 and 1.1 are deprecated (RFC 8996)
ssl_protocols TLSv1.2 TLSv1.3;

# Ciphers — Mozilla Intermediate profile (January 2026)
# Source: https://ssl-config.mozilla.org/
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:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;

# DH parameters — 2048-bit, RFC 7919 ffdhe2048
ssl_dhparam /etc/nginx/dhparam.pem;

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

# HSTS — tell browsers to always use HTTPS (2 years)
# Only add includeSubDomains if ALL subdomains use HTTPS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

# Hide Nginx version in error pages and headers
server_tokens off;

Generate the DH parameters file (this takes a few seconds):

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

Verify the file was created:

sudo ls -la /etc/nginx/dhparam.pem

Now update your HTTPS server block to use these settings instead of Certbot's defaults. Remove the include /etc/letsencrypt/options-ssl-nginx.conf; line and the ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; line. Replace them with:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    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 hardening (replaces Certbot defaults)
    include snippets/tls-params.conf;

    # ... your location blocks ...
}

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Which TLS versions and ciphers should you use?

Mozilla publishes three TLS profiles. Here's the comparison:

Profile Protocols Oldest compatible client Use case
Modern TLS 1.3 only Firefox 63, Chrome 70, Android 10 Services where all clients are recent
Intermediate TLS 1.2 + 1.3 Firefox 27, Chrome 31, Android 4.4 General-purpose web servers
Old TLS 1.0 + 1.1 + 1.2 + 1.3 Firefox 1, Chrome 1, IE 8 Legacy systems only

Use Intermediate unless you have a specific reason not to. It covers 99.9%+ of current browsers while excluding weak protocols. TLS 1.0 and 1.1 were formally deprecated by RFC 8996 in March 2021.

The cipher list in our snippet uses only AEAD ciphers (GCM and ChaCha20-Poly1305). ssl_prefer_server_ciphers off lets the client pick its preferred cipher. This is the Mozilla recommendation because modern clients make better choices than a static server preference.

Does Let's Encrypt still support OCSP stapling?

No. Let's Encrypt shut down its OCSP service on August 6, 2025. Certificates issued after May 2025 contain no OCSP responder URL. Revocation status is now published exclusively through Certificate Revocation Lists (CRLs). If you only use Let's Encrypt certificates, remove any ssl_stapling directives from your Nginx config.

Here's the timeline:

  1. December 2024. Let's Encrypt announced the plan to end OCSP.
  2. January 30, 2025. Certificates requesting the OCSP Must-Staple extension started failing.
  3. May 7, 2025. New certificates stopped including OCSP responder URLs. CRL URLs added instead.
  4. August 6, 2025. OCSP service shut down completely. All previously issued certificates with OCSP URLs have now expired.

If you see ssl_stapling on; or ssl_stapling_verify on; in any guide or config snippet, that's outdated advice for Let's Encrypt users. These directives are harmless (Nginx silently ignores them when there's no OCSP responder URL), but they add unnecessary clutter. Remove them.

If you use certificates from a different CA that still provides OCSP, those directives remain valid for those certificates.

Why did Let's Encrypt drop OCSP? Two reasons. OCSP is a privacy risk: every time a browser checked certificate revocation via OCSP, the CA learned which site was visited from which IP. At peak, Let's Encrypt's OCSP service handled 340 billion requests per month. Switching to CRLs eliminates that privacy leak. CRLs are downloaded in bulk by browsers, so no per-visit request goes to the CA.

How do you enable HTTP/2 with Nginx?

HTTP/2 multiplexes requests over a single connection, reducing latency. If you installed Nginx from the official nginx.org repository (version 1.25.1 or later), enable HTTP/2 with the http2 directive inside your server block.

Add http2 on; inside your HTTPS server block:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    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;
    include snippets/tls-params.conf;

    # ... your location blocks ...
}

The http2 on; directive replaced the deprecated listen 443 ssl http2; syntax in Nginx 1.25.1. The old syntax still works but produces deprecation warnings in the error log. If you're running the distro-packaged Nginx (1.22 on Debian 12, 1.24 on Ubuntu 24.04), use the old listen 443 ssl http2; syntax instead.

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Verify HTTP/2 is active:

curl -I --http2 -s https://example.com | head -1

Expected output:

HTTP/2 200

If you see HTTP/1.1 instead, double-check that http2 on; is inside the correct server block and that your Nginx version supports it.

How does automatic certificate renewal work?

Let's Encrypt certificates expire after 90 days. Certbot installs a systemd timer (certbot.timer) that checks twice daily whether any certificate is within 30 days of expiration. If so, it renews automatically. You don't need to set up a cron job.

Check that the timer is active:

systemctl status certbot.timer

You should see Active: active (waiting) and a line showing the next trigger time.

Check when the next renewal will happen:

systemctl list-timers certbot.timer

This shows the NEXT and LAST run times.

Test renewal without actually renewing

Run a dry-run to verify the renewal process works end-to-end:

sudo certbot renew --dry-run

This contacts the Let's Encrypt staging server and simulates a full renewal. If it prints Congratulations, all simulated renewals succeeded, your setup is correct.

Set up a deploy hook to reload Nginx

When Certbot renews a certificate, Nginx needs to reload to pick up the new files. Configure a deploy hook that runs only on successful renewal:

sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Add:

#!/bin/bash
/usr/bin/systemctl reload nginx

Make it executable:

sudo chmod 700 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Verify the permissions:

ls -la /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Expected output shows -rwx------ (only root can read and execute).

The deploy hook directory runs scripts only after a successful renewal, not on every timer tick. This avoids unnecessary reloads.

How do you verify your TLS setup is correct?

After completing all the steps above, run these verification commands. Each one checks a different aspect of your setup.

Check the certificate chain with OpenSSL

From your local machine:

openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null | openssl x509 -noout -dates -subject -issuer

Expected output:

notBefore=Mar 19 00:00:00 2026 GMT
notAfter=Jun 17 00:00:00 2026 GMT
subject=CN = example.com
issuer=C = US, O = Let's Encrypt, CN = R12

Sharp eyes: the notAfter date should be roughly 90 days from issuance. The issuer should be Let's Encrypt. Current RSA intermediates are R12 and R13. For ECDSA certificates, look for E7 or E8.

Check the TLS version and cipher in use

openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null | grep -E "Protocol|Cipher"

Expected:

    Protocol  : TLSv1.3
    Cipher    : TLS_AES_256_GCM_SHA384

If you see TLSv1.2, that's also fine. It depends on which version your local OpenSSL prefers.

Check HTTPS headers

curl -I https://example.com

Look for:

HTTP/2 200
strict-transport-security: max-age=63072000; includeSubDomains

If strict-transport-security is missing, verify the add_header line in your TLS snippet. The always parameter ensures the header is sent even on error responses.

Verification commands summary

Command What it checks What to look for
dig +short example.com DNS resolution Your server's IP
curl -I http://example.com HTTP redirect 301 with Location: https://
curl -I https://example.com HTTPS + headers HTTP/2 200, HSTS header
openssl s_client -connect ... Certificate chain, TLS version Let's Encrypt issuer, TLSv1.2 or 1.3
certbot renew --dry-run Renewal process all simulated renewals succeeded
systemctl status certbot.timer Auto-renewal timer active (waiting)

Test with SSL Labs

For a thorough external audit, submit your domain to SSL Labs Server Test. With the configuration in this guide, you should score an A or A+. The A+ requires HSTS, which we enabled in the TLS snippet.

Complete Nginx configuration reference

Here's the full server block with all the settings from this tutorial combined:

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

# HTTPS server block
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    server_name example.com www.example.com;

    # Let's Encrypt certificate
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # TLS hardening
    include snippets/tls-params.conf;

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

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

    # Deny access to hidden files
    location ~ /\. {
        deny all;
    }
}

And the TLS snippet at /etc/nginx/snippets/tls-params.conf:

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:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_dhparam /etc/nginx/dhparam.pem;

ssl_session_timeout 1d;
ssl_session_cache shared:TLS:10m;
ssl_session_tickets off;

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

For additional security headers like Content-Security-Policy, X-Frame-Options, and Permissions-Policy, see Nginx Security Hardening.

Something went wrong?

Certbot says "Could not automatically find a matching server block" Certbot looks for a server_name directive matching your -d domain. Make sure your server block file is in /etc/nginx/conf.d/ or /etc/nginx/sites-enabled/ and contains server_name example.com;.

Certbot says "Connection refused" or "Challenge failed" Port 80 must be open. Check your firewall:

sudo ufw status          # if using UFW
sudo nft list ruleset    # if using nftables

Also verify Nginx is listening on port 80:

sudo ss -tlnp | grep ':80'

"SSL: error" in Nginx error log after editing TLS settings You likely have a syntax error in the cipher string or a missing file path. Check:

sudo nginx -t
sudo journalctl -u nginx -n 20 --no-pager

Browser shows "NET::ERR_CERT_DATE_INVALID" The certificate may have expired. Check the expiry:

sudo certbot certificates

If it's expired, force a renewal:

sudo certbot renew --force-renewal

certbot renew --dry-run fails Common cause: the domain's DNS no longer points to this server, or port 80 is blocked. Certbot needs HTTP-01 access for renewal.

HTTP/2 not working Check your Nginx version: nginx -v. If it's below 1.25.1, use listen 443 ssl http2; instead of the separate http2 on; directive. If it's 1.25.1+, make sure http2 on; is inside the correct server block (not http or location).


Copyright 2026 Virtua.Cloud. All rights reserved. This content is original work by the Virtua.Cloud team. Reproduction, republication, or redistribution without written permission is prohibited.

Ready to try it yourself?

Deploy your own server in seconds. Linux, Windows, or FreeBSD.

See VPS Plans