SSL/TLS Certificate Renewal Automation with Certbot and Cron
Setting up fully automated SSL/TLS certificate renewal using Certbot, cron jobs, and post-renewal hooks to prevent expiration downtime.
Introduction
There is a special kind of panic that comes with discovering your SSL certificate has expired at 2 AM on a Saturday. It happened to me once, about three years ago, and I vowed never to let it happen again. The cause was simple β I had manually renewed the certificate and forgotten to set up a renewal mechanism. Since then, I have automated everything, but the automation itself needs to be tested and verified. This post describes my current setup for SSL/TLS certificate renewal that has been running reliably for over two years.
The setup uses Certbot with a standalone webserver for domain validation, cron for scheduling, and a custom post-renewal hook script that reloads Nginx and sends a notification if anything goes wrong.
Environment
- Server: Ubuntu 22.04 LTS on Hetzner Cloud
- Web server: Nginx 1.24.0
- Certificate authority: Let's Encrypt
- Certbot version: 2.1.0 (snap package)
- Domains covered: 5 domains across 2 servers
- Cron: System cron (not systemd timers)
I chose Certbot over alternatives like acme.sh because it has the most mature plugin ecosystem and the best documentation. For a production environment, I wanted something battle-tested rather than clever.
Problem
The initial setup was straightforward β I ran certbot --nginx and everything worked. But several months later, I noticed the certificate was approaching renewal time, and I had not configured automatic renewal. The Certbot snap package comes with a systemd timer that runs twice daily, but I discovered it was disabled on my system because I had installed Certbot differently earlier and the timer was never enabled:
systemctl list-timers | grep certbot
# No output β the timer was not activeWithout the timer, the only way the certificate would renew was if I manually ran certbot renew. This was the exact situation that had caused my outage years ago.
Additionally, even if the timer were running, a successful renewal does not automatically reload Nginx. Certbot's --deploy-hook or --renew-hook flags need to be configured to reload the web server after a renewal. Without this, the new certificate sits on disk but Nginx continues serving the old one until it is manually reloaded.
Solution
I built a complete automation system with three components:
1. Certbot renewal with hooks:
First, I ensured the systemd timer was enabled:
sudo systemctl enable --now certbot.timer
sudo systemctl status certbot.timerThen I configured the renewal with hooks in /etc/letsencrypt/renewal/example.com.conf:
[renewalparams]
authenticator = nginx
installer = nginx
pre_hook = systemctl stop nginx
post_hook = systemctl start nginx && /opt/scripts/cert-notify.shThe pre_hook stops Nginx before renewal (in case standalone mode is needed), and the post_hook starts it back and runs a notification script.
2. Notification script:
I created /opt/scripts/cert-notify.sh:
#!/bin/bash
CERT_DOMAIN="example.com"
LOG_FILE="/var/log/cert-renewal.log"
check_cert_expiry() {
local expiry_date
expiry_date=$(echo | openssl s_client -servername "$CERT_DOMAIN" \
-connect "$CERT_DOMAIN:443" 2>/dev/null | \
openssl x509 -noout -enddate 2>/dev/null | \
cut -d= -f2)
if [ -z "$expiry_date" ]; then
echo "[$(date)] ERROR: Could not retrieve certificate for $CERT_DOMAIN" >> "$LOG_FILE"
return 1
fi
local expiry_epoch
expiry_epoch=$(date -d "$expiry_date" +%s)
local current_epoch
current_epoch=$(date +%s)
local days_left=$(( (expiry_epoch - current_epoch) / 86400 ))
echo "[$(date)] Certificate for $CERT_DOMAIN expires in $days_left days" >> "$LOG_FILE"
if [ "$days_left" -lt 7 ]; then
echo "[$(date)] WARNING: Certificate renewal may have failed!" >> "$LOG_FILE"
return 1
fi
return 0
}
# Main execution
echo "[$(date)] Running certificate renewal check..." >> "$LOG_FILE"
certbot renew --quiet --deploy-hook "systemctl reload nginx" >> "$LOG_FILE" 2>&1
if check_cert_expiry; then
echo "[$(date)] Certificate renewal successful" >> "$LOG_FILE"
else
echo "[$(date)] ALERT: Certificate issue detected" >> "$LOG_FILE"
fi3. Cron backup with monitoring:
Even though the systemd timer handles renewal, I added a cron job as a safety net:
# /etc/cron.d/certbot-backup
# Run renewal check daily at 3:30 AM
30 3 * * * root /opt/scripts/cert-notify.sh
# Check certificate expiry weekly and alert if < 14 days
0 9 * * 1 root /opt/scripts/check-cert-expiry.shThe weekly check script sends me a Slack notification if the certificate is expiring within 14 days:
#!/bin/bash
DOMAINS=("example.com" "api.example.com" "admin.example.com")
SLACK_WEBHOOK="https://hooks.slack.com/services/xxx"
for domain in "${DOMAINS[@]}"; do
expiry_date=$(echo | openssl s_client -servername "$domain" \
-connect "$domain:443" 2>/dev/null | \
openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
expiry_epoch=$(date -d "$expiry_date" +%s)
current_epoch=$(date +%s)
days_left=$(( (expiry_epoch - current_epoch) / 86400 ))
if [ "$days_left" -lt 14 ]; then
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\":\"WARNING: $domain certificate expires in $days_left days\"}"
fi
doneAfter setting everything up, I verified the renewal process works by forcing a test renewal:
sudo certbot renew --dry-run
# Simulating renewal of an existing certificate for example.com
# Congratulations, the simulated renewal succeeded.The dry run confirmed that the entire renewal pipeline β authentication, certificate issuance, post-hook execution β was working correctly.
Lessons Learned
- Never rely on manual renewal β Automated systems fail silently. Set up the timer, verify it is active, and add monitoring.
- Use dry-run β
certbot renew --dry-runtests the entire renewal process without actually renewing. Run it periodically. - Post-renewal hooks are critical β A renewed certificate is useless if your web server is not reloaded. Always configure deploy hooks.
- Add external monitoring β Use a service or script to check certificate expiry independently. Do not rely solely on the renewal system working correctly.
- Log everything β The notification script logs all renewal attempts and certificate states. When something goes wrong, the logs tell you exactly what happened.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.