Automating SSL Certificate Renewal with Let's Encrypt and Cron: A Step-by-Step Guide

Automation October 02, 2026
Automating SSL Certificate Renewal with Let's Encrypt and Cron: A Step-by-Step Guide

A certificate that expires at 3 a.m. does not care that you planned to renew it on Friday. The failure is rarely dramatic: a browser warning appears on the marketing site, an internal API starts rejecting calls, and someone gets paged. Let's Encrypt removed the cost of certificates but left the scheduling problem in your hands. The fix is a small amount of automation, done once, that keeps working for years.

This guide walks through issuing certificates, wiring up renewal hooks, and adding the monitoring that tells you when renewal quietly stops working.

Why Manual Renewal Always Fails Eventually

Let's Encrypt certificates are valid for 90 days, and the client renews them inside a 30-day window before expiry. That means a healthy setup touches each certificate roughly every 60 days. For one domain on one server, a calendar reminder works. For twenty domains across three hosts, it becomes a chore that gets skipped.

The boring failure modes are the interesting ones. A reminder lives on a laptop that got replaced. A DNS record was migrated and the challenge now fails. Port 80 was closed by a security review. Nobody reads the renewal log, so nothing warns you until the certificate is already dead. Expiry is a hard failure: browsers block, mobile clients fail, and health checks turn red.

Issue the First Certificate

Start with a domain that resolves to the server, port 80 reachable from the internet, and sudo access. Install Certbot from your distribution packages rather than pip if you can — the packaged version sets up the scheduling for you later.

On Debian and Ubuntu: apt install certbot python3-certbot-nginx. On RHEL-family systems: dnf install certbot python3-certbot-nginx.

If Nginx serves the site, the plugin path is fastest. Run certbot --nginx -d example.com -d www.example.com. Certbot asks a couple of questions, edits the server block, installs the certificate, and offers to redirect HTTP to HTTPS.

If you manage configuration with Ansible or a template, use the webroot method instead: certbot certonly --webroot -w /var/www/html -d example.com. Certbot writes only the challenge file and leaves your config untouched. You then point Nginx or Apache at /etc/letsencrypt/live/example.com/fullchain.pem and privkey.pem yourself.

For wildcards or servers that never expose port 80, use a DNS challenge plugin such as --dns-cloudflare with an API token. The certificate is issued the same way; only the validation method differs.

One detail worth internalising: the files in /etc/letsencrypt/live/ are symlinks into an archive directory that changes on every renewal. Always reference the symlinks in your web server config, never the numbered files behind them.

Certbot Already Schedules Itself — Verify That

Packaged installs create either a systemd timer (certbot.timer) or an entry in /etc/cron.d/certbot that runs certbot renew twice a day with a random delay. The renew command reads every configuration under /etc/letsencrypt/renewal and acts only on certificates inside the renewal window.

Confirm it exists. Run systemctl list-timers and look for certbot, or read /etc/cron.d/certbot directly. If you installed Certbot from pip or by hand, you may have no scheduler at all, which is a common reason renewals never happen.

Then test without risking rate limits: certbot renew --dry-run. This performs the full challenge against the staging environment and reports what would happen. Run it after every change to DNS, proxy rules, or firewall configuration. Let's Encrypt enforces weekly certificate limits per domain, so keep real renewals for real renewals.

Add Renewal Hooks That Do Something Useful

Renewal alone is not enough. Your web server keeps serving the old certificate from memory until it reloads. That is what hooks are for, and the difference between them matters:

  • --pre-hook runs before every renewal attempt, even when nothing needs renewing. Use it to open a temporary firewall port or stop a conflicting service.
  • --post-hook runs after every attempt. It fires whether or not a certificate changed.
  • --deploy-hook runs only when a certificate was actually renewed. This is the right place for reloads.

A practical deploy hook for Nginx looks like certbot renew --deploy-hook "systemctl reload nginx". Reloading is almost always better than restarting: it picks up the new file without dropping connections. Certbot also exposes the environment variables RENEWED_LINEAGE and RENEWED_DOMAINS to the hook, so a script can copy certificates into an application directory, push them to a load balancer, or restart a container that mounts them.

You can set hooks globally in /etc/letsencrypt/cli.ini so every certificate benefits, or per certificate inside its renewal configuration file. Keep hooks short, non-interactive, and logged. A hook that hangs holds the whole renewal run.

Write a Cron Job When You Need Control

Some clients do not schedule themselves — acme.sh with a DNS API, for instance, or a custom renewal script — and sometimes you want the schedule in one place you can audit. A plain cron entry works fine:

17 3 * * * /usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx" >> /var/log/certbot-renew.log 2>&1

A few habits keep hand-rolled cron reliable. Run it daily rather than on a monthly schedule, because the client decides whether action is needed. Pick an odd minute to avoid the midnight spike everyone else creates. Wrap the command in flock to prevent overlapping runs on a slow morning. Do not discard the exit code — pipe failures into your alerting rather than a log nobody opens. And make sure the job runs as root or as a user with read access to the private keys.

Monitor Expiry Before Your Users Notice

Renewal breaks silently. A redirect added in front of the challenge path, a WAF rule, a deauthorised ACME account, a full disk, or a DNS migration will all stop validation without touching your application. Logs record it; nobody reads them.

Check the certificate as the public sees it: openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -enddate. Wrap that in a script, parse the date, and exit non-zero when fewer than 14 days remain so your monitoring platform alarms.

Two checks are better than one. First, verify the served certificate, which catches load balancers and proxies you forgot about. Second, verify the file in /etc/letsencrypt/live/ — if the served certificate is older than the local one, your reload hook is not firing. If you run Prometheus, the blackbox exporter exposes probe_ssl_earliest_cert_expiry, which makes an alert on anything under 21 days trivial.

Finally, alert on silence. A check that fires when no successful renewal has been recorded in 40 days catches the case where the scheduler was removed, the timer is disabled, or the cron entry was lost during a server rebuild.

A Short Checklist to Finish

  1. Confirm a scheduler exists — certbot.timer or /etc/cron.d/certbot — and is enabled.
  2. Point web server configs at the live symlinks, not the archive files.
  3. Add a deploy hook that reloads your web server or proxy.
  4. Run certbot renew --dry-run and read the output.
  5. Set up an external expiry check with a two-week warning threshold.
  6. Alert if no successful renewal happens for 40 days.

Do those six things once and certificate expiry stops being a recurring worry. When you next change DNS, firewall rules, or proxy configuration, run the dry run again — that single habit catches almost every renewal failure before it reaches production.

Photo: Nathan Thomas / Pexels