Certbot on Debian 13: Renewals for 45-Day Certs

11 min read

Installing certbot on Debian 13 is easy. apt install certbot gives you a systemd timer that tries to renew twice a day, and most guides end there. The problems show up later. A certificate gets renewed on disk but nginx keeps serving the old one. A renewal fails and nobody notices, because Let's Encrypt stopped sending expiration emails on June 4, 2025. Certificate lifetimes are also getting shorter, so a renewal setup that has worked for years may soon have less room for error.

This tutorial builds a certbot setup on Debian 13 that handles those cases: the packaged timer, a deploy hook that runs nginx -t before it reloads, alerts through OnFailure=, expiry checks from outside the server, key permissions for a non-root service, DNS-01 for wildcards, and a checklist for 64-day and 45-day certificates. Each step ends with a check.

What we build and prerequisites

You need a Debian 13 (trixie) server running nginx, a DNS name pointing at it, port 80 reachable for HTTP-01 (or DNS API access for DNS-01), and root access. The versions below come from Debian's package pages and manpages.debian.org:

ComponentDebian 13 versionWhy it matters
certbot4.0.0-2+deb13u1Renews when 1/3 of the lifetime is left. No ARI support (ARI arrived in 4.1.0).
systemd257.13Timer, OnFailure=, drop-ins
OpenSSL3.5.7 (3.5.7-1~deb13u3)s_client and x509 -checkend for expiry checks

When I wrote this, Debian testing had certbot 5.5.0-2. The package tracker listed no trixie-backports build.

Step 1: Install certbot and inspect the Debian timer

apt update
apt install certbot nginx
certbot --version
systemctl cat certbot.timer certbot.service

Debian's units look like this:

[Unit]
Description=Run certbot twice daily

[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=43200
Persistent=true

[Install]
WantedBy=timers.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew --no-random-sleep-on-renew
PrivateTmp=true

The timer fires at 00:00 and 12:00 and adds a random delay of up to 12 hours. Persistent=true catches up on runs missed while the machine was off. The package also installs /etc/cron.d/certbot, but that job only runs when systemd is not PID 1. Its comment still says renewal happens "within 30 days" of expiry. Since 4.0.0 that is out of date: certbot renews when one third of the lifetime is left, or one half if the lifetime is shorter than 10 days.

Check that the timer is active:

systemctl list-timers certbot.timer

Step 2: Serve the HTTP-01 webroot from nginx

I use the webroot authenticator so that certbot never edits nginx config. Only the hook below touches nginx.

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

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
        default_type "text/plain";
    }

    location / {
        return 301 https://$host$request_uri;
    }
}
mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
echo ok > /var/www/letsencrypt/.well-known/acme-challenge/probe
nginx -t
systemctl reload nginx
curl -i http://example.com/.well-known/acme-challenge/probe

Check: you should get 200 OK with body ok, not a 301. If you get a redirect, another location block is catching the request first. Delete the probe file when you're done.

Step 3: Issue the certificate

certbot certonly --webroot -w /var/www/letsencrypt -d example.com -d www.example.com --email admin@example.com --agree-tos --no-eff-email
certbot certificates

certbot certificates lists each lineage with its expiry and paths:

Certificate Name: example.com
  Domains: example.com www.example.com
  Expiry Date: ... (VALID: 89 days)
  Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem
  Private Key Path: /etc/letsencrypt/live/example.com/privkey.pem

Point your HTTPS server block at /etc/letsencrypt/live/example.com/fullchain.pem and privkey.pem, then run nginx -t and reload. For TLS settings and headers, see web server security basics for self-hosters.

Step 4: A deploy hook that tests nginx before reloading

Certbot runs executables from three directories, in alphabetical order:

  • /etc/letsencrypt/renewal-hooks/pre: before a renewal attempt
  • /etc/letsencrypt/renewal-hooks/deploy: once per certificate that was issued successfully
  • /etc/letsencrypt/renewal-hooks/post: after attempts, whether they succeeded or failed

Since certbot 3.2.0 these directory hooks run for every subcommand, not just renew. Deploy hooks get RENEWED_LINEAGE (the live directory) and RENEWED_DOMAINS.

#!/bin/bash
# Runs once per successfully issued or renewed certificate.
set -u

logger -t certbot-deploy "deployed ${RENEWED_LINEAGE:-?} (${RENEWED_DOMAINS:-?})"

if ! nginx -t -q; then
    logger -p user.err -t certbot-deploy "nginx -t failed, not reloading"
    systemctl start --no-block certbot-alert@certbot.service.service
    exit 1
fi

systemctl reload nginx
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/10-nginx-reload
RENEWED_LINEAGE=/etc/letsencrypt/live/example.com RENEWED_DOMAINS=example.com /etc/letsencrypt/renewal-hooks/deploy/10-nginx-reload
journalctl -t certbot-deploy -n 5

Use reload, not restart. A reload keeps existing connections. If the new config is broken, the old workers keep running, but the nginx -t gate means you shouldn't get that far. The alert unit the hook calls is set up in Step 6.

Step 5: Dry-run the renewal and check the timer

certbot renew --dry-run --run-deploy-hooks
systemctl list-timers certbot.timer
journalctl -u certbot.service -n 20 --no-pager

--dry-run runs against the Let's Encrypt staging server and doesn't save the test certificates. Deploy hooks are skipped during a dry run unless you add --run-deploy-hooks, which is why it's in the command above. Check that journalctl -t certbot-deploy shows a new entry and that list-timers shows a NEXT time.

Step 6: Alert on failed renewals with OnFailure=

certbot renew exits with 1 only when a renewal attempt failed, so a failure leaves certbot.service in the failed state. OnFailure= starts other units when that happens. You need a template alert unit and a script. The script below uses mail, so you need bsd-mailx or mailutils plus a working MTA. You can swap in a webhook instead.

#!/bin/bash
set -u
unit="$1"
journalctl -u "$unit" -n 40 --no-pager | mail -s "[$(hostname -f)] $unit failed" root
[Unit]
Description=Alert on failure of %i

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/certbot-alert %i
[Unit]
OnFailure=certbot-alert@%n.service
chmod 0755 /usr/local/sbin/certbot-alert
systemctl daemon-reload
systemctl show certbot.service -p OnFailure

Test with a forced failure. The next drop-in replaces certbot's command with /bin/false. Remove it right after the test, or renewals will stop.

[Service]
ExecStart=
ExecStart=/bin/false
systemctl daemon-reload
systemctl start certbot.service
journalctl -u certbot-alert@certbot.service.service -n 10 --no-pager
rm /etc/systemd/system/certbot.service.d/zz-failtest.conf
systemctl daemon-reload
systemctl reset-failed certbot.service
systemctl cat certbot.service

systemctl start returning an error is expected here. Check that the mail arrived and that systemctl cat no longer shows /bin/false.

If you want to sandbox certbot.service further, the approach in sandboxing systemd services on Debian 13 applies, with one catch. Certbot writes to /etc/letsencrypt, /var/lib/letsencrypt and /var/log/letsencrypt, and the hook calls systemctl reload. Lock it down too far and renewals or the reload will break, so run another dry run after any change.

Step 7: Let a non-root service read the private key

nginx's master process runs as root and reads the key itself. Other daemons often don't, such as a mail server or an app running as its own user. Certbot creates live and archive with mode 0700, and keys default to 0600. The certbot docs say you can open the directories with chmod 0755 as long as you never downgrade to an older certbot. They also say a key's group and group mode are kept on renewal.

groupadd --system tlsread
usermod -aG tlsread myapp
chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive
chgrp tlsread /etc/letsencrypt/archive/example.com/privkey*.pem
chmod 0640 /etc/letsencrypt/archive/example.com/privkey*.pem
sudo -u myapp test -r /etc/letsencrypt/live/example.com/privkey.pem && echo readable

Restart myapp so it picks up the new group membership. Change the files in archive/: the paths under live/ are symlinks to them. After the next real renewal, run ls -l /etc/letsencrypt/archive/example.com/ and check that the newest privkeyN.pem still has group tlsread. Also add the app's reload command to a deploy hook, or the app will keep the old certificate in memory.

Step 8: Wildcards with DNS-01 and a scoped token

Wildcard certificates need the DNS-01 challenge. Debian packages several DNS plugins. This example uses Cloudflare:

apt install python3-certbot-dns-cloudflare
install -m 0600 /dev/null /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = REPLACE_WITH_TOKEN
certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini --dns-cloudflare-propagation-seconds 30 -d example.com -d '*.example.com'

The plugin docs say the token needs Zone:DNS:Edit only for the zones you issue for. Don't use a global API key. Let's Encrypt points out that a DNS API credential on a web server makes a compromise of that server much worse. Their suggested mitigations are a narrowly scoped token, running DNS validation on a separate host and copying certificates over, or delegating _acme-challenge with a CNAME to a zone used only for validation. Check whether your plugin follows such a CNAME before you rely on it. If the file is readable by other users, the plugin warns about unsafe permissions on every renewal, so keep it at 0600.

Step 9: Monitor expiry from outside

A renewed file on disk doesn't prove nginx is serving it. Compare the two:

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

If the two notAfter dates differ, the reload didn't happen. Run a check like the one below from a different machine, from cron or a timer with the same OnFailure= pattern:

#!/bin/bash
set -u
days="${DAYS:-10}"
status=0
for host in "$@"; do
    if ! openssl s_client -connect "${host}:443" -servername "$host" </dev/null 2>/dev/null | openssl x509 -noout -checkend $((days * 86400)) >/dev/null; then
        echo "$host: expires within ${days} days or check failed"
        status=1
    fi
done
exit "$status"

-checkend exits non-zero if the certificate expires within the given number of seconds. Pick a threshold below certbot's renewal point. With 45-day certificates certbot renews at 15 days left, so an alert at 10 days still leaves time to fix things.

Preparing certbot on Debian 13 for shorter lifetimes

Let's Encrypt's published schedule: on February 10, 2027 the default classic profile moves to 64-day certificates with 10-day authorization reuse. On February 16, 2028 it moves to 45-day certificates with 7-hour reuse. The opt-in tlsserver profile switched to 45 days on May 13, 2026. A shortlived profile (160 hours) is available now if you ask for it. You may see other figures quoted, such as 47 days or 6 days in 2028. Go by the CA's own announcement.

LifetimeWhencertbot 4.0 renews withRenewal around
90 daysclassic, today30 days left (1/3)day 60
64 daysclassic from 2027-02-1021 days 8 hours left (1/3)day 42.7
45 daystlsserver now; classic from 2028-02-1615 days left (1/3)day 30
160 hoursshortlived, opt-in80 hours left (1/2)hour 80

Let's Encrypt recommends ARI so the CA can tell clients when to renew, for example after a mass revocation. Debian 13's certbot 4.0.0 doesn't support ARI: it was added in 4.1.0, and in 4.1.0 an early ARI window also overrides a later renew_before_expiry. Until a newer certbot reaches trixie, renewal timing on Debian 13 comes from the 1/3 rule alone.

For 45-day certificates the twice-daily timer leaves plenty of retries. For shortlived certificates, or just faster retries after a failure, run it more often:

[Timer]
OnCalendar=
OnCalendar=*-*-* 00/6:00:00
RandomizedDelaySec=1h
systemctl daemon-reload
systemctl list-timers certbot.timer

The empty OnCalendar= clears Debian's schedule before the new one is added. To try 45-day certificates now, issue a test lineage with --preferred-profile tlsserver. On 4.0.0 the profile isn't saved to the renewal config (that started in 4.1.0), so treat it as a one-off test.

Common pitfalls

  • Hook not executable. Certbot only runs executable files from renewal-hooks. Check with ls -l.
  • Hard-coded renew_before_expiry. Run grep -r renew_before_expiry /etc/letsencrypt/renewal/. An old fixed value overrides the 1/3 rule. The certbot docs advise against editing these files by hand, so re-issue with the options you want instead.
  • Two certbots. A snap or pip install next to the Debian package means two timers and two sets of hooks. Keep one.
  • Port 80 closed. HTTP-01 needs it. With 7-hour authorization reuse from 2028, almost every renewal will validate again, so firewall changes will break renewals sooner than they do now.
  • Relying on CA emails. Let's Encrypt no longer sends expiry emails. Your own checks from Step 9 replace them.
  • Services that cache certificates. Every daemon that uses the certificate needs a reload in a deploy hook, not just nginx.

Takeaways

  • systemctl list-timers certbot.timer shows a next run. Debian 13 ships certbot 4.0.0, which renews at 1/3 of the lifetime left.
  • A deploy hook in /etc/letsencrypt/renewal-hooks/deploy/ runs nginx -t before systemctl reload nginx.
  • certbot renew --dry-run --run-deploy-hooks passes.
  • OnFailure= on certbot.service has been tested with a forced failure, and the test drop-in has been removed.
  • Expiry is checked from outside, and the served certificate matches the one on disk.
  • Non-root key readers use a dedicated group and 0640 on archive/ keys.
  • DNS-01 tokens are limited to Zone:DNS:Edit on the needed zones, in a 0600 file.
  • Before February 10, 2027, you've tested a tlsserver certificate, checked for fixed renew_before_expiry values, and decided whether the timer needs to run more often.

Sources

Comments