Why Auto-Renewal Is Not Optional
Let's Encrypt certificates expire after 90 days. That's not a bug — short-lived certificates limit the window an attacker can use a compromised key. The trade-off is that manual renewal is not practical. If you forget, your site goes down. Browser users see a full-page certificate error. API clients fail TLS handshakes. Email delivery breaks. Every sysadmin has a war story about the cert that expired at 3 AM on a Saturday.
Auto-renewal is the only sane approach. Both Certbot (the Electronic Frontier Foundation's official client) and acme.sh (a lightweight shell-script client) support fully unattended certificate issuance and renewal. Choose one, set it up once, and monitor it.
Real-World Failure: The Cron That Never Ran
A small hosting provider I consulted for had Certbot installed on 12 servers. The certs renewed fine for 8 months. Then a technician rebuilt the monitoring server and forgot to re-add the renewal alerting pipeline. Four certificates expired simultaneously because Certbot's systemd timer had been silently failing for 6 weeks — the /etc/letsencrypt directories had wrong permissions after a dist-upgrade. The problem wasn't the renewal tool. It was the assumption that "set it and forget it" works.
Auto-renewal needs three layers: the tool that renews, the scheduler that invokes it, and monitoring that verifies the renewal actually happened.
Option 1: Certbot with systemd Timer
Certbot packages for modern distributions ship a systemd timer out of the box. On Debian/Ubuntu:
apt install certbot python3-certbot-nginx # if using nginx
apt install certbot python3-certbot-apache # if using Apache
After obtaining the initial certificate, verify the timer exists and is active:
systemctl list-timers certbot.timer
systemctl status certbot.timer
Certbot's timer runs twice daily at a random minute (to spread ACME server load). It checks whether any certificate is within 30 days of expiry and renews if needed. Renewal is a no-op for certificates with more than 30 days remaining, so running it frequently costs almost nothing.
Manual Renewal Dry-Run
Before trusting the timer, test renewal manually with a dry run:
certbot renew --dry-run
If the dry run succeeds, Certbot can communicate with Let's Encrypt's ACME server and the authentication challenges resolve correctly. The output lists each certificate domain and whether renewal would succeed.
Hooks for Post-Renewal Actions
Certificates on disk don't help if the web server isn't told to reload them. Certbot supports deploy hooks — scripts that run after each successful renewal:
# /etc/letsencrypt/renewal-hooks/deploy/restart-services.sh
#!/bin/bash
systemctl reload nginx
systemctl reload postfix
systemctl reload dovecot
chmod +x /etc/letsencrypt/renewal-hooks/deploy/restart-services.sh
Certbot runs every script in the deploy directory. You don't need to touch the systemd timer configuration. Add your server reload commands and move on.
Option 2: acme.sh for Lightweight, DNS-Challenge Setups
acme.sh is a single shell script — no Python runtime, no systemd dependency, no root access required. It shines in two scenarios: servers where Certbot's footprint is too heavy, and DNS-based validation that works behind firewalls or for wildcard certificates.
Installation
curl https://get.acme.sh | sh -s email=admin@example.com
The installer puts acme.sh in ~/.acme.sh/ and adds a cron entry for auto-renewal. No sudo, no package manager. It also registers an ACME account automatically.
Issuing a Certificate with DNS Challenge
DNS challenges are required for wildcard certificates (*.example.com) and for servers that are not reachable from the public internet. acme.sh integrates with over 80 DNS providers via API:
# Cloudflare example
export CF_Token="your-api-token"
acme.sh --issue -d example.com -d '*.example.com' --dns dns_cf
# Route53 example
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
acme.sh --issue -d example.com --dns dns_aws
acme.sh creates the _acme-challenge TXT record, waits for propagation, completes validation, and removes the record. No listener on port 80 or 443 required.
Installing the Certificate
acme.sh keeps certificates in its own directory. Use the --install-cert command to copy them where your services expect them and trigger a reload:
acme.sh --install-cert -d example.com \
--key-file /etc/nginx/ssl/example.com.key \
--fullchain-file /etc/nginx/ssl/example.com.crt \
--reloadcmd "systemctl reload nginx"
The reload command is baked into the certificate configuration. On every renewal, acme.sh copies the new files and runs this command automatically.
Verifying Renewal Actually Works
Don't wait 90 days to find out. Check certificate expiry dates with the OpsCheck SSL Certificate Checker — it shows the notAfter date and remaining validity period for any public-facing certificate. Run it monthly against your domains.
CLI Expiry Check
# Check when a live server's cert expires
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | \
openssl x509 -noout -enddate
# Check a local cert file
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate
Nagios-Style Expiry Alert
Wrap the check in a script that fires an alert when the remaining validity drops below a threshold:
#!/bin/bash
# check-ssl-expiry.sh — exit 2 (CRITICAL) if cert expires within 14 days
DOMAIN="${1:-example.com}"
DAYS_THRESHOLD=14
expiry=$(echo | openssl s_client -servername "$DOMAIN" -connect "$DOMAIN":443 2>/dev/null | \
openssl x509 -noout -enddate | cut -d= -f2)
expiry_epoch=$(date -d "$expiry" +%s)
now_epoch=$(date +%s)
days_remaining=$(( (expiry_epoch - now_epoch) / 86400 ))
if [ "$days_remaining" -lt "$DAYS_THRESHOLD" ]; then
echo "CRITICAL: $DOMAIN cert expires in $days_remaining days ($expiry)"
exit 2
else
echo "OK: $DOMAIN cert valid for $days_remaining days"
exit 0
fi
Drop this into your monitoring system's check directory. Run it daily via cron alongside the renewal timer.
Common Renewal Failure Modes
1. HTTP Challenge Port Blocked
Certbot's HTTP-01 challenge needs inbound traffic to port 80. If you moved your site behind Cloudflare or a CDN after issuing the cert, the ACME validation server hits your origin IP directly and may be firewalled. Fix: switch to DNS-01 validation with acme.sh, or open port 80 from Let's Encrypt's validation IP ranges.
2. Renewal Hook Fails Silently
If the post-renewal reload hook exits non-zero (e.g., nginx config has a syntax error), Certbot logs the failure but doesn't retry. The new cert sits on disk but the running server still uses the old, soon-to-expire one. Check logs weekly:
journalctl -u certbot.service --since "2 weeks ago" | grep -i error
3. DNS Propagation Delays
acme.sh's DNS challenge waits for the TXT record to propagate. Some providers take 2-5 minutes. If propagation exceeds acme.sh's default wait window, the challenge fails. Increase the wait time:
acme.sh --issue -d example.com --dns dns_cf --dnssleep 120
4. Account Key or API Token Rotation
If you rotate your Cloudflare API token or AWS IAM key without updating the environment variables in acme.sh's config, the next renewal fails silently. Store API credentials in a file sourced by cron, not in a shell history that gets lost.
# /etc/acme-credentials
export CF_Token="xxx"
# In crontab
0 3 * * * source /etc/acme-credentials && /root/.acme.sh/acme.sh --cron --home /root/.acme.sh
Monitoring: The OpsCheck Safety Net
Even with perfect automation, monitor externally. Internal cron jobs can fail because the server is down, disk is full, or DNS is broken. The OpsCheck SSL Checker probes from outside your infrastructure — if it reports a cert expiring in 5 days, your renewal pipeline has a problem regardless of what your logs say.
Run it weekly against every production domain. The 90-day Let's Encrypt window gives you plenty of time to catch and fix renewal failures, but only if you actually check.
Putting It All Together
Pick a client: Certbot for standard web server setups with HTTP validation, acme.sh for DNS challenges and minimal-footprint environments. Set up automatic renewal with systemd timers or cron. Add post-renewal hooks to reload services. Validate with dry runs. Then — and this is the part most people skip — verify externally with the OpsCheck SSL Checker on a schedule.
# Full setup in 5 commands (Certbot + Nginx on Debian)
apt install certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.com
echo '#!/bin/bash' > /etc/letsencrypt/renewal-hooks/deploy/reload.sh
echo 'systemctl reload nginx postfix dovecot' >> /etc/letsencrypt/renewal-hooks/deploy/reload.sh
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload.sh
certbot renew --dry-run
That is the entire setup. Five commands, zero ongoing maintenance, and a cert that renews itself every 60 days like clockwork. The only thing left is to check the OpsCheck SSL Checker once a month and confirm the expiry date is always 60+ days in the future.