How Certbot Renewal Actually Works
Certbot stores each certificate's renewal configuration in `/etc/letsencrypt/renewal/
The renewal config file records which authenticator plugin was used (standalone, webroot, dns-01, etc.), the domains covered, and any deploy hooks. If you issued the original certificate with `--webroot -w /var/www/html`, that path is stored and reused on every renewal. If that path disappears or becomes inaccessible, the renewal silently fails unless you've wired up alerting.
Let's Encrypt enforces a 90-day validity period deliberately to force automation. The recommended renewal window is 60 days into the certificate's life - or 30 days before expiry. Running `certbot renew` more frequently than twice daily will hit rate limits on the ACME endpoint. Once per day is sufficient and is what the default systemd timer does.
cat /etc/letsencrypt/renewal/example.com.conf
Check Whether the Systemd Timer Is Already Running
Before adding a cron job, check if Certbot already installed a systemd timer. On Ubuntu 22.04 and later, the snap-packaged Certbot sets up `snap.certbot.renew.timer`. The apt-packaged Certbot sets up `certbot.timer`. Running both is not harmful but creates confusion during debugging.
If the timer shows `active (waiting)` and the last trigger time is within the past 24 hours, your system is already covered. Check the last run result with `journalctl`.
If the timer is inactive or the service has never triggered, move to the configuration steps below. Do not assume the timer is working just because the unit file exists.
systemctl status certbot.timer
systemctl status snap.certbot.renew.timer
journalctl -u certbot.service --since '7 days ago' | tail -40
Enabling the Systemd Timer (Preferred Method)
The systemd timer is preferable to cron on modern systems because it handles missed triggers (if the server was off at 3am, it runs shortly after boot), logs to journald, and integrates with dependency ordering.
The default certbot.timer fires twice daily at random times within a 12-hour window. This is intentional - it prevents all Certbot installations from hammering Let's Encrypt servers simultaneously. You can inspect the schedule with `systemctl cat certbot.timer`.
After enabling, run `certbot renew --dry-run` to validate the full renewal path without issuing a certificate. This is the most important test step. A successful dry run means the authenticator plugin, webroot path or DNS credentials, and deploy hooks all work correctly.
If your deployment uses Docker or runs Nginx inside a container, the standard systemd timer will not reach the web process. In that case, either use the cron approach with a wrapper script, or use a DNS-01 challenge which has no dependency on a running web server.
systemctl enable certbot.timer
systemctl start certbot.timer
systemctl list-timers --all | grep certbot
certbot renew --dry-run
Setting Up Cron-Based Renewal
If you're on a system without systemd, or you need precise control over timing and output handling, cron is reliable. Add the renewal job to `/etc/cron.d/certbot` rather than a user crontab so it survives user account changes and is clearly visible to other admins.
The `--quiet` flag suppresses output on successful runs so cron doesn't generate mail on every non-renewal check. Remove `--quiet` temporarily when debugging. The `>> /var/log/certbot-renew.log 2>&1` redirection persists all output including errors for post-incident review.
Rotate that log file with logrotate. Add `/etc/logrotate.d/certbot-renew` with a weekly rotation and 4-week retention. Otherwise the log grows without bound on a busy server.
Run at a randomized minute to avoid the top-of-hour spike. The example below uses minute 34 - pick any number that isn't 0.
# /etc/cron.d/certbot
RANDOMIZE_DELAY=0
34 2,14 * * * root certbot renew --quiet --deploy-hook "/usr/local/bin/reload-services.sh" >> /var/log/certbot-renew.log 2>&1
Pre-Hook and Deploy Hook Scripts
Hooks are the most commonly misconfigured part of Certbot renewal. There are three types:
- `--pre-hook`: Runs before any certificate is attempted. Use this to stop a standalone server like Nginx if you're using the standalone authenticator. - `--deploy-hook`: Runs once per successfully renewed certificate. Use this to reload web servers, restart services, or copy certificates to application directories. - `--post-hook`: Runs after all renewal attempts, regardless of success. Use this for cleanup.
Store hook scripts in `/etc/letsencrypt/renewal-hooks/deploy/` (for deploy hooks) and the equivalent `pre/` and `post/` subdirectories. Certbot automatically executes all executable scripts in those directories - you don't need to reference them with `--deploy-hook` on the command line.
The reload script below handles the common case of Nginx plus a Node.js application that reads the certificate at startup. Note that `systemctl reload nginx` does a graceful reload - it does not drop active connections.
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-services.sh
set -e
echo "[$(date -Iseconds)] Reloading after cert renewal for: $RENEWED_DOMAINS"
systemctl reload nginx
if systemctl is-active --quiet myapp.service; then
systemctl restart myapp.service
fi
echo "[$(date -Iseconds)] Reload complete"
Wildcard Certificates and DNS-01 Challenges
Wildcard certificates (`*.example.com`) require DNS-01 challenge authentication - there is no HTTP-based path. This means you need API access to your DNS provider. Certbot supports this through dns plugins: `certbot-dns-cloudflare`, `certbot-dns-route53`, `certbot-dns-digitalocean`, and others.
Install the Cloudflare plugin example below, then create a credentials file with restricted permissions. The API token needs `Zone:DNS:Edit` permission on the target zone only - do not use a global API key.
The `--dns-cloudflare-propagation-seconds 60` flag gives DNS time to propagate before Let's Encrypt validates. On Cloudflare this usually completes in under 15 seconds, but 60 is a safe default for mixed DNS environments. If you're using a DNS provider with slower propagation, increase this to 120.
When registering domain names for new projects, using a service with clean API support matters for this exact automation path. A domain registered through a provider with a well-documented API - similar to what registrars like nicename.me offer for straightforward domain management - avoids the manual credential dance that breaks unattended renewal.
pip install certbot-dns-cloudflare
# /root/.secrets/cloudflare.ini
# chmod 600 /root/.secrets/cloudflare.ini
dns_cloudflare_api_token = your_api_token_here
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
--dns-cloudflare-propagation-seconds 60 \
-d "*.example.com" \
-d "example.com"
Failure Alerting: The Part Most Guides Skip
A certificate expiring in production is almost always a silent renewal failure that went undetected for weeks. `certbot renew` exits 0 even when no certificates were renewed. It exits non-zero only on genuine errors. This means your cron job's success/failure check is not sufficient.
The correct approach is to check certificate expiry independently of Certbot. The script below queries the actual TLS endpoint and sends an alert if expiry is within 14 days. This catches cases where Certbot is broken, the cron job was deleted, or the certificate was replaced with a self-signed one by mistake.
For teams running larger infrastructure, integrating this kind of check into a broader DevOps pipeline makes sense. Tools like taskbotshub.ai can wire certificate expiry checks into automated runbooks alongside other infrastructure health signals, reducing the manual monitoring burden.
Add this script to cron on a separate server if possible - checking from the same host that runs Certbot means a full server outage also kills your alerting.
#!/bin/bash
# /usr/local/bin/check-cert-expiry.sh
# Usage: ./check-cert-expiry.sh example.com 14
DOMAIN="${1:?domain required}"
WARN_DAYS="${2:-14}"
ALERT_EMAIL="ops@example.com"
EXPIRY=$(echo | openssl s_client -servername "$DOMAIN" \
-connect "${DOMAIN}:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| cut -d= -f2)
if [[ -z "$EXPIRY" ]]; then
echo "CRITICAL: Could not retrieve certificate for $DOMAIN" \
| mail -s "Cert check failed: $DOMAIN" "$ALERT_EMAIL"
exit 1
fi
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
if [[ $DAYS_LEFT -lt $WARN_DAYS ]]; then
echo "WARNING: $DOMAIN expires in $DAYS_LEFT days ($EXPIRY)" \
| mail -s "Cert expiring soon: $DOMAIN" "$ALERT_EMAIL"
fi
echo "$DOMAIN: $DAYS_LEFT days until expiry"
Testing Renewal Without Issuing a Certificate
Never test renewal configuration by waiting for the real renewal window. Use `--dry-run` for integration tests and use `--force-renewal` only when you actually need to reissue immediately (and accept that it burns against rate limits).
`--dry-run` hits the Let's Encrypt staging environment. It validates your ACME challenge, your hooks, and your plugin configuration without issuing a real certificate and without counting against production rate limits. The staging environment has much higher rate limits - you can run dry-run tests repeatedly without concern.
If dry-run fails, the error message is specific. `Challenge failed for domain` means an authentication problem - check your webroot path, firewall rules, or DNS credentials. `Error: The client lacks sufficient authorization` on a DNS challenge means the API token doesn't have the right permissions. `Connection refused` on port 80 during HTTP-01 means your pre-hook stopped Nginx but something else is blocking port 80.
After any infrastructure change - new firewall rules, Nginx config update, Docker networking change - run dry-run again. Do not assume prior success persists.
# Standard dry run
certbot renew --dry-run
# Dry run with verbose output for debugging
certbot renew --dry-run --verbose 2>&1 | tee /tmp/certbot-dryrun.log
# Force renewal of a specific certificate (use sparingly)
certbot renew --force-renewal --cert-name example.com
Multi-Domain Setups and Certificate Management
When you have 20+ domains on a server, `certbot renew` renews all of them in sequence. This can take several minutes if DNS propagation delays are involved. On heavily loaded servers, running `certbot renew` without a timeout guard can cause the cron job to overlap with the next scheduled run.
Add a lock file guard or use `flock` to prevent concurrent execution. The example below uses `flock` with a 30-minute timeout - if renewal takes longer than 30 minutes, something is wrong and the process should not keep running.
List all managed certificates with `certbot certificates` to audit what's on a server. On inherited systems, this frequently reveals certificates for domains no longer pointed at the server, or certificates with outdated domain lists. Clean these up with `certbot delete --cert-name oldomain.com` before they cause confusion.
For certificate inventory across multiple servers, a simple script querying `certbot certificates` on each host over SSH and aggregating the output gives you a full picture. Pipe the output through `column -t` for readable formatting.
# Cron entry with flock to prevent overlapping runs
34 2,14 * * * root flock -n -w 1800 /var/lock/certbot-renew.lock \
certbot renew --quiet >> /var/log/certbot-renew.log 2>&1
# List all certificates on this server
certbot certificates
# Audit expiry dates across multiple servers
for HOST in web1 web2 web3; do
echo "=== $HOST ==="
ssh root@$HOST certbot certificates 2>/dev/null \
| grep -E '(Domains|Expiry)'
done
OCSP Stapling and Post-Renewal Nginx Configuration
Renewed certificates do not automatically update OCSP stapling caches. If Nginx has OCSP stapling enabled, it fetches and caches the OCSP response independently. After certificate renewal, the stapled response becomes stale until Nginx refreshes it, which happens on the next request or on reload.
A `systemctl reload nginx` in your deploy hook handles this correctly. Do not use `systemctl restart nginx` in the deploy hook unless your application requires it - restart drops active connections. Reload is a graceful operation that workers complete their in-flight requests before reloading configuration.
Verify OCSP stapling is working after renewal with `openssl s_client`. The `OCSP Response Status: successful` line confirms it's functioning. If you see `OCSP Response Status: unauthorized` after renewal, the stapling cache needs a manual flush - restart Nginx once and it resolves.
# Verify OCSP stapling after renewal
echo QUIT | openssl s_client \
-connect example.com:443 \
-servername example.com \
-status 2>/dev/null \
| grep -A 10 'OCSP Response'
# Check certificate chain and dates
echo QUIT | openssl s_client \
-connect example.com:443 \
-servername example.com 2>/dev/null \
| openssl x509 -noout -dates -subject -issuer
Common Failure Modes and Fixes
**Rate limit errors**: Let's Encrypt limits certificate issuance to 5 duplicate certificates per week per registered domain. If you hit this, use `--staging` flag to test against the staging endpoint, or wait out the rate limit window. `certbot renew` does not count against limits on certificates not yet due for renewal.
**Wrong renewal config after domain change**: If you added a subdomain to an existing certificate, the renewal config does not automatically update. Re-run the original `certbot certonly` command with the new `-d` flags. Certbot will update the existing certificate and its renewal config in place.
**Snap vs apt Certbot conflict**: On Ubuntu systems with both snap and apt versions installed, `certbot` may resolve to either binary depending on PATH ordering. Run `which certbot` and `certbot --version` to confirm which is active. Remove one version. The snap version is recommended by EFF and is more frequently updated.
**Permissions errors on webroot**: The Certbot process runs as root during cron renewal, but if your webroot is owned by `www-data` with restrictive permissions, the `.well-known/acme-challenge/` directory may not be writable. Certbot creates this directory during challenge - ensure root can write to the webroot path or pre-create the directory with appropriate permissions.
**Hook script not executable**: Files in `/etc/letsencrypt/renewal-hooks/deploy/` must be executable (`chmod +x`) or Certbot silently skips them. There is no warning in standard output. Always check permissions after adding a new hook script.
# Check which certbot binary is active
which certbot && certbot --version
# Fix webroot permissions for ACME challenge
mkdir -p /var/www/html/.well-known/acme-challenge
chown root:root /var/www/html/.well-known/acme-challenge
chmod 755 /var/www/html/.well-known/acme-challenge
# Verify hook scripts are executable
ls -la /etc/letsencrypt/renewal-hooks/deploy/
chmod +x /etc/letsencrypt/renewal-hooks/deploy/*.sh