Let's Encrypt certificates for Nginx on Ubuntu 26.04 and 24.04
Install certbot, issue a certificate for Nginx, confirm the renewal timer, and avoid the two-certbots trap. For Ubuntu 26.04 and 24.04.
Jacob Søndergaard ·
A free Let’s Encrypt certificate for Nginx on Ubuntu 26.04 takes five commands: install certbot from apt, point DNS at the server, write a server block, run certbot --nginx, and prove renewal works with certbot renew --dry-run. The same commands work on 24.04. The traps are around them: two certbots on one machine, a stale AAAA record, a config file that points into archive/, and a renewal nobody checked.
Everything below was run on fresh Ubuntu 26.04 and 24.04 servers. Issuance and renewal ran against Pebble, Let’s Encrypt’s test ACME server, which speaks the same protocol to the same certbot. Against Let’s Encrypt the commands are the same; the domain has to resolve to the server.
Install certbot on Ubuntu 26.04 or 24.04: apt or snap, never both
sudo apt update
sudo apt install certbot python3-certbot-nginx
certbot --versionThat is certbot 4.0.0 on 26.04 and 2.9.0 on 24.04, maintained by Ubuntu and patched by the same unattended security upgrades as everything else on the box. Both versions do everything in this post. The package also installs and enables certbot.timer, so renewal is scheduled from the moment it is installed.
Certbot’s own instructions use the snap instead, which follows upstream releases and is newer. Either is fine. Both is not. On a server that already has the apt package, the snap instructions’ ln -s /snap/bin/certbot /usr/bin/certbot fails with Already exists, certbot on your PATH stays the apt one, and two timers now renew the same certificates in /etc/letsencrypt. Check what you have:
which -a certbot
systemctl list-timers --all | grep -i certbotOne certbot.timer line means apt; snap.certbot.renew.timer means snap. If you see both, remove one. To move to the snap, run sudo apt remove certbot python3-certbot-nginx first; your certificates stay in /etc/letsencrypt.
DNS before the challenge
Let’s Encrypt checks that you control the domain by fetching a file from it over plain HTTP on port 80. The domain has to resolve to this server first, from the outside:
dig +short example.com A
dig +short example.com AAAA
dig +short www.example.com AThe A records should print the server’s address. The AAAA line matters as much: when a record exists, Let’s Encrypt prefers IPv6, so an AAAA left over from a previous host sends the challenge to the wrong machine and it fails while IPv4 is perfect. Delete it, or make it point at this server’s IPv6 address. Port 80 must be open too, in UFW and in any firewall at your provider, even if the site will only ever serve HTTPS. The hardening post opens 80 and 443 alongside SSH.
A server block for the domain
certbot --nginx finds the server block whose server_name matches each domain you ask for and edits it, so the block has to exist first:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html index.php;
}sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxFor a Laravel or PHP application the block is longer; the Laravel Nginx config goes through one line by line. What certbot needs from it is the server_name and the listen 80.
Issue the certificate with certbot --nginx
Do a dry run first. It runs the real challenge against Let’s Encrypt’s staging service and saves nothing, so a DNS or firewall mistake costs nothing against the production rate limits, which count failed validations per hostname:
sudo certbot certonly --nginx --dry-run -d example.com -d www.example.comIt ends with The dry run was successful. Then issue the real certificate and install it into the server block:
sudo certbot --nginx -d example.com -d www.example.com --redirectThe first run asks for an email address and for agreement to the terms; add -m you@example.com --agree-tos to answer both up front. --redirect adds the HTTP to HTTPS redirect. Certbot then rewrites the file it found:
server {
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html index.php;
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
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
}
server {
if ($host = www.example.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = example.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 404; # managed by Certbot
}The original block now listens on 443 with the certificate, and a new block on port 80 redirects both names, and answers 404 for any other name that reaches it. The # managed by Certbot comments mark the lines certbot wrote. A second site on the same server gets the same treatment, and certbot leaves ipv6only=on off the second block, which Nginx would otherwise refuse as a duplicate.
The renewal timer
Let’s Encrypt certificates last 90 days and stopped coming with expiry reminder emails in June 2025. The timer is the only thing renewing them. The apt package ships it enabled:
systemctl list-timers certbot.timer$ systemctl cat certbot.timer certbot.service
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renewIt fires twice a day at a random point within twelve hours of midnight and noon, and certbot renew renews each certificate once it is within 30 days of expiry, skipping the rest. Persistent=true catches up on a run missed while the server was off.
If you call certbot renew from your own cron job or script, keep --no-random-sleep-on-renew. Without a terminal, certbot waits a random delay of up to about eight minutes before starting, which looks exactly like a hang.
Test renewal with certbot renew --dry-run
sudo certbot renew --dry-runThis simulates renewing every certificate against the staging service with the same authenticator and settings the real renewal will use, from the same /etc/letsencrypt/renewal/ files. It ends with Congratulations, all simulated renewals succeeded. On a server with no certificates yet it prints No simulated renewals were attempted. Run it after issuing, and again after any change to DNS, the firewall or the server block.
Reload hooks
With the nginx installer, certbot reloads Nginx itself after a renewal. A deploy hook is for everything else that holds a copy of the certificate: a mail server, a Node process terminating TLS, or a certificate issued with certonly. Executable files in /etc/letsencrypt/renewal-hooks/deploy/ run after every successful renewal, with $RENEWED_LINEAGE and $RENEWED_DOMAINS set:
#!/bin/sh
nginx -t -q && systemctl reload nginxsudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx
sudo certbot renew --dry-run --run-deploy-hooksA plain --dry-run skips deploy hooks, so it cannot tell you a hook is broken. --run-deploy-hooks runs them during the simulation; it exists in both the 24.04 and 26.04 versions. The nginx -t in the hook keeps a broken configuration from being loaded by a renewal at four in the morning.
Point at live/, never archive/
$ ls -l /etc/letsencrypt/live/example.com/
cert.pem -> ../../archive/example.com/cert2.pem
chain.pem -> ../../archive/example.com/chain2.pem
fullchain.pem -> ../../archive/example.com/fullchain2.pem
privkey.pem -> ../../archive/example.com/privkey2.pemarchive/ keeps every version of the certificate, numbered. live/ holds symlinks to the current one, and each renewal writes the next number and moves the links. A configuration that names archive/example.com/fullchain1.pem keeps serving the first certificate until it expires, while certbot reports every renewal as a success.
Use fullchain.pem, not cert.pem. cert.pem lacks the intermediate certificate: browsers often cope, while curl, mobile apps and other servers fail with an unknown issuer. The private keys are readable by root only. If a service running as another user needs one, copy it in a deploy hook with install -m 640 -g thatgroup rather than loosening the permissions on /etc/letsencrypt.
HTTP-01 or DNS-01, and wildcard certificates
Everything above uses HTTP-01, the default: Let’s Encrypt fetches a file over port 80. DNS-01 instead asks you to publish a TXT record, which works before the domain points at the server and is the only way to get a wildcard such as *.example.com. Certbot needs a plugin for your DNS provider to publish the record unattended. For Cloudflare:
sudo apt install python3-certbot-dns-cloudflare
sudo install -d -m 700 /root/.secrets
sudo nano /root/.secrets/cloudflare.ini
sudo chmod 600 /root/.secrets/cloudflare.inidns_cloudflare_api_token = your-zone-scoped-tokensudo certbot -a dns-cloudflare -i nginx \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d example.com -d '*.example.com'-a picks the plugin that proves control and -i nginx still installs the result into the server block. Create the token from Cloudflare’s “Edit zone DNS” template, limited to the one zone. A wildcard covers one level of subdomain and not the bare domain, which is why both names are listed. Renewal reuses the plugin and the credentials file, so the timer and the dry run work as before. Other DNS providers have their own python3-certbot-dns-* package.
The plugin installs and loads on both releases. Issuing through it needs a real Cloudflare zone, which the test servers did not have, so that last command follows the plugin’s documentation rather than a test run.
HSTS and forcing HTTPS
--redirect sends every HTTP request to HTTPS. HSTS goes a step further and tells browsers never to try HTTP for the domain again. Add it inside the 443 server block, starting with a five-minute lifetime:
add_header Strict-Transport-Security "max-age=300" always;Once the site has worked over HTTPS for a week, raise it to max-age=31536000. A browser that has seen the header refuses plain HTTP for that long, so a long value on a broken setup locks visitors out until it expires. Add includeSubDomains only when every subdomain has a certificate. An add_header inside a location block replaces the server-level ones for that location, so repeat the HSTS line in any location that sets its own headers.
Ubuntu 26.04 and 24.04 differences
The certbot commands are identical. Nginx is not: 26.04 ships Nginx 1.28 and 24.04 ships 1.24, and HTTP/2 is switched on differently. On 26.04, use a separate http2 on; line in the 443 block; the older listen 443 ssl http2; still works but prints a deprecation warning from nginx -t. On 24.04, http2 on; fails with unknown directive "http2", and listen 443 ssl http2; is the form to use. A configuration copied between the two releases needs that line changed.
How Provisore handles certificates
Provisore installs certbot from apt when it provisions a server and issues a certificate per site, over HTTP-01 by default or DNS-01 through a connected Cloudflare token, including wildcards. Renewal runs on the server under the same lock Provisore uses for its own Nginx changes and reloads Nginx afterwards. Provisore also reads each certificate’s real expiry date and notifies you if a renewal has not happened in time. See domains and certificates and Nginx settings, where forced HTTPS is a switch per site.