Skip to content
← All articles

Certbot Not Renewing: Troubleshooting Let's Encrypt Auto-Renewal

Short answer. Let's Encrypt auto-renewal is driven by the certbot.timer systemd unit or a cron job: certbot runs twice a day, walks every certificate and renews the ones with less than roughly thirty days left. If renewal stopped working, start with certbot renew --dry-run — it reproduces the real validation without burning production limits. Then check the config in /etc/letsencrypt/renewal/, external reachability of /.well-known/acme-challenge/, and whether a deploy hook reloads your web server.

This article is about failures, not first issuance. Installing certbot and getting a certificate from scratch is covered separately in free SSL with Let's Encrypt: setup guide. Here we assume the certificate was issued once, worked, and then quietly stopped renewing — and we walk every typical failure with the command that proves it and the fix.

How certbot auto-renewal actually works

Auto-renewal is not magic inside certbot. It is an external scheduler calling one command: certbot renew. Certbot then walks every file in /etc/letsencrypt/renewal/, checks the remaining lifetime of each certificate and renews only the ones below the threshold — by default about thirty days before expiry.

There are three schedulers in the wild, and a single server can end up running two of them:

  • systemd timer certbot.timer — used by Debian/Ubuntu packages and the snap install. Fires twice a day with a randomized delay so the whole internet does not hit the CA at midnight sharp.
  • cron job /etc/cron.d/certbot — the older scheme, also twice a day, usually with a random sleep up front.
  • a custom script or container loop — typical in Docker: an entrypoint that sleeps and calls renew forever.
# systemd timer: does it exist, when did it run, when will it run
systemctl list-timers --all | grep -i certbot
systemctl status certbot.timer
systemctl cat certbot.service

# cron variant
cat /etc/cron.d/certbot 2>/dev/null

# what certificates exist and when they expire
certbot certificates

# logs of recent runs
journalctl -u certbot --since "30 days ago" --no-pager | tail -n 60
tail -n 200 /var/log/letsencrypt/letsencrypt.log

The detail people usually miss: the renewal window opens well in advance. Certbot starts trying about a month before expiry and retries twice a day. That gives you roughly sixty failed attempts and a full month of buffer before visitors see an error. The flip side is silence: a successful renewal announces nothing, and a failed one lands in a log file nobody reads.

If you learned about the problem from a browser warning or from a customer, a month of silent failures has already passed. The correct detection point is not an expired certificate — it is the first failing certbot renew --dry-run.
Diagram of the auto-renewal loop: a systemd timer runs certbot renew, certbot reads renewal configs, checks remaining lifetime and performs the ACME domain validation
The renewal loop: timer → certbot renew → renewal configs → ACME validation → files written → deploy hook.

certbot renew --dry-run is the diagnostic command

certbot renew --dry-run performs a full renewal cycle against the ACME staging environment: it builds the order, runs the domain validation, receives a test certificate — and writes nothing into /etc/letsencrypt/live/. It is the only safe way to learn whether a certificate will renew before that becomes urgent.

# check every certificate on the box
certbot renew --dry-run

# a single certificate — faster and easier to read
certbot renew --cert-name example.com --dry-run

# verbose, when the error is vague
certbot renew --cert-name example.com --dry-run -v

A successful run looks roughly like this:

Processing /etc/letsencrypt/renewal/example.com.conf
Simulating renewal of an existing certificate for example.com and www.example.com

Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/example.com/fullchain.pem (success)

A failure looks like this:

Failed to renew certificate example.com with error: Some challenges have failed.

All simulated renewals failed. The following certificates could not be renewed:
  /etc/letsencrypt/live/example.com/fullchain.pem (failure)

What matters is not the failure itself but the reason. It sits a line or two above in the output and always in full in /var/log/letsencrypt/letsencrypt.log. The exact wording — Timeout, 404, NXDOMAIN, unauthorized — tells you which section below you need.

One important limitation: --dry-run does not run deploy hooks by default. A green dry run proves the certificate can be issued. It does not prove your web server will pick up the new file. Recent certbot versions have a dedicated flag to run deploy hooks during a dry run; if yours does not, verify the reload by hand.

Practical rule: run --dry-run on a schedule — monthly, and always after any change to nginx, DNS, firewall rules, WAF policy, or the site's document root. A second of checking prevents a night-time incident.

The renewal config: /etc/letsencrypt/renewal/<domain>.conf

Certbot stores the parameters each certificate was issued with. Renewal uses those, not whatever you typed in the shell last time. If the site moved, changed web server or changed validation method, this file is stale and renewal keeps failing until it matches reality.

# cat /etc/letsencrypt/renewal/example.com.conf
archive_dir = /etc/letsencrypt/archive/example.com
cert = /etc/letsencrypt/live/example.com/cert.pem
privkey = /etc/letsencrypt/live/example.com/privkey.pem
chain = /etc/letsencrypt/live/example.com/chain.pem
fullchain = /etc/letsencrypt/live/example.com/fullchain.pem

[renewalparams]
account = 4a1f...
authenticator = webroot
webroot_path = /var/www/example.com/public,
server = https://acme-v02.api.letsencrypt.org/directory
key_type = ecdsa

[[webroot_map]]
example.com = /var/www/example.com/public
www.example.com = /var/www/example.com/public

What to read here:

  • authenticator — how ownership is proven: webroot, nginx, apache, standalone or a DNS plugin. Half of all failures are an authenticator that no longer matches reality — for example standalone on a box that now runs nginx permanently.
  • webroot_path and the [[webroot_map]] block — where certbot drops the challenge file. It must match the directory the web server actually serves for that host.
  • installer — what edits the web server config after issuance. If the plugin is gone or the web server changed, renewal fails at install time even though the certificate was obtained.
  • server — the ACME endpoint. If a staging URL ended up here, renewal technically succeeds but the certificate is untrusted, and browsers complain about an unknown CA.
  • renew_before_expiry — the renewal window, when set explicitly.
  • key_type — key algorithm. Changing it requires reissuance, not an edit.
Hand-editing this file is a last resort and a reliable source of subtle breakage. The supported way to change renewal parameters is to reissue with the same --cert-name: certbot rewrites the config correctly and completely.
# switch the certificate to webroot and a new directory, keeping its name
certbot certonly --cert-name example.com \
  --webroot -w /var/www/example.com/public \
  -d example.com -d www.example.com \
  --dry-run

# once the dry run passes, repeat without it
certbot certonly --cert-name example.com \
  --webroot -w /var/www/example.com/public \
  -d example.com -d www.example.com

HTTP-01 failures: redirects, webroot, port 80, WAF

HTTP-01 is simple: certbot writes a file at /.well-known/acme-challenge/<token>, and the CA's validators fetch it over plain HTTP on port 80 and compare the contents. It breaks in five ways.

1. The HTTPS redirect eats the request path

A plain http → https redirect is fine in itself — validators follow redirects. Two specific spellings are fatal. The first drops the URI:

# BAD: /.well-known/acme-challenge/... collapses to /
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host/;
}

The second redirects to an HTTPS host that does not serve that directory: a different root, a different server block, or a front-controller application that answers unknown paths with its own 404 page. Same outcome — the validator gets the wrong body.

The robust fix is an explicit ACME exception placed before the general redirect. The ^~ prefix guarantees this location wins over regex locations:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    # ACME exception — must sit ABOVE the general redirect
    location ^~ /.well-known/acme-challenge/ {
        root         /var/www/example.com/public;
        default_type "text/plain";
        auth_basic   off;
        allow        all;
        try_files    $uri =404;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

A separate trap: a return 301 written at the server level rather than inside location / overrides every location block. If your ACME exception seems ignored, look for that return first. Location priority and related pitfalls are covered in the nginx configuration guide; doing the HTTPS switch properly is covered in migrating from HTTP to HTTPS.

mkdir -p /var/www/example.com/public/.well-known/acme-challenge
echo ok > /var/www/example.com/public/.well-known/acme-challenge/test

# the whole redirect chain and the final status code
curl -sSIL http://example.com/.well-known/acme-challenge/test | grep -E 'HTTP/|[Ll]ocation'

# the body — must be exactly "ok"
curl -sSL http://example.com/.well-known/acme-challenge/test

rm /var/www/example.com/public/.well-known/acme-challenge/test

2. A 404 because webroot points elsewhere

Classic after a migration or a framework change: webroot_path still says /var/www/html while the site has long been served from /var/www/example.com/public. Certbot happily writes the file — into a directory nobody serves. Log symptom: Invalid response ... 404. The fix is reissuance with the correct -w, as shown above.

3. Port 80 is closed or taken

HTTP-01 always connects to port 80; you cannot move it on the CA side. A port blocked by the host firewall, a cloud security group or the provider itself produces Timeout during connect (likely firewall problem).

# is anything listening locally
ss -lntp | grep ':80 '

# firewall rules
nft list ruleset 2>/dev/null | grep -n 'dport 80' || iptables -S | grep -- '--dport 80'

# reachability from outside (run from another machine)
curl -sS -o /dev/null -w '%{http_code}\n' http://example.com/

A local ss only shows half the picture: the server may be listening while traffic never arrives. Verify from outside — for example with the port scanner.

4. Basic auth, WAF and bot protection

If the whole site sits behind HTTP basic auth, the validator gets a 401 and validation fails. WAFs and bot-protection layers do the same thing: they return a JavaScript challenge, a captcha, or a 403 for requests with no cookies and an unusual User-Agent. An ACME validator is not a browser — it runs no JavaScript, keeps no cookies and passes no interstitials.

The fix is a narrow exception for /.well-known/acme-challenge/ in both the auth layer and the WAF ruleset (in nginx, auth_basic off; inside that location, as above). If the site sits behind a proxying CDN with protection enabled, the request may never reach your origin at all — in that case switch to DNS-01.

5. Geo, ASN or allowlist blocking

The CA does not validate from a single location. It queries your domain from several network vantage points in different regions, and all of them must get the correct answer. If you restricted the site to certain countries, ASNs or IP ranges, HTTP-01 stops working even though the site loads perfectly from your own office.

Geo-blocking and Let's Encrypt auto-renewal are incompatible unless you leave /.well-known/acme-challenge/ open to the whole world. The alternative is DNS-01, which never checks network reachability of the site at all.
HTTP-01 validation path: the validator request hits port 80, passes the redirect, firewall, WAF and reaches the webroot directory, with four failure points marked
Four HTTP-01 failure points: a closed port 80, a redirect that drops the path, WAF or basic auth, and the wrong webroot.

DNS, SAN entries and server moves

The domain stopped resolving or the A record moved

DNS problem: NXDOMAIN looking up A for example.com means the validator could not find an address. Causes: the domain was not renewed, the zone is not delegated, the record was dropped during a DNS migration, or nameservers changed and the old ones no longer answer.

# does the name resolve and where does it point
dig +short A example.com
dig +short AAAA example.com

# which nameservers are authoritative right now
dig +short NS example.com

# does CAA forbid issuance for Let's Encrypt
dig +short CAA example.com

CAA is the underrated cause. If the zone carries a CAA record allowing only one CA and Let's Encrypt is not on the list, renewal fails on policy grounds while the site is perfectly reachable. This happens when someone adds CAA for a different certificate vendor and forgets certbot exists. Check the live zone with the DNS record lookup, and if the record was just edited, with the DNS propagation checker.

One dead subdomain kills the entire certificate

A Let's Encrypt certificate can carry several names in its SAN field. Renewal validates all of them, and a single name that no longer resolves or is unreachable fails the renewal of the whole certificate. Typical case: old.example.com or staging.example.com is still listed, the project was shut down, the record was deleted — and the main domain goes down with it.

# which names are in the certificate
certbot certificates

# same, straight from the file
openssl x509 -noout -text -in /etc/letsencrypt/live/example.com/fullchain.pem \
  | grep -A1 'Subject Alternative Name'

# reissue without the dead name: pass the FULL new -d list
certbot certonly --cert-name example.com \
  --webroot -w /var/www/example.com/public \
  -d example.com -d www.example.com \
  --dry-run

Note that the -d list is declarative, not subtractive: what you list is what you get. Certificates you no longer need should be removed entirely with certbot delete --cert-name old.example.com, otherwise they stay in the renewal queue and keep failing forever.

Server migration without /etc/letsencrypt

The certificate and key were copied, the /etc/letsencrypt directory was not. The new server has no ACME account, no renewal configs and no archive: there is nothing to renew and no timer will help. A worse variant: the directory was copied with plain cp, the symlinks under live/ became regular files, and certbot no longer understands the layout.

# move it preserving symlinks, ownership and ACLs
rsync -aAX --numeric-ids /etc/letsencrypt/ root@new-host:/etc/letsencrypt/

# on the new host — mandatory verification
certbot certificates
certbot renew --dry-run
Private keys under /etc/letsencrypt/archive/ are as sensitive as the root password. Move them only over an encrypted channel, keep mode 0600 and owner root, never drop them into unencrypted backups, and do not stage them on intermediate machines.

An alternative to migration is to issue a fresh certificate on the new server before switching DNS, using DNS-01. That is cleaner: the old server keeps serving with its certificate, the new one gets its own, and the traffic switch creates no TLS gap.

DNS-01: expired provider keys, stale plugins, impatient propagation waits

DNS-01 does not require the site to be reachable: certbot creates a _acme-challenge.example.com TXT record through the DNS provider's API, waits for propagation and asks the validator to read it. It is the only way to obtain a wildcard certificate and the best option for sites behind a CDN, a WAF or a geo filter. It has three failure modes of its own.

  • The API key expired or changed shape. Providers regularly rework access models: a global key is replaced by a scoped token, old keys get revoked. The plugin then receives a 401 or 403, and certbot reports a plugin error rather than a domain error. Verified by refreshing the credentials file and rerunning --dry-run.
  • The plugin is older than the API. Especially when certbot came from a distro package and the plugin from somewhere else. Symptom: it worked for a year, nobody changed anything, and it suddenly stopped.
  • The propagation wait is too short. Certbot waits a fixed interval and then asks for validation. If the provider's zone converges more slowly, the validator reads the old state. Fixed by raising --dns-<provider>-propagation-seconds (for example --dns-cloudflare-propagation-seconds 60).
# the credentials file must have strict permissions
chmod 600 /etc/letsencrypt/dns-credentials.ini
ls -l /etc/letsencrypt/dns-credentials.ini

# is the TXT record visible to public resolvers and to the authoritative server
dig +short TXT _acme-challenge.example.com
dig +short TXT _acme-challenge.example.com @1.1.1.1
dig +short TXT _acme-challenge.example.com @ns1.example.com

# a run with a longer wait
certbot renew --cert-name example.com \
  --dns-cloudflare-propagation-seconds 60 --dry-run

A durable trick for awkward setups: CNAME _acme-challenge.example.com into a small dedicated zone hosted somewhere with a decent API. The production zone stays untouched, and certbot only ever manages that tiny helper zone — fewer privileges, smaller blast radius.

Standalone mode when nginx already owns port 80

standalone spins up a temporary web server on port 80. If the certificate was first issued before nginx existed on that box, the renewal config still says authenticator = standalone forever, and renewal produces:

Problem binding to port 80: Could not bind to IPv4 or IPv6.

Three fixes, in descending order of correctness:

  • Move to webroot. The right answer for any box running a permanent web server: no downtime, no hooks. Done by reissuing with --cert-name and --webroot -w.
  • Keep standalone on a non-standard port and proxy to it. Certbot listens locally and nginx proxies only the ACME path. It works, but it is more moving parts than webroot for no benefit.
  • Stop and start the web server with hooks. Reasonable when there is no web server at all (a mail server, a VPN, a broker) but the certificate still needs port 80.
# one-off, on the command line
certbot renew --cert-name mail.example.com \
  --pre-hook  "systemctl stop nginx" \
  --post-hook "systemctl start nginx"

# permanently, for every certificate — scripts in the hook directories
/etc/letsencrypt/renewal-hooks/pre/
/etc/letsencrypt/renewal-hooks/post/
/etc/letsencrypt/renewal-hooks/deploy/

Certbot executes scripts from these directories on every renew: pre before the attempt, post afterwards regardless of outcome, and deploy only when a certificate was actually renewed. Do not forget chmod +x — a non-executable file is silently ignored.

Let's Encrypt rate limits: why retrying makes it worse

Let's Encrypt enforces limits on how many operations you may perform in a period — both on certificates issued for a given set of names and, separately, on failed validation attempts. The exact numbers have changed over time; always read the current values in the Let's Encrypt rate limits documentation.

The practical consequence matters more than the numbers: retrying against production does not fix anything, it locks you out. Every failed certbot renew consumes the failed-validation budget. Once exhausted, you are refused for hours whether or not you have fixed the root cause — and the site stays without a certificate the whole time.

The order is strict: --dry-run first (staging, separate limits), find and fix the cause, repeat --dry-run until it passes, and only then run one production certbot renew. Never the other way round.

About --force-renewal: the flag only ignores the renewal window and reissues even when months remain. It is almost never useful for troubleshooting, and it burns the issuance budget for that set of names beautifully. Use it deliberately — for a key type change, say — not as a panic button.

If you are already rate-limited and need a certificate now, there is a legitimate escape hatch: limits are counted per exact set of names. A certificate for a narrower list (just example.com without www) is a different set with its own counter. As a stopgap, that works.

The certificate renewed but the browser still shows the old one

The most frustrating category: certbot succeeded, the files under /etc/letsencrypt/live/ are fresh, and the site keeps serving the expired certificate. The reason is that nginx, Apache, Postfix, Dovecot and HAProxy read the certificate at startup and keep it in memory. A new file on disk means nothing to them.

# what is on disk
openssl x509 -noout -dates -subject \
  -in /etc/letsencrypt/live/example.com/fullchain.pem

# what clients actually receive
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates -subject -issuer

If the dates differ, you need a deploy hook. It runs only after an actual renewal, so it is safe to attach a service reload to it:

# one-off on the command line
certbot renew --deploy-hook "systemctl reload nginx"

# permanently, for every certificate
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/sh
set -e
nginx -t
systemctl reload nginx
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Service-specific notes:

  • nginx and Apache — a reload is enough. Always gate it behind nginx -t or apachectl configtest: a broken config on reload leaves the old process running and you wondering why nothing changed.
  • Postfix and Dovecot — a reload is usually enough, but some builds only pick up keys on a full restart. Verify the same way with openssl s_client against ports 25, 465, 587 and 993, using -starttls smtp where appropriate.
  • HAProxy — expects a single file containing the chain and the private key concatenated. The deploy hook has to build that file from fullchain.pem and privkey.pem before reloading.
  • Containers — the hook must reload the process inside the container, not on the host.
Comparison of a fresh certificate file on disk versus the stale certificate held in the nginx process memory, bridged by a deploy hook that reloads the service
The file changed, the process did not. The bridge between them is a deploy hook that reloads the service.

Two certbots, two timers: snap, apt and pip on one server

Certbot can be installed at least three ways and each brings its own scheduler. When a box ends up with two, things get interesting: a manual certbot works while the scheduled one fails; or two timers touch the same directory with different versions; or a cron job calls /usr/bin/certbot while you have been diligently upgrading the snap in /snap/bin/certbot.

# how many certbots exist and which one wins on PATH
which -a certbot
certbot --version

# where each came from
snap list certbot 2>/dev/null
dpkg -l 2>/dev/null | grep -i certbot
rpm -qa 2>/dev/null | grep -i certbot
pip3 show certbot 2>/dev/null

# how many schedulers
systemctl list-timers --all | grep -iE 'certbot|snap\.certbot'
ls -l /etc/cron.d/ | grep -i certbot

The rule is simple: one install source, one scheduler. Remove the extra package together with its timer or cron file. Separately, make sure any cron job calls certbot by absolute path — cron's environment is leaner than an interactive shell, and certbot: command not found in the cron log means exactly that.

One more detail: distro-packaged certbot on LTS releases lags noticeably behind upstream. An old certbot may not support the DNS plugin you need or a newer ACME response format. If renewal started failing with no changes on your side, check the version.

Certbot in Docker: volumes, clocks, reloading a neighbour

Containers add their own set of traps, and every one of them is about isolation.

The container cannot see the webroot

The most common mistake: nginx serves /usr/share/nginx/html while certbot writes to /var/www/certbot, and the two paths are mounted from different places or do not overlap at all. The challenge file is created, and the web server knows nothing about it.

# can nginx see what certbot wrote
docker compose exec certbot sh -c 'echo ok > /var/www/certbot/.well-known/acme-challenge/test'
docker compose exec nginx  ls -la /var/www/certbot/.well-known/acme-challenge/

# and from outside
curl -sSL http://example.com/.well-known/acme-challenge/test

# full simulation
docker compose run --rm certbot renew --dry-run

An anonymous volume instead of a named one

If /etc/letsencrypt is mounted as an anonymous volume, recreating the container loses the ACME account, the renewal configs and the key archive. From the outside this looks like "certbot just stopped renewing": there is nothing left to renew, the directory is empty. That volume must be named or a host bind mount.

Clock drift

ACME is sensitive to time: drift produces badNonce errors or complaints about validity periods. A container normally takes its time from the host kernel, so fix the host.

date -u
docker compose exec nginx date -u
timedatectl status
timedatectl set-ntp true

Reloading nginx from the certbot container

A deploy hook inside the certbot container cannot see the nginx process in a neighbouring container. Workable options: a sidecar that periodically runs nginx -s reload inside its own container; a scheduled daily restart of the nginx container; or a shared volume with a flag file the nginx container watches.

Do not mount /var/run/docker.sock into the certbot container just to reload a neighbour. Access to the Docker socket is equivalent to root on the host: any weakness in that container becomes full host compromise. For an nginx reload, that trade is not worth it.

Why Let's Encrypt email is not a monitoring system

Let's Encrypt may send expiry warnings to the address registered with your ACME account. You cannot build a process on that: the address often belongs to someone who left the company or to a former contractor; the mail lands in spam; and the CA has been reducing the volume of such notifications over time.

# inspect and update the account contact address
certbot show_account
certbot update_account --email ops@example.com

Keeping the address current is free and occasionally saves you. But the only dependable safety net is external expiry monitoring that looks at the live site rather than a file on disk, and alerts early — at thirty, fourteen and seven days. Then broken renewal surfaces within a day instead of at expiry. The full setup is in SSL certificate expiry monitoring; the ready-made tool is uptime and SSL monitoring.

Checking from outside: the file on disk lies

The file under /etc/letsencrypt/live/ answers "what did certbot obtain", not "what does a visitor see". Between them sit at least four places where the picture diverges: a missing reload, multiple server blocks with different certificates, SNI and default_server behaviour, and a load balancer or CDN terminating TLS with its own copy.

# what each IP of the domain serves — important with multiple A records
for ip in $(dig +short A example.com); do
  echo "== $ip"
  echo | openssl s_client -connect "$ip:443" -servername example.com 2>/dev/null \
    | openssl x509 -noout -dates -subject -issuer
done

# quick yes/no on remaining lifetime
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -checkend 604800 && echo "more than 7 days" || echo "less than 7 days"

# the chain as a client sees it
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
  | grep -E 's:|i:'

The -servername flag is mandatory: without it the server hands you the default_server certificate and you end up debugging the wrong one. Check chain completeness too — a renewed certificate served without its intermediate breaks a share of clients and looks exactly like "the certificate did not renew". That case is covered in incomplete SSL certificate chain; hands-on inspection methods are in how to check a site's SSL certificate.

Cheat sheet: symptom → cause → check → fix

Symptom or messageLikely causeCheckFix
Invalid response ... 404 Wrong webroot, or a redirect that drops the path curl -sSIL http://d/.well-known/acme-challenge/test Reissue with the correct -w; add an ^~ ACME location above the redirect
Timeout during connect (likely firewall problem) Port 80 blocked by firewall, security group or provider ss -lntp | grep ':80 ' plus an external port check Open 80 along the whole path; review nft/iptables and cloud rules
DNS problem: NXDOMAIN looking up A Name does not resolve: record deleted, NS changed, domain lapsed dig +short A d, dig +short NS d Restore the A record or drop the dead name from the certificate
Rejected on CAA policy CAA record allows a different certificate authority only dig +short CAA d Add letsencrypt.org to CAA or remove the stray record
unauthorized, a 401 or 403 response Basic auth, WAF, bot protection or geo filter in front curl -sSI http://d/.well-known/acme-challenge/test auth_basic off; plus a WAF path exception, or move to DNS-01
Only one hostname fails, yet nothing renews A dead name in the SAN list certbot certificates Reissue with the full corrected -d list
Problem binding to port 80: Could not bind authenticator = standalone while nginx holds the port ss -lntp | grep ':80 ' Switch to webroot, or add pre/post hooks stopping the web server
Too many failed validations or issuances Rate limit hit after a series of retries tail -n 200 /var/log/letsencrypt/letsencrypt.log Pause, diagnose via --dry-run only, then one production renew
DNS plugin returns 401 or 403 API key revoked, token model changed, plugin outdated certbot renew --cert-name d --dry-run -v New token in the credentials file, chmod 600, update the plugin
TXT record invisible to the validator Propagation wait too short for that provider dig +short TXT _acme-challenge.d Raise --dns-<provider>-propagation-seconds
Fresh file on disk, stale certificate in the browser No deploy hook: the service holds the old certificate in memory openssl s_client compared with the file --deploy-hook "systemctl reload nginx" or a renewal-hooks/deploy script
Manual renew works, scheduled renew does not Two certbot installs, two timers, different PATH which -a certbot, systemctl list-timers --all Keep one source and one scheduler; call certbot by absolute path
Docker: No such file or directory on the webroot certbot and nginx volumes do not overlap docker compose exec nginx ls -la /var/www/certbot/... Mount one named volume into both containers
badNonce or validity-period complaints Clock drift on the host or in the container timedatectl status, date -u timedatectl set-ntp true and proper host time sync
Browser reports an untrusted CA after a successful renewal The renewal config points at the staging ACME endpoint grep server /etc/letsencrypt/renewal/d.conf Reissue against the production endpoint, without the staging flag
The following certs are not due for renewal Not a failure: the renewal window has not opened yet certbot certificates Do nothing; verify the mechanism with --dry-run
Troubleshooting decision tree branching from the certbot error text into three paths: network reachability, DNS, and renewal configuration
Decision tree: the dry-run error text picks the branch — network, DNS or renewal configuration.

What to do right now if the certificate already expired

  1. Confirm it from outside, not from the file: openssl s_client with -servername, or the online SSL check. Sometimes "expired" is really an incomplete chain or the wrong server block.
  2. Do not loop renew. Each failed attempt moves you closer to the limit that blocks issuance for hours.
  3. Check the basics: is port 80 open, does the domain resolve, does /.well-known/acme-challenge/ serve a test file.
  4. Get the real error: certbot renew --cert-name example.com --dry-run -v plus the full text from /var/log/letsencrypt/letsencrypt.log.
  5. Fix the cause using the table above and repeat --dry-run until it passes.
  6. One production run: certbot renew --cert-name example.com. An expired certificate is always inside the renewal window, so --force-renewal is unnecessary.
  7. Reload the service and confirm from outside that the new certificate is being served.
  8. If you are rate-limited, a certificate for a narrower set of names is a legitimate stopgap: a different set has its own counter.
  9. Set up monitoring the same day, while the outage is still fresh in memory.

A broader walkthrough of the emergency scenario, including causes unrelated to certbot, is in what to do when an SSL certificate expires.

How to check

Run the diagnosis from outside — the same vantage point the certificate authority uses:

  • SSL certificate check — real issue and expiry dates, chain completeness, hostname match. Start here: it answers whether renewal is actually the problem.
  • SSL error diagnostics — decoding a specific browser message: untrusted authority, name mismatch, expired certificate, broken chain.
  • Redirect chain checker — shows where a request to /.well-known/acme-challenge/ actually ends up and whether the path survives. The most common HTTP-01 failure is visible right here.
  • Website and SSL monitoring — continuous expiry tracking with early alerts. The mandatory insurance against a repeat.
  • Port scanner — whether port 80 is open from the outside, not just locally.
  • DNS record lookup — A, NS and CAA records as an external resolver sees them.

FAQ

Certbot says "not due for renewal" — is that an error?

No, that is normal: more than the threshold period remains, so there is nothing to renew. The only way to verify the mechanism at that moment is certbot renew --dry-run, which performs a real domain validation regardless of remaining lifetime.

How often should the timer run?

The standard scheme is twice a day with a randomized delay. That yields roughly sixty attempts across the month-long renewal window and spreads load on the CA. Running more often is pointless; running less often is risky, because a single missed attempt stops being compensated by the others.

Does the private key change on renewal?

By default certbot generates a new key on every renewal, which is the right security posture. If your infrastructure is pinned to a specific key, the --reuse-key flag exists — but use it deliberately: a long-lived key is a long-lived risk.

What if port 80 is unavailable entirely?

Move to DNS-01 validation. It requires neither site reachability nor open ports, only API access to the DNS zone. It is also the only way to get a wildcard certificate and the working option for sites behind a CDN, a WAF or a geo filter.

Does changing the server IP require reissuing the certificate?

No — certificates are issued for hostnames, not IP addresses. But renewal revalidates the domain, so the A record must point at a server capable of answering the ACME challenge. When migrating, move /etc/letsencrypt wholesale and run --dry-run on the new host immediately.

Does --force-renewal help when renewal fails?

No. The flag only ignores the renewal window; it does not fix an unreachable challenge, a dead hostname or a broken config. Meanwhile every run consumes the issuance budget. Use --dry-run for failures and keep --force-renewal for deliberate tasks such as a key type change.

Checklist: renewal that does not break

  • certbot renew --dry-run passes right now — verified, not assumed.
  • A monthly --dry-run is scheduled, and it is also run after every change to nginx, DNS, firewall or WAF.
  • Exactly one scheduler: a systemd timer, a cron job, or a container loop. Confirmed via systemctl list-timers and which -a certbot.
  • No renewal configs for long-dead domains remain in /etc/letsencrypt/renewal/.
  • The certificate's name list matches reality: no dead subdomains.
  • nginx has a ^~ /.well-known/acme-challenge/ location above the general redirect, with auth_basic off;.
  • Port 80 is reachable from outside and the ACME path is exempt from geo filters, WAF and bot protection.
  • A deploy hook reloads every service that uses the certificate: web, mail, proxy.
  • The deploy hook is executable (chmod +x) and validates the config before reloading.
  • /etc/letsencrypt is backed up and migrated as a whole, preserving symlinks and permissions.
  • The renewal config points at the production ACME endpoint, not staging.
  • The ACME account contact address is current, but no process depends on those emails.
  • External expiry monitoring alerts at least thirty days ahead and inspects the live site, not a file.
  • A written runbook exists for "the certificate expired", so nobody improvises during an incident.

Check your website right now

Check your site's SSL →
More articles: SSL/TLS
SSL/TLS
Expired SSL Certificate: How to Fix NET::ERR_CERT_DATE_INVALID
15.04.2026 · 692 views
SSL/TLS
SSL Certificate Chain: How It Works, How to Verify It and How to Fix an Incomplete Chain
15.04.2026 · 641 views
SSL/TLS
Weak Cipher Suites: How to Find and Disable Insecure TLS Ciphers
15.04.2026 · 610 views
SSL/TLS
SSL Handshake Failed: Root Causes and Step-by-Step Diagnosis
15.04.2026 · 607 views