# Certbot Renew Cron: Monitor Renewals Before Certificates Expire

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/certbot-renewal-monitoring

> How certbot renew is scheduled on snap, Debian, Ubuntu, and pip installs, why renewals fail unnoticed, and how to test, hook, and monitor every run.

**`certbot renew` is the command that renews Let's Encrypt certificates, and the snap and Linux distribution packages run it twice a day from a systemd timer or a cron job.** Each run renews only the certificates close to expiry: those with less than a third of their lifetime left in certbot 4.0 and later, or less than 30 days in older versions ([certbot docs](https://eff-certbot.readthedocs.io/en/stable/using.html#renewing-certificates)). Every other run does nothing and exits 0. When renewal starts failing, nothing visible changes, because the current certificate keeps working until the day it expires and Let's Encrypt no longer emails a warning. The only proof the job works is a record of every run.

Behavior below is from certbot 5.8.0, the current release ([releases](https://github.com/certbot/certbot/releases), checked October 25, 2026). Debian 13 packages certbot 4.0.0 and Debian 12 packages 2.1.0 ([Debian](https://sources.debian.org/src/python-certbot/)); Ubuntu 24.04 packages 2.9.0 ([Launchpad](https://launchpad.net/ubuntu/noble/+source/python-certbot)). `certbot --version` shows yours.

## Where Each Install Method Schedules certbot renew

| Install method           | Scheduler                                                 | When `certbot renew` runs                                         |
| ------------------------ | --------------------------------------------------------- | ----------------------------------------------------------------- |
| Snap                     | `snap.certbot.renew.timer`                                | One random time in 00:00-11:55 and one in 12:00-23:55, local      |
| Debian or Ubuntu package | `certbot.timer`, or `/etc/cron.d/certbot` without systemd | 00:00 and 12:00 local, each plus a random delay of up to 12 hours |
| pip                      | None until you add a cron entry                           | When your entry says                                              |

- **Snap.** The snap declares `renew` as a oneshot service with `timer: 00:00~24:00/2` ([snapcraft.yaml](https://github.com/certbot/certbot/blob/v5.8.0/snap/snapcraft.yaml#L45-L49)). Each time snapd writes the timer unit, for example when the snap is installed or refreshed, it picks a random time inside each 12-hour window and stores the two as fixed `OnCalendar=` lines, with no `Persistent=` ([service_timer_gen.go](https://github.com/canonical/snapd/blob/2.78/wrappers/internal/service_timer_gen.go#L208-L270), snapd 2.78). A run due while the machine was off is not made up.
- **Debian and Ubuntu.** `certbot.timer` sets `OnCalendar=*-*-* 00,12:00:00`, `RandomizedDelaySec=43200`, and `Persistent=true`; `certbot.service` runs `certbot -q renew --no-random-sleep-on-renew` ([Debian 13 packaging](https://sources.debian.org/src/python-certbot/4.0.0-2%2Bdeb13u1/debian/); Debian 12 and Ubuntu 24.04 ship the same units). The cron entry begins with `test -x /usr/bin/certbot -a \! -d /run/systemd/system`, so on a host booted with systemd it exits without renewing. Disable the timer and nothing renews, even though `/etc/cron.d/certbot` is still there.
- **pip.** Certbot's [pip instructions](https://certbot.eff.org/instructions?ws=nginx&os=pip) add an `/etc/crontab` line that runs at 00:00 and 12:00 after a random sleep of up to an hour.

When stdin isn't a terminal, certbot also waits a random 1 to 480 seconds before the first renewal of a run, unless `--no-random-sleep-on-renew` is set, as Debian's units do because the timer already randomizes ([renewal.py](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/_internal/renewal.py#L662-L708)). Since certbot 4.1, a CA's ACME Renewal Information (ARI) can move renewal earlier; for Let's Encrypt that typically means about two-thirds of the way through the lifetime ([changelog](https://github.com/certbot/certbot/blob/v5.8.0/certbot/CHANGELOG.md#410---2025-06-10)).

`certbot renew` exits 1 when any certificate fails to renew or any renewal configuration can't be parsed, and 0 otherwise, including when nothing was due ([renewal.py](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/_internal/renewal.py#L744-L746)). It only considers certificates with a `*.conf` file in `/etc/letsencrypt/renewal/` ([storage.py](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/_internal/storage.py#L49-L60)) that don't set `autorenew = False` ([storage.py](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/_internal/storage.py#L992-L1002)).

## Why Certbot Renewals Fail Without Anyone Noticing

Each failure below leaves the working certificate in place, so the first symptom anyone sees is a browser error on expiry day, weeks after the first failed run.

- **HTTP-01 can't reach port 80.** The challenge "can only be done on port 80", redirects are followed only to ports 80 and 443, and Let's Encrypt may fetch the file from several vantage points ([challenge types](https://letsencrypt.org/docs/challenge-types/)). A firewall rule, a CDN or load balancer in front of port 80, or a new redirect breaks it. A name with an AAAA record is checked over IPv6 first, with a fallback to IPv4 only on a connection timeout ([IPv6 support](https://letsencrypt.org/docs/ipv6-support/)), so an IPv6 address that answers from the wrong server fails every time.
- **Standalone can't bind port 80** because the web server holds it. Pre and post hooks, below, stop and start the server.
- **DNS-01 credentials stop working.** A DNS plugin authenticates with the API token in its credentials file; once that token is revoked, expires, or loses access to the zone, every renewal fails.
- **`--manual` certificates don't renew automatically** unless they were set up with authentication hook scripts ([certbot docs](https://eff-certbot.readthedocs.io/en/stable/using.html#renewing-certificates)).
- **Rate limits.** Only renewals that name the certificate they replace through ARI are exempt from every limit, and certbot 5.8.0's order request has no `replaces` field ([acme/client.py](https://github.com/certbot/certbot/blob/v5.8.0/acme/src/acme/client.py#L115-L134)). Its renewals still count toward 5 certificates per exact set of names every 7 days and 5 failed authorizations per name per hour ([rate limits](https://letsencrypt.org/docs/rate-limits/), updated August 5, 2026). Repeated `--force-renewal` while debugging can use up a week's allowance; test with `--dry-run`, which uses the staging server.
- **The server keeps the old certificate.** With the `--nginx` or `--apache` plugin, certbot reloads the server after renewing ([main.py](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/_internal/main.py#L1556-L1560)). A certificate from `certonly` (webroot, standalone, DNS) is renewed on disk only, and nginx, HAProxy, or a mail server keeps serving the old one until it expires. A deploy hook that fails only logs a warning; certbot's exit status doesn't change ([ops.py](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/display/ops.py#L256-L271)).
- **Nothing runs `certbot renew`.** The timer was disabled or masked, a container has neither systemd nor a cron daemon, a pip install never got its crontab line, or a renewal file was renamed out of `*.conf`.
- **A snap refresh changed something.** snapd checks for updates four times a day by default ([snap docs](https://snapcraft.io/docs/how-to-guides/manage-snaps/manage-updates/)), so certbot can change between two unattended runs. For DNS plugins installed as separate snaps, certbot's [instructions](https://certbot.eff.org/instructions?ws=other&os=snap&tab=wildcard) say that if certbot has issues, you may need to repeat `sudo snap set certbot trust-plugin-with-root=ok` and the plugin install.

## Let's Encrypt Changes That Raise the Stakes

Let's Encrypt stopped sending expiration notification emails on June 4, 2025, and deleted the addresses it had stored for them; it recommends a third-party monitoring service instead ([Let's Encrypt](https://letsencrypt.org/2025/06/26/expiration-notification-service-has-ended/)). Nothing warns you before expiry now unless you set it up.

Lifetimes are shrinking too. As of October 25, 2026, the default `classic` profile issues 90-day certificates, the opt-in `tlsserver` profile 45-day ones, and `shortlived` 160-hour ones ([profiles](https://letsencrypt.org/docs/profiles/), updated October 4, 2026). The default drops to 64 days on February 10, 2027 ([announcement](https://letsencrypt.org/2026/10/07/64-day-certs/)) and to 45 days on February 16, 2028, according to the December 2025 [timeline](https://letsencrypt.org/2025/12/02/from-90-to-45/). Each step shortens the time between the first failed renewal and expiry:

| Certificate                                       | Lifetime  | Certbot 4.0+ starts renewing with |
| ------------------------------------------------- | --------- | --------------------------------- |
| `classic` until February 10, 2027                 | 90 days   | 30 days left                      |
| `classic` from February 10, 2027                  | 64 days   | About 21 days left                |
| `tlsserver`, and `classic` from February 16, 2028 | 45 days   | 15 days left                      |
| `shortlived`                                      | 160 hours | 80 hours left                     |

Certificates under 10 days renew at half their lifetime ([renewal.py](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/_internal/renewal.py#L413-L431)). Certbot before 4.0, as in Debian 12 and Ubuntu 24.04, renews 30 days before expiry whatever the lifetime.

## Use Hooks to Stop, Start, and Reload Only When Needed

`certbot renew` runs hooks only when a certificate is due, so they cost nothing on the runs that do nothing ([certbot docs](https://eff-certbot.readthedocs.io/en/stable/using.html#renewing-certificates), [cli help](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/_internal/cli/__init__.py#L410-L451)):

| Hook                                     | Runs                                                                                      | Use it to                               |
| ---------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------- |
| `--pre-hook`, `renewal-hooks/pre/`       | Before renewing, each distinct command once per run                                       | Stop a server that holds port 80        |
| `--post-hook`, `renewal-hooks/post/`     | Once after all attempts, successful or not, with `$RENEWED_DOMAINS` and `$FAILED_DOMAINS` | Start that server again                 |
| `--deploy-hook`, `renewal-hooks/deploy/` | Once per renewed certificate, with `$RENEWED_LINEAGE` and `$RENEWED_DOMAINS`              | Reload the server or copy the new files |

The directories sit under `/etc/letsencrypt/renewal-hooks/`. Certbot runs every executable file in them, in byte order of their names, before any hook given on the command line or saved in a renewal configuration, so one file covers every certificate.

Reload the web server from a deploy hook, which runs only when there's a new certificate to load; a post hook also runs after failed attempts. This hook checks the configuration before reloading and leaves a marker when the reload fails, which the monitoring script below reports:

```bash
#!/bin/sh
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx
# Runs once per renewed certificate; $RENEWED_LINEAGE is its live directory.
nginx -t -q && systemctl reload nginx && exit 0
echo "nginx reload failed after renewing $RENEWED_LINEAGE" >&2
touch /run/certbot-reload-failed
exit 1
```

## Test Renewal Before You Depend on It

```bash
sudo certbot renew --dry-run                      # renew every certificate against staging
sudo certbot renew --dry-run --run-deploy-hooks   # also run deploy hooks (certbot 2.3+)
sudo certbot certificates                         # names, paths, expiry, days left
systemctl list-timers 'certbot*' 'snap.certbot*'  # next and last run
journalctl -u certbot.service --since "14 days ago"   # snap: -u snap.certbot.renew.service
```

`--dry-run` gets test certificates from the Let's Encrypt staging server without saving them. It runs pre and post hooks, and skips deploy hooks unless `--run-deploy-hooks` is given, which runs them against the current certificate ([cli help](https://github.com/certbot/certbot/blob/v5.8.0/certbot/src/certbot/_internal/cli/__init__.py#L165-L186)). With the nginx or apache plugin, it makes temporary configuration changes, reloads the server, and rolls them back. It doesn't test the timer: `sudo systemctl start certbot.service` (or `snap.certbot.renew.service`) runs the real job once. `certbot certificates` prints each certificate's expiry as `VALID: 37 days` or `INVALID: EXPIRED`, and certbot keeps its logs in `/var/log/letsencrypt`.

After a renewal, compare what the server presents with the file on disk. Different dates mean nothing reloaded the server:

```bash
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -enddate
sudo openssl x509 -noout -enddate -in /etc/letsencrypt/live/example.com/cert.pem
```

## Monitor Every certbot renew Run

CronRadar monitors the renewal job. It doesn't check certificate expiry, what your server presents, or whether the site is up; a certificate or uptime monitor does that, and [cron monitoring vs uptime monitoring](https://cronradar.com/blog/cron-monitoring-vs-uptime-monitoring) covers how the two fit together. There's no CronRadar SDK for certbot or systemd and no auto-discovery, so the renewal job reports with its own HTTP check-ins.

Report every run, not just the ones that renew. A run that renews nothing still proves the timer fired and certbot read every renewal configuration, and a check-in from every run is what lets a [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) notice when the runs stop. This script reports both ways: from systemd hooks, or wrapped around the command in cron.

```bash
#!/bin/sh
# /usr/local/bin/certbot-cronradar: report certbot renew runs to CronRadar.
# systemd: "start" from ExecStartPre=, "finish" from ExecStopPost=.
# cron: "run" reports start, runs certbot renew, and reports its result.
url="https://cron.life/api/v1/ping/certbot-renew"
schedule="0 */12 * * *" # UTC: the timer's two daily windows on a UTC host
grace=46800             # the 12-hour random delay plus an hour
run_id=${INVOCATION_ID:-certbot-renew-$(date +%s)-$$}
[ -r /etc/cronradar.env ] && . /etc/cronradar.env # CRONRADAR_API_KEY=...

checkin() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=$schedule" \
    --data-urlencode "gracePeriod=$grace" \
    --data-urlencode "run_id=$run_id" \
    "$@" "$url/$event" || true
}

# Whole days until the soonest certificate on disk expires; 0 if none.
# Needs openssl and GNU date.
days_left() {
  now=$(date +%s)
  min=
  for cert in /etc/letsencrypt/live/*/cert.pem; do
    [ -f "$cert" ] || continue
    end=$(openssl x509 -enddate -noout -in "$cert" | cut -d= -f2)
    days=$(( ($(date -d "$end" +%s) - now) / 86400 ))
    if [ -z "$min" ] || [ "$days" -lt "$min" ]; then
      min=$days
    fi
  done
  echo "${min:-0}"
}

finish() {
  if [ "$1" != success ]; then
    checkin fail --data-urlencode \
      "message=certbot renew failed ($2); see /var/log/letsencrypt"
  elif [ -e /run/certbot-reload-failed ]; then
    checkin fail --data-urlencode \
      "message=a deploy hook failed; reload the server, then delete /run/certbot-reload-failed"
  else
    curl --fail --silent --show-error --max-time 5 --output /dev/null \
      --request POST --user "$CRONRADAR_API_KEY:" \
      --header "Content-Type: application/json" \
      --data "{\"outcomes\":{\"days_left\":$(days_left)}}" \
      "$url/complete?run_id=$run_id" || true
  fi
}

case $1 in
  start) checkin start ;;
  finish) finish "$SERVICE_RESULT" "systemd result $SERVICE_RESULT, ${EXIT_CODE:-} ${EXIT_STATUS:-}" ;;
  run)
    grace=4500 # cron: up to an hour of random sleep runs before this script
    checkin start
    timeout 1h certbot -q renew # a hang becomes exit code 124
    rc=$?
    if [ "$rc" -eq 0 ]; then finish success; else finish failed "exit code $rc"; fi
    exit "$rc"
    ;;
esac
exit 0
```

Attach it to the packaged service with a drop-in, leaving the unit file alone. For the snap, use the directory `/etc/systemd/system/snap.certbot.renew.service.d/`, and after a refresh check that `systemctl cat snap.certbot.renew.service` still lists it.

```ini
# /etc/systemd/system/certbot.service.d/cronradar.conf
[Service]
ExecStartPre=-/usr/local/bin/certbot-cronradar start
ExecStopPost=-/usr/local/bin/certbot-cronradar finish
TimeoutStartSec=1h
```

```bash
sudo install -m 0755 certbot-cronradar /usr/local/bin/certbot-cronradar
sudo install -m 0600 /dev/null /etc/cronradar.env
sudoedit /etc/cronradar.env   # one line: CRONRADAR_API_KEY=ck_app_12345_abcdef
sudo install -D -m 0644 cronradar.conf /etc/systemd/system/certbot.service.d/cronradar.conf
sudo systemctl daemon-reload
sudo systemctl start certbot.service   # one real run; its check-ins create the monitor
```

- **Monitoring can't break renewal.** Each request gives up after five seconds and ignores errors, and the `-` prefixes tell systemd to ignore the script's own failures; [systemd timers](https://cronradar.com/blog/systemd-timers) explains that pattern and `$SERVICE_RESULT`. The key file is root-only, and a missing one only makes the check-ins fail.
- **Results.** `success` sends `complete` with the days left. Any other result, such as `exit-code` or `timeout`, sends `fail`, which opens an incident immediately. A failed reload sends `fail` too, and the marker keeps every later run failing until you delete it or reboot, which empties `/run` ([file-hierarchy(7)](https://www.freedesktop.org/software/systemd/man/latest/file-hierarchy.html)).
- **Hangs.** A oneshot service has no start timeout by default ([systemd.service(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html)), so a hung certbot would block every later run. `TimeoutStartSec=1h` makes systemd kill it and report `timeout`; set it well above your slowest run, since some DNS plugins wait minutes for propagation.
- **The monitor.** The first `start` creates it with the schedule and grace period. Later check-ins don't change those, so edit them in the monitor's settings. Give each server its own monitor key in `url`, such as `certbot-renew-web1`, and use one CronRadar application and API key per environment.

### Choosing the Schedule and Grace Period

With a random delay, nothing says when a run is due, only which 12-hour window it falls in. The script sets the schedule to the window starts, `0 */12 * * *`, and a grace period of 46,800 seconds: the 12-hour delay, the up-to-a-minute `AccuracySec=` default ([systemd.timer(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html)), and margin. The snap's times fall inside the same windows, so the same values work.

The trade-off is that the monitor sits in `warning` from the start of each window until that window's run reports, on average half of each window with the Debian timer's evenly distributed delay. `warning` is a status, not an alert. A window that ends without a run goes `critical` about an hour after it closes, so a stopped timer is flagged within about 25 hours of the last good run, well inside a 15- or 30-day margin. [Choosing a grace period](https://cronradar.com/blog/cron-grace-period) covers sizing it for other jobs.

CronRadar reads schedules in UTC, and the timer's windows open at local midnight and noon. On a host at UTC-5 without daylight saving, the schedule is `0 5,17 * * *`; in a zone with daylight saving, use the earlier of the two UTC times and add an hour to the grace. Edit `schedule` and `grace` in the script before its first run.

### Reporting Days Until Expiry

`days_left` catches what the exit code can't: a certificate under `/etc/letsencrypt/live` that certbot no longer renews, because its renewal file was removed or renamed, or because it sets `autorenew = False`. Retire a certificate with `certbot delete --cert-name` rather than by deleting its renewal file, or the check will flag it. In the automation's settings in CronRadar, under Outcome checks, add `days_left >= 14`. That suits 90- and 64-day certificates; for 45-day ones, which certbot renews with about 15 days left, use 7. A missing or violated value opens an incident. [Scheduled job silent success](https://cronradar.com/blog/scheduled-job-silent-success) covers outcome checks in general, and the [API reference](https://docs.cronradar.com/api-reference) the request rules.

This reads the files on disk, not what the server presents. A server that was never reloaded passes it while serving an older certificate, which is why the deploy hook marker exists and why a served-certificate check belongs in a certificate or uptime monitor.

### pip Installs: Report From Cron

Replace the `/etc/crontab` line from certbot's pip instructions with one that keeps the random sleep and runs the script:

```text
0 0,12 * * * root /opt/certbot/bin/python -c 'import random; import time; time.sleep(random.random() * 3600)' && /usr/local/bin/certbot-cronradar run
```

In `run` mode the grace is 4,500 seconds, enough for the hour of sleep; GNU `timeout` turns a hang into a `fail`, and the script exits with certbot's own status. The same UTC rule applies to the schedule.

## Frequently Asked Questions

### How do I check if certbot auto-renewal is working?

Run `sudo certbot renew --dry-run` to test a full renewal against the staging server, `systemctl list-timers` to see the timer's last and next run, and `sudo certbot certificates` for days left. Those show the state today; a monitor that receives a check-in from every run shows when renewal breaks.

### Does certbot renew automatically?

Yes for the snap and the Debian and Ubuntu packages, which install a timer that runs `certbot renew` twice a day. A pip install needs a cron entry you add yourself. Each run renews only certificates near expiry, so most runs do nothing.

### Why did my Let's Encrypt certificate expire?

Either renewal had been failing for weeks while the old certificate still worked, for example because port 80 was blocked, a DNS API token expired, or the timer stopped, or the renewal succeeded on disk but the server was never reloaded. Compare `certbot certificates` with what `openssl s_client` shows for the site.

### How do I reload nginx after certbot renews?

Put an executable script that runs `nginx -t && systemctl reload nginx` in `/etc/letsencrypt/renewal-hooks/deploy/`. Certbot runs it once per renewed certificate and never on runs that renew nothing. Certificates obtained with `--nginx` are reloaded by certbot itself.

### Do I still get Let's Encrypt expiration emails?

No. Let's Encrypt ended expiration notification emails on June 4, 2025. It suggests a third-party certificate monitoring service; to know the renewal job itself still runs, monitor the job.
