The symptom on a site I run was a certificate that expired on a Monday, and for the following eight days every visitor on Chrome saw the full-page NET::ERR_CERT_DATE_INVALID warning before anyone told me. Certbot begins trying to renew 30 days before expiry, so every attempt across that month had failed while I assumed auto-renew, which had been switched on for months, was doing its job. Nothing had emailed me, partly because Let’s Encrypt stopped sending expiry reminder emails in June 2025 and partly because the failures only went to /var/log/letsencrypt/. The fault was in the nginx config, not in certbot, and the same config pattern is common enough that yours deserves checking today.
Step 1. Confirm the expiry and read what certbot has been saying
Start from the outside, because the browser is what your visitors see. Replace DOMAIN throughout this guide with your bare domain.
echo | openssl s_client -connect DOMAIN:443 -servername DOMAIN 2>/dev/null \
| openssl x509 -noout -enddate
You will get one line, notAfter=Mon Aug 17 09:41:12 2026 GMT or similar. If that date is in the past, the certificate nginx is serving has expired, which is a separate fact from whether certbot has a newer one on disk, so check the disk next.
sudo certbot certificates
This lists every certificate certbot manages with its expiry date and the domains it covers. In my case it showed the same expired date as openssl, which meant renewal itself was failing rather than nginx serving a stale file. Now read the log, remembering that certbot rotates it on every run, so the current file is often just the last renewal attempt and the useful history is in the numbered files.
sudo grep -l "unauthorized" /var/log/letsencrypt/letsencrypt.log* | head
sudo grep -h -A3 "Challenge failed\|Invalid response\|unauthorized" \
/var/log/letsencrypt/letsencrypt.log.1 | head -40
You are looking for two things. The first is the error type urn:ietf:params:acme:error:unauthorized, which tells you the Let’s Encrypt validation server reached your host but got the wrong answer. The second is the URL in the detail line. Certbot asks Let’s Encrypt to fetch http://DOMAIN/.well-known/acme-challenge/TOKEN, but the failing response in my log was reported against https://DOMAIN/.well-known/acme-challenge/TOKEN with a 403. That switch from http to https in the detail is the redirect chain written down for you, and the 403 at the end of it is the second half of the fault.
Step 2. Reproduce the redirect with curl
Before touching config, prove the diagnosis from your own laptop.
curl -I http://DOMAIN/.well-known/acme-challenge/test
A healthy server answers HTTP/1.1 404 Not Found straight from port 80, because the challenge directory is served directly and the test file does not exist. A broken one answers HTTP/1.1 301 Moved Permanently with a Location header pointing at https://DOMAIN/.well-known/acme-challenge/test. Follow the redirect to see what the validation server saw at the end of the chain.
curl -IL http://DOMAIN/.well-known/acme-challenge/test
On my server the second response was 403 Forbidden, served by the HTTPS block. If you see the 301 you have found the cause and can move on. If you see a 404 from port 80 but renewals still fail, the fault is somewhere else, and the Where I could be wrong section lists the usual suspects.
Step 3. Find the file nginx actually loads
Most Debian and Ubuntu servers have a pile of files in /etc/nginx/sites-available/, often including an old default, a copy from a previous migration, and something called DOMAIN.conf.bak. Only the ones symlinked from /etc/nginx/sites-enabled/ are read. On the affected server I spent ten minutes editing a file that was not enabled before I checked.
ls -l /etc/nginx/sites-enabled/
sudo nginx -T 2>/dev/null | grep -n "server_name\|return 301\|listen\|acme"
The first command shows you which file each symlink points to. The second dumps the full configuration as nginx has assembled it and prints only the lines that matter, with line numbers, so you can see exactly which return 301 sits at server level and which server_name values are present. Edit the target of the symlink, not the copy.
Take a backup before you change anything, because the next step edits the file that keeps your site online.
sudo tar czf /root/nginx-backup-$(date +%F).tgz /etc/nginx
Step 4. Fix the port-80 server block
This was the block that caused the outage, reduced to the lines that matter.
server {
listen 80;
server_name DOMAIN;
return 301 https://$host$request_uri;
}
The return sits directly inside server, so nginx executes it during the server-level rewrite phase, before it has even begun matching the request against location blocks. It does not matter what locations you add below it. Every request on port 80, including certbot’s, is redirected. The HTTPS block then had this, which is a common hardening rule to hide .git, .env and .htaccess.
location ~ /\. {
deny all;
}
The regex matches any path containing a slash followed by a dot, and /.well-known/acme-challenge/ qualifies, so the redirected challenge request got a 403. Replace the port-80 block with this.
server {
listen 80;
listen [::]:80;
server_name DOMAIN www.DOMAIN;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type "text/plain";
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
Three things changed here. The redirect moved inside location /, so it now runs only after location matching and only for requests that did not match a more specific location. The challenge path has its own location with the ^~ modifier, which tells nginx to stop searching once this prefix matches and not to consult any regex locations, so the deny all rule can no longer interfere even if you copy it into this block later. And server_name now lists both the bare domain and www, because certbot validates every name on the certificate and a request for the www name that fell through to a different server block would fail in exactly the same silent way.
Create the webroot and make sure the path matches what certbot has stored for this certificate.
sudo mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
sudo grep -A3 "webroot" /etc/letsencrypt/renewal/DOMAIN.conf
The renewal file should show authenticator = webroot and a [[webroot_map]] section mapping each domain to /var/www/letsencrypt. If it maps to a different directory, either change the root line above to match or re-issue once with sudo certbot certonly --webroot -w /var/www/letsencrypt -d DOMAIN -d www.DOMAIN, which rewrites the renewal file.
Step 5. Add the deploy hook that was never there
Even a successful renewal would not have fixed the red screen on its own. Nginx reads certificate files at start-up and holds them in memory, so a new file on disk changes nothing until nginx reloads. Certbot runs any executable in /etc/letsencrypt/renewal-hooks/deploy/ after a successful renewal, and only after a successful one, which is precisely the moment you want a reload.
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
Use reload rather than restart, because a reload keeps existing connections open and swaps the certificate for new ones, while a restart drops everything for a second. If the script is not executable certbot ignores it without complaint, so the chmod is not optional.
Step 6. Test in the right order, then renew
Test the config syntax first, because a typo here takes the site down.
sudo nginx -t
sudo systemctl reload nginx
curl -I http://DOMAIN/.well-known/acme-challenge/test
curl -I http://www.DOMAIN/.well-known/acme-challenge/test
Both curl calls should now return a 404 from port 80 with no Location header. Then run a dry run, which goes through the whole challenge against the Let’s Encrypt staging environment without issuing a real certificate or counting against rate limits.
sudo certbot renew --dry-run
The output should end with a line saying the simulated renewal succeeded for each certificate. Note that a dry run skips deploy hooks unless you add --run-deploy-hooks, so a passing dry run does not prove the reload script works; Step 7 covers that. Only if the dry run passes, force a real renewal.
sudo certbot renew --force-renewal
Do not reach for --force-renewal before the dry run passes. Every failed real attempt counts towards the Let’s Encrypt failed validation limit, which is 5 failures per account, per hostname, per hour [VERIFY: current rate limit], and locking yourself out for an hour while the site is already showing a warning is a bad afternoon.
Step 7. Put a 30-second check in the calendar
The outage lasted eight days because nothing in my routine looked at the certificate. This takes half a minute and I now run it quarterly on every server, with a calendar reminder.
echo | openssl s_client -connect DOMAIN:443 -servername DOMAIN 2>/dev/null \
| openssl x509 -noout -enddate
systemctl list-timers | grep certbot
The first line should show a notAfter date between 30 and 90 days out, since certbot renews at 30 days remaining and Let’s Encrypt certificates last 90. If the date is under 30 days away, the last renewal failed and you should go back to Step 1. The second line should show certbot.timer (or snap.certbot.renew.timer on snap installs) with a NEXT time within the next 12 hours, because the timer fires twice a day. An empty result means the timer is not installed or not enabled, and the certificate will never renew regardless of how correct your nginx config is.
Check it worked
Within a minute of the forced renewal, the openssl command from Step 1 should show a notAfter date about 90 days in the future, and it should match the date printed by sudo certbot certificates. If certbot shows the new date but openssl shows the old one, the deploy hook did not run or did not reload nginx; run sudo systemctl reload nginx by hand, then check ls -l /etc/letsencrypt/renewal-hooks/deploy/ for the executable bit.
Open the site in a private Chrome window and click the padlock, then Connection is secure, then Certificate is valid, and read the expiry there too. Chrome caches HSTS and certificate state aggressively, so a normal window may still show the warning for a short while after the fix.
Finally, check that the hook fired by grepping the log for it.
sudo grep -h "reload-nginx\|Running deploy-hook" /var/log/letsencrypt/letsencrypt.log* | head
You should see a line naming the hook script. If it is absent after a real renewal, the file is either not executable or not in the deploy directory.
Where I could be wrong
This guide assumes HTTP-01 validation through webroot. If your renewal file shows authenticator = dns-cloudflare or another DNS plugin, the challenge is a TXT record and nginx is not involved at all; a failure there is usually an expired API token, and none of the nginx changes above will help.
Apache servers have the same class of fault with different syntax. A Redirect permanent / in the port-80 VirtualHost fires before any Alias for the challenge path, and the fix is either a RedirectMatch that excludes /.well-known/ or an Alias /.well-known/acme-challenge/ /var/www/letsencrypt/.well-known/acme-challenge/ placed above the redirect, then apachectl configtest.
Some hosts, including older Ubuntu images and most CentOS and Alma installs from pip, run certbot from cron rather than a systemd timer. On those, systemctl list-timers returns nothing and that is not a fault; check cat /etc/cron.d/certbot or sudo crontab -l for the schedule instead, and make sure the cron line calls certbot renew without --dry-run left in from a test.
The rate limit figure and the exact log wording change between certbot and Boulder releases. If your log does not contain the strings I grep for, read the whole of the most recent numbered log file rather than trusting my patterns.
Sources
- Certbot documentation, User Guide (renewal, hooks, dry run, logs), https://eff-certbot.readthedocs.io/en/stable/using.html
- nginx documentation, ngx_http_rewrite_module (order of server-level and location-level directives), https://nginx.org/en/docs/http/ngx_http_rewrite_module.html
- nginx documentation, location directive and the
^~modifier, https://nginx.org/en/docs/http/ngx_http_core_module.html#location - Let’s Encrypt, Challenge Types, https://letsencrypt.org/docs/challenge-types/
- Let’s Encrypt, Rate Limits, https://letsencrypt.org/docs/rate-limits/
- Let’s Encrypt, Ending Support for Expiration Notification Emails, https://letsencrypt.org/2025/01/22/ending-expiration-emails/
- OpenSSL manual, s_client, https://docs.openssl.org/master/man1/openssl-s_client/