Renewal that works when you test it and fails three months later is the classic failure mode. Two habits prevent it.
Let's Encrypt rate limits are strict enough that a botched production attempt can block you for a week. Run the same command against staging:
certbot --nginx -d example.com --dry-run
--dry-run exercises the full challenge flow without consuming the real quota.
certbot renews the files on disk. Whether your web server notices is a separate question. Add a deploy hook:
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
systemctl reload nginx
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
Hooks run only when a certificate was actually renewed, so this is cheap.
systemctl list-timers | grep certbot
systemctl status certbot.timer
If certbot.timer is inactive, renewal depends on a cron entry that may not exist.
Check both.
A one-line check that catches everything:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
Thirty seconds now beats a two-hour outage at the wrong moment.