Certbot asked Let's Encrypt to fetch http://yourdomain.com/.well-known/acme-challenge/<token>.
Your vhost answered with a 301 to HTTPS, a 404, or a login page.
The renew log shows unauthorized or 404. Exit is not 0. No new files under /etc/letsencrypt/live/. The cert on :443 keeps ageing.
HTTP-01 is an HTTP on port 80 check. A site-wide "force HTTPS" rule that also catches /.well-known/acme-challenge/ breaks renewals the day the redirect went live, not the day you first enabled TLS.
Prove it
1. Read the failure (on the box)
# last renew attempt sudo tail -n 80 /var/log/letsencrypt/letsencrypt.log # or a dry-run that prints the same class of error sudo certbot renew --dry-run
Look for lines naming http-01, unauthorized, 404, or the challenge URL. If the log instead shows success and disk is fresh but browsers still see the old cert, stop here and use the nginx reload write-up. That is disk vs wire, not ACME.
2. Hit the challenge path yourself (from a laptop, not only localhost)
Certbot writes a token file only during an active attempt. Between runs the path may 404 for a different reason. During a dry-run or renew, in another terminal:
# replace TOKEN with the value certbot is serving this attempt curl -sS -D- -o /dev/null http://yourdomain.com/.well-known/acme-challenge/TOKEN
What you want: HTTP 200 on port 80, body equal to the token file contents.
What breaks renewals:
| Response | Likely cause |
|---|---|
301 / 302 to https://… | Force-HTTPS caught the challenge path |
404 | Root/alias wrong, or the request never reached the web root certbot uses |
401 / 403 | Auth wall, IP allowlist, WAF, or deny all on .well-known |
Connection refused on :80 | Nothing listening; firewall; "HTTPS only" at the edge |
200 on HTTPS only | The challenge must work on HTTP for classic HTTP-01 |
3. Confirm what owns port 80
sudo ss -lptn 'sport = :80' # or sudo lsof -i :80
If the process is not the nginx or Apache you edited, you fixed the wrong edge (CDN, load balancer, another host).
Same class of check without SSH while a cert is still on the wire: paste the hostname into the SSL expiry checker and read days left. That does not debug ACME, but it shows how close the served leaf is to becoming a browser warning while renew stays red.
For the full failure map (reload, timer, rate limit, root mail), use the disk vs wire hub.
Fix now (carve out ACME on port 80)
Goal: /.well-known/acme-challenge/ is served in plain HTTP before any HTTPS redirect or auth.
nginx
Place the challenge location above the blanket redirect. Use the same webroot certbot expects (often /var/www/html or a dedicated /var/www/certbot).
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
# ACME HTTP-01 must stay on HTTP
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
root /var/www/certbot; # parent of /.well-known/...
}
location / {
return 301 https://$host$request_uri;
}
}
sudo nginx -t && sudo systemctl reload nginx
Webroot plugin example (paths must match root above):
sudo certbot certonly --webroot \ -w /var/www/certbot \ -d yourdomain.com -d www.yourdomain.com
If you use certbot --nginx, still verify the generated server block did not put a catch-all redirect ahead of the challenge location after a later manual edit.
Apache
<VirtualHost *:80>
ServerName yourdomain.com
ServerAlias www.yourdomain.com
DocumentRoot /var/www/html
# Allow ACME without auth
<Location "/.well-known/acme-challenge/">
Require all granted
</Location>
# Redirect everything else to HTTPS
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/.well-known/acme-challenge/
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</VirtualHost>
sudo apache2ctl configtest && sudo systemctl reload apache2 # Debian/Ubuntu; on RHEL-like: httpd + systemctl reload httpd
CDN / load balancer / "HTTPS only" at the edge
If Cloudflare (or similar) forces HTTPS at the zone before traffic hits origin, HTTP-01 never reaches your carve-out.
Options that ops teams actually use:
- DNS-01 instead of HTTP-01 (API token to your DNS provider; no port 80 needed).
- Edge rule: do not force HTTPS for
/.well-known/acme-challenge/*, and do not lock that path behind a bot challenge in a way that blocks the CA. - Issue and terminate certificates at the edge, then monitor the edge hostname visitors hit, not only origin.
Pick one model. Mixed "origin certbot HTTP-01 plus edge force-HTTPS with no exception" stays red.
Firewall
# port 80 must accept the CA (and your own curl check) sudo iptables -L -n | head # or nft list ruleset; or cloud SG/NSG UI
If :80 is closed world-wide, HTTP-01 cannot pass. Open 80 for the challenge path, or move to DNS-01.
Re-run renew
sudo certbot renew --dry-run # when dry-run is clean: sudo certbot renew
Then confirm disk has a new notAfter:
openssl x509 -noout -enddate \ -in /etc/letsencrypt/live/yourdomain.com/fullchain.pem
And wire matches after the TLS process reloads (see the nginx reload write-up if disk moves and wire does not):
echo | openssl s_client -connect yourdomain.com:443 \ -servername yourdomain.com 2>/dev/null \ | openssl x509 -noout -enddate
Make it permanent
- Keep the ACME location permanent in config management. A one-off edit dies on the next "force HTTPS" PR.
- Prefer a dedicated webroot for challenges (
/var/www/certbot) so app deploys do not wipe token files. - After any change to redirects, HSTS assumptions, or CDN HTTPS mode, run
certbot renew --dry-runin CI or as a scheduled check. - If port 80 is a long-term liability, switch the authenticator to DNS-01 and document the API credential rotation.
HSTS on HTTPS does not replace a working HTTP-01 path. Browsers honor HSTS; the Let's Encrypt validation client still speaks HTTP for HTTP-01.
How you catch it off-box
On the box, renew logs go unread for weeks. Root mail never leaves. The next human signal is a browser warning or a monitoring page that only pings HTTP 200.
- After the fix: SSL expiry checker on the public hostname. Confirm days left on the served leaf.
- Any port, quick wire read: SSL checker.
- Ongoing: CertPost watches the served cert (3 free, no card) and emails at 30/14/7/1 days. When HTTP-01 breaks again, you still see the leaf age even if nobody opens
letsencrypt.log.
Watch the wire. Fix ACME so disk can catch up.
Related failures
- Certbot says it renewed. Your server is still serving the old cert. — the full disk-vs-wire failure map
- nginx reloaded nothing. Certbot still exited 0. — when renew succeeds but the reload is missing
- The certbot timer is not running. — when nothing schedules the renew at all
- Full chain check when leaf dates look fine but clients still warn
