DEV Community

Cover image for How to automate SSL certificate renewal across a fleet of servers
binadit
binadit

Posted on Originally published at binadit.com

How to automate SSL certificate renewal across a fleet of servers

Stop babysitting SSL certs: automate renewal across your whole fleet

If someone on your team still has a recurring calendar reminder to check certificate expiry dates, you have a problem worth fixing today. Not next quarter, today. Manual cert tracking across multiple hosts is the kind of low-grade recurring work that quietly eats hours every month and never shows up as a line item until something expires in production at 2am.

This guide walks through building fleet-wide automated renewal using Let's Encrypt, the ACME protocol, and a central orchestration pattern. It works whether you have 5 boxes or 500, behind a load balancer, as separate app nodes, or spread across regions.

Assumptions before you start

  • You control DNS (or can hit a DNS API) for Cloudflare, Route 53, DigitalOcean, etc.
  • Linux servers (Ubuntu 22.04/24.04 in examples, distro-agnostic approach)
  • SSH access across the fleet, ideally via Ansible or Salt, not manual loops
  • TLS termination happens at Nginx or HAProxy, not inside the app itself
  • You're fine with cron or systemd timers

We'll use certbot since it has the broadest plugin support, though acme.sh or lego work fine with the same architecture if you want something lighter.

Pick a validation method that actually scales

HTTP-01 validation needs port 80 reachable on the exact host being validated. That's fine for one server. It falls apart across a fleet behind a load balancer, where traffic for a domain doesn't predictably land on the same box twice.

DNS-01 is the better default. No inbound traffic required, supports wildcards, and lets you centralize issuance on one host even if the cert gets distributed everywhere else.

Cloudflare example:

apt install certbot python3-certbot-dns-cloudflare -y

cat > /etc/letsencrypt/cloudflare.ini << EOF
dns_cloudflare_api_token = YOUR_TOKEN
EOF

chmod 600 /etc/letsencrypt/cloudflare.ini
Enter fullscreen mode Exit fullscreen mode

Centralize issuance, distribute the result

Don't run certbot independently on every server. That gives you inconsistent cert state across the fleet and more surfaces that can silently fail. Issue once, push everywhere.

Use a deploy hook to push the renewed cert out via your config management tool:

cp /etc/letsencrypt/live/example.com/fullchain.pem /etc/haproxy/certs/example.com.pem
systemctl reload haproxy
Enter fullscreen mode Exit fullscreen mode

Keep that logic in one deploy hook script, not scattered across playbooks. One script, one source of truth for what happens post-renewal.

Don't forget the reload

Copying a new cert file does nothing if the service is still holding the old one in memory. Every distribution step needs a paired reload handler, Nginx or HAProxy, whichever terminates TLS.

Alert on failure, not just on success

A cron job that fails silently for a month isn't automation, it's a delayed incident waiting to happen. Wrap the renewal and check the exit code:

#!/bin/bash
if ! certbot renew --quiet; then
  curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL \
    -d '{"text":"Certbot renewal failed on '"$(hostname)"'"}'
fi
Enter fullscreen mode Exit fullscreen mode

Swap the webhook for whatever your team already uses, PagerDuty, Opsgenie, Slack. The point is a human sees the failure within a day, not when the cert is about to expire.

Verify it actually works

Don't trust the cron job exists and assume it's fine. Check end to end.

Dry run:

certbot renew --dry-run
Enter fullscreen mode Exit fullscreen mode

Check expiry across the fleet:

for host in $(cat fleet_hosts.txt); do
  echo -n "$host: "
  echo | openssl s_client -connect $host:443 -servername $host 2>/dev/null \
    | openssl x509 -noout -enddate
done
Enter fullscreen mode Exit fullscreen mode

Better yet, wire cert expiry into your existing monitoring. Prometheus blackbox exporter, Uptime Kuma, Zabbix all support it natively. Alert when probe_ssl_earliest_cert_expiry drops below 14 days, this is your independent confirmation layer outside certbot's own logs.

And confirm distribution reached every node, not just the one that ran certbot:

ansible web_fleet -m shell -a "openssl x509 -in /etc/ssl/certs/example.com.pem -noout -enddate"
Enter fullscreen mode Exit fullscreen mode

A mismatched expiry date across hosts means distribution failed somewhere, which is a more common failure mode than renewal itself breaking.

Pitfalls worth avoiding

  • Hardcoding API tokens in playbooks instead of using a secrets manager
  • Forgetting the service reload after distribution
  • Hitting Let's Encrypt's rate limit (50 certs per registered domain per week), easy to hit if you're doing per-subdomain certs instead of a wildcard
  • Running certbot independently per server instead of centralizing
  • Shipping automation with zero failure alerting

Where this goes next

The same pattern, central issuance plus fleet-wide distribution via config management, extends naturally to SSH key rotation, firewall rule pushes, and post-migration config redeploys. If you're running this at scale, folding it into a GitOps pipeline means one less automation system to monitor separately from your app deployments.

Full original writeup with more detail: binadit.com

Originally published on binadit.com

Top comments (0)