Certbot on Ubuntu 24.04: Let's Encrypt + nginx
Install Certbot on Ubuntu 24.04 with apt or snap, get a Let's Encrypt cert for nginx, and fix the port 80 failures that break HTTP-01 renewal on a live box.
Install Certbot: apt or snap
On Ubuntu 24.04, sudo apt install certbot python3-certbot-nginx gives you a working Certbot that issues real, publicly trusted Let's Encrypt certificates. Certbot's upstream docs point you at the snap instead; the difference is narrow — the snap tracks upstream releases, the archive package tracks whatever shipped with the LTS and gets security fixes.
Pick one. Two copies of Certbot means two renewal timers aimed at the same /etc/letsencrypt tree, and the one you forgot about is the one that surprises you.
The apt path:
sudo apt update
sudo apt install certbot python3-certbot-nginx
That installs /usr/bin/certbot, the nginx plugin, a certbot.service + certbot.timer pair, and an /etc/cron.d/certbot entry that no-ops under systemd.
The snap path:
sudo apt remove certbot python3-certbot-nginx
sudo snap install core && sudo snap refresh core
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
The snap ships its own timer, snap.certbot.renew.timer. Remove the apt package before installing the snap.
Both installs behave the same afterwards. Certbot 2.x defaults to ECDSA (P-256) keys — pass --key-type rsa only for a client that cannot do ECDSA. All state lives under /etc/letsencrypt: archive/ holds the real key and certificate files, live/ symlinks to the current ones, renewal/ one config file per certificate, accounts/ your ACME account key.
What HTTP-01 actually does, and why port 80 is not optional
The HTTP-01 challenge is a callback. You ask Let's Encrypt for a certificate covering example.com; it resolves the name in public DNS, opens a connection to port 80 at the address it finds, and requests http://example.com/.well-known/acme-challenge/<token>. Your server answers with the exact token content Certbot just wrote to disk. That is the whole mechanism. Three consequences fall out of it, and they account for most failed issuances.
- Port 80 must be reachable from the public internet, not merely from your laptop. A
ufwrule, a cloud-provider security group, or a VPS-console firewall that opens only 443 breaks issuance and every future renewal with it. - DNS must already point at this box. The validation server does its own lookup from the outside; your
/etc/hostsentries and browser cache mean nothing to it. - If you publish an AAAA record, IPv6 is tried first. Let's Encrypt retries over IPv4 when the IPv6 connection fails outright — but a stale AAAA aimed at a host that accepts the connection and serves something else gives you a hard failure.
Redirects are allowed: validation follows an HTTP redirect to HTTPS and does not care that the certificate on the other end is missing, expired or self-signed. What it will not do is start anywhere other than port 80. Certbot has no TLS-ALPN-01 implementation, so "just use 443" is not an escape hatch.
Choosing an authenticator: --nginx, --webroot, --standalone
--nginx is the right default when nginx already runs and already serves the domain. Certbot parses your config, injects a temporary challenge location, reloads nginx, validates, then writes the TLS directives into your server block. No downtime.
sudo certbot --nginx -d example.com -d www.example.com
Scripted, for a fresh box:
sudo certbot --nginx \
-d example.com -d www.example.com \
--agree-tos -m [email protected] --no-eff-email \
--redirect --non-interactive
--webroot is right when you want Certbot nowhere near your nginx config — one you generate from a template, keep in git, or push with Ansible. Certbot writes only the challenge file, into a directory you already serve.
sudo certbot certonly --webroot -w /var/www/example.com \
-d example.com -d www.example.com \
--deploy-hook "systemctl reload nginx"
--standalone is right when nothing listens on port 80: a mail server, an API that only speaks 443, a first-boot script that runs before nginx exists. Certbot binds port 80 itself for a few seconds. If nginx is running, this fails — stop it around the run:
sudo certbot certonly --standalone -d mail.example.com \
--pre-hook "systemctl stop nginx" \
--post-hook "systemctl start nginx"
Those hooks are recorded in the certificate's renewal config, so the same stop/start happens unattended at renewal.
A server block that works before and after the certificate exists
The chicken-and-egg problem: nginx refuses to start with ssl_certificate pointing at a file that does not exist, and Certbot cannot validate while nginx is down. Bring the site up on port 80 first.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com;
index index.html;
location ^~ /.well-known/acme-challenge/ {
root /var/www/example.com;
default_type "text/plain";
try_files $uri =404;
}
location / {
try_files $uri $uri/ =404;
}
}
Run sudo nginx -t && sudo systemctl reload nginx, confirm curl -I http://example.com/ answers from outside the box, then issue. Afterwards:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/example.com;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
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;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/www/example.com;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
The ^~ prefix on the ACME location earns its keep: it stops the return 301 block from swallowing the challenge request. Keeping that location on port 80 means renewals keep working after the rest of the site goes HTTPS-only.
HTTP/2 syntax depends on your nginx version, and mixing the two forms up produces a startup error. Ubuntu 24.04 ships nginx 1.24, which wants it inline — listen 443 ssl http2;. Debian 13 ships a newer nginx, which wants the separate http2 on; directive. Check nginx -v first.
Point nginx at live/, never at archive/. The live/ symlinks get repointed on every renewal; a hard path into archive/ pins you to a certificate that expires under you.
Wildcards mean DNS-01, and DNS-01 means a plugin
A wildcard certificate (*.example.com) cannot be validated over HTTP-01 — there is no single hostname to fetch a file from. DNS-01 is the only route: you prove control by publishing a _acme-challenge.example.com TXT record. Certbot needs API credentials for your DNS provider to do that unattended, which is what the provider plugins exist for.
sudo snap set certbot trust-plugin-with-root=ok
sudo snap install certbot-dns-cloudflare
On the apt path that is sudo apt install python3-certbot-dns-cloudflare instead. Credentials go in a root-only file:
# /root/.secrets/cloudflare.ini
# then: sudo chmod 600 /root/.secrets/cloudflare.ini
dns_cloudflare_api_token = your_scoped_token_here
Scope the token to DNS-edit rights on that one zone. It is a key to your DNS; treat it like one.
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d example.com -d '*.example.com'
Quote the wildcard so your shell does not glob it. DNS-01 also solves what HTTP-01 cannot: certificates for hosts with no public port 80 — an internal service, a box reachable only over a self-hosted WireGuard VPN on a VPS, an admin panel on a private interface.
Renewal: the 90 days, the timer, the deploy hook
Let's Encrypt certificates are valid for 90 days. Certbot renews when fewer than 30 days remain, which hands you a 30-day window in which a broken renewal is a fixable annoyance rather than an outage. Let's Encrypt no longer sends expiry-warning emails — nobody is going to tap you on the shoulder, so monitoring is yours now.
Check the timer your install shipped:
systemctl list-timers 'certbot*' 'snap.certbot*'
sudo certbot certificates
certbot renew walks every config in /etc/letsencrypt/renewal/, skips anything outside the 30-day window, and renews the rest using exactly the flags of the original run. That is why the first run matters: it is the one that gets recorded.
Renewing the file on disk changes nothing on its own — nginx keeps serving the old certificate out of memory until something tells it to reload. Wire a deploy hook once:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'EOF'
#!/bin/sh
set -e
nginx -t && systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
Anything executable in renewal-hooks/deploy/ runs after any successful renewal. The --deploy-hook flag does the same job for one certificate, storing renew_hook = ... in its renewal config. certbot --nginx reloads for you; --webroot and --standalone setups do not. A missing hook is exactly why a site serves an expired certificate while certbot certificates cheerfully reports a fresh one. Anything else that reads the certificate at startup wants the same hook — a containerised app such as a Nextcloud VPS install with Docker, TLS and backups needs its own restart or reload step wired in here too.
Testing renewal for real
sudo certbot renew --dry-run
That runs the full challenge against Let's Encrypt's staging environment: same code path, same firewall, same DNS, no rate-limit cost, nothing written to disk. If it passes today, the unattended renewal in 60 days passes too, assuming nothing about the box changes underneath it.
A dry run does not prove your reload hook fires — behaviour there varies by Certbot version. Test that half by hand: execute the hook script directly, confirm systemctl reload nginx succeeds, and check sudo grep renew_hook /etc/letsencrypt/renewal/example.com.conf.
The errors you will actually hit
Could not bind to IPv4 or IPv6. — --standalone while nginx already holds port 80. Use --nginx or --webroot, or stop nginx around the run. Confirm the holder with sudo ss -lntp | grep ':80'.
Timeout during connect (likely firewall problem) — Let's Encrypt could not reach port 80. Walk outward: sudo ufw status (open it with sudo ufw allow 'Nginx Full'), then the VPS provider's own firewall, then DNS. Test from somewhere that is not your server: curl -sSv http://example.com/.well-known/acme-challenge/test. A stale AAAA record produces this same message.
unauthorized :: Invalid response from http://example.com/.well-known/acme-challenge/xyz: 404 — port 80 is reachable, but the token is not being served. The request landed in a different server block (check which one owns default_server), or the directory passed to -w is not the one nginx serves. Drop a file at /var/www/example.com/.well-known/acme-challenge/test and fetch it externally; if that 404s, the certificate was never the problem.
DNS problem: NXDOMAIN looking up A for example.com — the name does not resolve publicly. New records that have not propagated, or a record in a zone your registrar is not serving.
too many certificates already issued for: example.com — a rate limit, and the one people hit while debugging in a loop. Let's Encrypt caps duplicate certificates per registered domain per week, and nothing unblocks it except time. Debug against staging with --dry-run.
nginx: [emerg] cannot load certificate "/etc/letsencrypt/live/example.com/fullchain.pem": No such file or directory — nginx is configured for a certificate never issued, or one removed with certbot delete. Comment out the TLS server block, start nginx, issue, restore the block.
open() "/etc/letsencrypt/options-ssl-nginx.conf" failed — that file arrives with the nginx plugin package. On a certonly box without python3-certbot-nginx, either add the plugin or replace the include line with your own ssl_protocols and ssl_ciphers settings.
Owning this at scale
One certificate can carry up to 100 names, and a single certbot --nginx -d a.example.com -d b.example.com ... is tempting — until one stale DNS record fails validation and takes every other name on that certificate down with it. Separate certificates per site fail independently, which is what you want on a box hosting more than a couple of things. Past a handful of sites, an ACME-aware front door earns its keep: a Traefik reverse proxy running multiple apps under Docker Compose requests and renews the certificates itself, and Certbot drops out of the picture entirely.
Back up /etc/letsencrypt whole — sudo tar -czf letsencrypt-$(date +%F).tar.gz -C /etc letsencrypt — symlinks intact. That tree holds accounts/, your ACME account key, which you cannot regenerate identically. Moving to a new VPS then reduces to: rsync the tree with -a, install Certbot, repoint DNS, and run certbot renew --dry-run before you cut over.
Rebuild the box or hop to a new LTS and the renewal timer does not follow you. After any migration, snapshot restore, or distro upgrade, run systemctl list-timers 'certbot*' and one --dry-run. Skipping that is how a site goes dark 89 days later, at 3am, on a certificate everyone assumed was renewing itself.
All of this assumes a machine you control, with a public IP and port 80 open to the world — a VPS, in other words. The mechanics above are identical on any of them.
FAQ
Do I need port 80 open if my site only serves HTTPS?
Yes, for the HTTP-01 challenge. Let's Encrypt always starts its validation request on port 80, and Certbot has no TLS-ALPN-01 implementation, so a firewall that opens only 443 blocks both the first issuance and every unattended renewal after it. A redirect from port 80 to HTTPS is fine — validation follows it. The only way to skip port 80 entirely is DNS-01 with a provider plugin.
apt or snap — which Certbot should I install on Ubuntu 24.04?
Either works. sudo apt install certbot python3-certbot-nginx gives you a fully functional Certbot backed by LTS security updates; the snap tracks upstream releases more closely and is what Certbot's own docs recommend. What matters is picking exactly one: two installs mean two renewal timers pointed at the same /etc/letsencrypt tree, and the forgotten one is the one that bites you.
Can Certbot issue a wildcard certificate for nginx?
Only over DNS-01. A wildcard like *.example.com has no single hostname to fetch a challenge file from, so --nginx, --webroot and --standalone are all out. Install the plugin for your DNS provider, put a scoped API token in a root-only credentials file, and run certbot certonly --dns-cloudflare -d example.com -d '*.example.com', quoting the wildcard to stop the shell globbing it.
Why does nginx still serve the old certificate after a successful renewal?
nginx keeps the certificate in memory and does not notice the new file on disk until it reloads. certbot --nginx reloads for you, but --webroot and --standalone runs do not, so a renewal can succeed while the browser still sees an expiring certificate. Drop an executable script into /etc/letsencrypt/renewal-hooks/deploy/ that runs nginx -t && systemctl reload nginx, and it fires after every successful renewal.
Does certbot renew --dry-run prove renewal will work?
Mostly. It runs the real challenge against the staging environment — same firewall, same DNS, same code path — with no rate-limit cost and nothing written to disk, so a pass means the network half is sound. It does not reliably prove your deploy hook fires, though. Test that separately: run the hook script by hand and check sudo grep renew_hook /etc/letsencrypt/renewal/example.com.conf.