Auto-renew SSL certificates with Certbot and cron on Linux
Set up automatic SSL renewal on Linux with Certbot: systemd timer vs cron, Nginx reload hooks, expiration monitoring and production troubleshooting.
Let’s Encrypt certificates have a 90-day validity period — an intentional decision by the project to force automation and reduce the exposure window in case of compromise. Manual renewal every 60-80 days is unsustainable in production: a trip, an overloaded on-call rotation or a forgotten server is all it takes for TLS to expire and the site to start returning ERR_CERT_DATE_INVALID in customer browsers.
This tutorial is for sysadmins and developers who already have Certbot installed and at least one issued certificate, and want to ensure renewal runs without intervention, reloads the web server without downtime and alerts them if something breaks. We’ll cover both approaches — classic cron and systemd timer — when to pick each, configure deploy hooks for Nginx/Apache reload and set up basic monitoring.
Estimated execution time: 15 to 25 minutes, depending on how many domains you manage and whether you’ll configure custom alerts.
Prerequisites
Ubuntu 22.04 LTS or higher (or Debian 11+, Rocky 9+), Certbot 2.0+ installed, at least one certificate already issued in /etc/letsencrypt/live/, sudo access and Nginx or Apache serving the domain. Port 80 must be open in the firewall for the HTTP-01 challenge (or DNS configured for DNS-01).
Confirm the Certbot version and the presence of at least one certificate before proceeding:
certbot --version
sudo certbot certificates
The certbot certificates output should list each certificate with its name, covered domains and expiration date. If nothing appears, issue a first certificate before setting up automation — this documentation assumes the initial issuance step has already been done.
/etc/letsencrypt/live/ /etc/letsencrypt/renewal-hooks/ /etc/letsencrypt/renewal/ /var/log/letsencrypt/ Checking if the systemd timer is already active
On modern distros the certbot package automatically installs a systemd unit that handles renewal — before touching cron, confirm whether it’s already running. Most Ubuntu 22.04+ installations don’t need any manual cron at all.
List active timers and look for certbot:
systemctl list-timers --all | grep -i certbotIf you see a line like certbot.timer ... loaded active waiting, the timer is already scheduled. The next execution appears in the NEXT column. Skip to the deploy hook section if that’s the case.
Inspect the timer configuration to understand the frequency:
systemctl cat certbot.timerThe default unit fires twice a day (00:00 and 12:00) with RandomizedDelaySec=43200 — meaning a random delay of up to 12 hours. This spreads renewals across all Let’s Encrypt users worldwide, avoiding load concentration.
Check the service the timer triggers:
systemctl cat certbot.serviceThe typical ExecStart is /usr/bin/certbot -q renew — the -q (quiet) flag suppresses output for “nothing to renew” cases, avoiding log noise. When a renewal does happen, the full output goes to the journal.
Configuring renewal via traditional cron
If you’re on an older distro, a server without systemd, or you simply prefer cron, use this approach. On modern distros, disable the timer first to avoid two concurrent executions.
Disable the systemd timer if you’ll use cron:
sudo systemctl disable --now certbot.timerThis command stops the timer and removes the symlink that activates it at boot. You can re-enable it at any time with enable --now.
Edit root’s crontab and add the renewal entry:
sudo crontab -eAdd:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3,15 * * * /usr/bin/certbot renew --quietThis line runs at 03:00 and 15:00 every day. Certbot only renews certificates with less than 30 days remaining — running twice a day guarantees a second attempt if the first fails due to temporary instability. The explicit PATH is mandatory: cron inherits a minimal environment, and commands without absolute paths fail silently.
Confirm the cron entry was registered:
sudo crontab -lThe line should appear. Cron reads the file automatically — no service needs to be restarted.
Avoid scheduling renewals at round times like 00:00 or 12:00 without jitter — thousands of servers do the same and the Let’s Encrypt API may rate-limit. Use odd times (03:17, 15:43) or add sleep $((RANDOM \% 3600)) before certbot to randomize.
Configuring a deploy hook for reload without downtime
Renewing the certificate isn’t enough — Nginx or Apache keeps using the old cert loaded in memory until it receives a reload signal. Deploy hooks solve this elegantly.
Create the Nginx reload hook:
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/bash
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.shAny executable script in /etc/letsencrypt/renewal-hooks/deploy/ runs after a successful renewal — and only then. Unlike --post-hook, which always runs, the deploy hook is skipped if no certificate was actually renewed, avoiding unnecessary Nginx reloads.
For Apache, use the equivalent:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh > /dev/null <<'EOF'
#!/bin/bash
systemctl reload apache2
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-apache.shsystemctl reload (not restart) reloads the configuration without dropping active connections — existing workers finish their requests while new workers already use the renewed cert.
Test the full pipeline in dry-run mode:
sudo certbot renew --dry-run--dry-run simulates a renewal against the Let’s Encrypt staging server — it doesn’t consume quota, doesn’t replace the real certificate, but executes all hooks. Check the output: at the end, Congratulations, all simulated renewals succeeded should appear and the hook should have run.
If you run Nginx and HAProxy on the same server and each uses different certificates, configure the hook inside the specific renewal file at /etc/letsencrypt/renewal/domain.conf in the [renewalparams] section with renew_hook = systemctl reload haproxy. Hooks in the global directory run for every renewed cert.
Monitoring renewals
Automation without observability is a trap — finding out the cert expired through a browser error is the worst-case scenario. Set up an active check that warns you in advance.
Create an expiration check script:
sudo tee /usr/local/bin/check-ssl-expiry.sh > /dev/null <<'EOF'
#!/bin/bash
THRESHOLD_DAYS=20
ALERT_EMAIL="[email protected]"
for cert in /etc/letsencrypt/live/*/cert.pem; do
domain=$(basename "$(dirname "$cert")")
end_date=$(openssl x509 -enddate -noout -in "$cert" | cut -d= -f2)
end_epoch=$(date -d "$end_date" +%s)
now_epoch=$(date +%s)
days_left=$(( (end_epoch - now_epoch) / 86400 ))
if [ "$days_left" -lt "$THRESHOLD_DAYS" ]; then
echo "ALERT: $domain expires in $days_left days" | \
mail -s "SSL expiring: $domain" "$ALERT_EMAIL"
fi
done
EOF
sudo chmod +x /usr/local/bin/check-ssl-expiry.shSchedule the script to run daily via cron or a systemd timer. If a certificate enters the under-20-days zone, you get an email — meaning automatic renewal failed and manual investigation is needed.
Schedule the daily check:
echo "30 8 * * * /usr/local/bin/check-ssl-expiry.sh" | sudo tee -a /etc/crontabRuns every morning at 08:30. Combine it with alert integration (Slack webhook, Discord, or your metrics system) by replacing the mail call with curl to the webhook.
Final verification
Run the commands below to confirm the whole setup is active:
sudo certbot renew --dry-run
systemctl list-timers --all | grep certbot
ls -la /etc/letsencrypt/renewal-hooks/deploy/
sudo /usr/local/bin/check-ssl-expiry.sh
The dry-run should pass without errors. The timer (if you chose systemd) should be active. Hooks must be executable. The check script should not emit an alert — if it does, the cert is in a critical window and you need to investigate logs at /var/log/letsencrypt/letsencrypt.log.
Troubleshooting
”Failed authorization procedure” on renew
Common cause: port 80 blocked by firewall or redirected to 443 without an exception for the /.well-known/acme-challenge/ path. Certbot needs to serve the HTTP-01 challenge on that route. Confirm with curl http://your-domain/.well-known/acme-challenge/test — it should return a 404 from your server, not a connection error. If you force a global redirect to HTTPS, add an exception for that path in Nginx.
”Hook command exited with code 1”
The hook script returned an error. Run it manually to debug: sudo bash /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh. Typical errors: invalid nginx config (nginx -t first), service not running, or wrong script permissions.
Timer appears active but never renews
Check the server clock with timedatectl — if it’s incorrect, the timer fires but certbot rejects the operation. Sync with sudo systemctl restart systemd-timesyncd. Another case: the cert isn’t yet in the 30-day pre-expiration window; certbot ignores it silently. Force with --force-renewal only if you’ve ruled out other causes.
Next steps
With renewal automated and monitored, consider these next steps to strengthen your TLS setup:
- Configure OCSP stapling in Nginx — reduces cert validation latency in the browser and lowers load on Let’s Encrypt OCSP servers.
- Evaluate wildcard certificates with the DNS-01 challenge — useful if you manage many subdomains; requires a DNS plugin for your specific provider.
- Implement HSTS preload after confirming renewal stability — hard to reverse, so only enable it once automation has run through at least 2 full cycles.
- Centralize renewal logs in an observability system — useful in server fleets where checking each machine individually doesn’t scale.
If you’re putting this into production, a Hostini VPS ships with up-to-date Ubuntu LTS, hardened SSH by default and traffic with DDoS protection — a solid base for TLS automation without kernel or network surprises.
Frequently asked questions
Why is my renewal cron not running even though it's configured?
Cron requires an explicit PATH — its environment doesn't inherit the interactive shell PATH. Add `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` at the top of the crontab and use absolute paths for certbot (`/usr/bin/certbot`). Also check `/var/log/syslog | grep CRON` to confirm execution.
Can I force renewal before the 30-day mark to test it?
Use `certbot renew --dry-run` to simulate without consuming Let's Encrypt quota. To force a real renewal, run `certbot renew --force-renewal` — but be careful: Let's Encrypt limits duplicate certificates to 5 per week, and forced renewals count against that limit.
What's the difference between traditional cron and a systemd timer for Certbot?
The modern certbot package (Ubuntu 22.04+, Debian 12+) automatically installs a systemd timer (`certbot.timer`) that runs twice a day with random jitter. Traditional cron still works, but the timer has advantages: it's persistent (runs at boot if it missed the window), randomized to prevent thundering herd, and logs go to journalctl.
How do I reload Nginx or Apache without downtime after renewal?
Use the deploy hook: `certbot renew --deploy-hook 'systemctl reload nginx'`. The hook only runs when a certificate was actually renewed, avoiding unnecessary reloads. Reload (not restart) reloads the config without dropping active connections — zero downtime.
The renewal failed with a rate limit error. How long until I can try again?
Let's Encrypt applies several limits: 50 certificates per root domain per week, 5 duplicates per week, 5 failures per hour per account+hostname. The window is sliding — wait until the oldest request leaves the window. Use staging (`--staging`) to test without affecting the production quota.
How will I be notified if renewal fails silently?
Configure a contact email in Certbot (`--email`) — Let's Encrypt sends warnings 20, 10 and 1 day before expiration. For active monitoring, add a script that checks `openssl x509 -enddate -noout -in /etc/letsencrypt/live/domain/cert.pem` and triggers an alert if fewer than 20 days remain.