TLS & Certificates
How to stop a TLS certificate from expiring (Caddy & certbot)
Let's Encrypt made free 90-day TLS certificates the default for self-hosters, and both Caddy and certbot auto-renew them. So why do certs still expire in production? Because the systems that renew certs and the systems that alert you about failures are the same systems that go offline when things go wrong.
How the not_after field works
Every X.509 certificate contains a Validity block with two timestamps:
notBefore (when the cert becomes valid) and notAfter (when it
expires). Browsers, servers, and TLS clients compare the current time against
notAfter on every handshake. If the current time is past notAfter,
the handshake fails with a certificate-expired error, and the browser shows the user a hard
stop — not a warning, a block.
You can read the notAfter field from a live server without a browser:
# Using openssl
echo | openssl s_client -connect yourapp.com:443 -servername yourapp.com 2>/dev/null | openssl x509 -noout -enddate
# Output:
# notAfter=Nov 15 12:00:00 2025 GMT
CT logs also record the not_after value from each certificate at issuance time,
so monitoring services like Perimeter can read expiry data directly from crt.sh without making
any connection to your server.
How Caddy handles cert renewal
Caddy uses an embedded ACME client (the certmagic library) to provision and renew
certificates automatically. The renewal cycle works like this:
- Caddy stores certs and private keys in its managed storage (by default,
~/.local/share/caddy/certificates/on Linux). - Every ~10 minutes, a background goroutine checks all managed certs. If a cert has fewer than 30 days remaining, Caddy attempts renewal.
- Renewal uses the ACME HTTP-01 challenge by default: Let's Encrypt sends a GET to
http://yourapp.com/.well-known/acme-challenge/<token>, and Caddy must respond with the correct key authorization string. - On success, the new cert is written to disk and loaded into memory with zero downtime (no restart required).
This works reliably when the server is reachable and the ACME challenge can complete. The problems occur at the edges.
Common Caddy renewal failure modes
The subdomain Caddy no longer serves
You added staging.yourapp.com to your Caddyfile six months ago. The staging
environment was later decommissioned, but you left the DNS record pointing at your box and
left the Caddy site block in place. Caddy renewed the cert automatically for months. Then
you reorganized the Caddyfile, removed the staging block, but forgot the DNS record. Now
Caddy no longer manages that hostname — no renewal goroutine watches it — but the cert will
eventually expire and any service that checks it externally will fail.
This is exactly the scenario CT monitoring catches: a hostname under your domain has a cert
approaching not_after, and your server either can't report it (the service is gone)
or the cert isn't being renewed because the server no longer manages it.
Failed ACME HTTP-01 challenge
The HTTP-01 challenge requires that port 80 be reachable from the public internet. If your server is behind a firewall, a misconfigured security group, or a hosting provider that blocks inbound port 80 traffic, the ACME challenge fails. Caddy will retry, log an error, and eventually stop retrying as the cert's renewal window closes.
Check journalctl -u caddy -n 100 --no-pager for lines containing
certificate management, ACME, or challenge.
Let's Encrypt rate limits
Let's Encrypt enforces rate limits: 50 certificates per registered domain per week, and 5 duplicate certificates per week. If you are running many subdomains or doing frequent testing, you may hit these limits. Rate limit errors appear in the Caddy log as HTTP 429 responses from the ACME directory.
The solution for high subdomain counts is a wildcard certificate (which requires DNS-01 challenge rather than HTTP-01), or batching SANs so one cert covers multiple subdomains.
DNS propagation lag during initial provisioning
When you first add a new subdomain and DNS, Caddy may attempt the ACME challenge before the
DNS record has propagated globally. Let's Encrypt resolves from multiple vantage points; if
any of them can't see the record, the challenge fails. This is usually a transient failure
that resolves on the next retry cycle, but it is worth confirming via
dig +short yoursubdomain.yourapp.com from a public resolver before expecting
Caddy to provision.
How certbot handles cert renewal
Certbot writes a systemd timer or a cron job during installation that runs
certbot renew twice daily. certbot renew checks all certificates
stored in /etc/letsencrypt/live/ and renews any that are within 30 days of
expiry. Unlike Caddy, certbot requires an explicit service reload after renewal to pick up
the new cert — usually handled by a --deploy-hook:
# Typical certbot renew hook in /etc/letsencrypt/renewal/yourapp.com.conf
[renewalparams]
post_hook = systemctl reload nginx
# or:
post_hook = systemctl reload caddy
If the deploy hook is missing or fails silently, certbot renews the cert on disk but the running server keeps serving the old, expired cert until the process restarts.
Common certbot failure modes
-
Timer disabled or drifted:
systemctl status certbot.timershould showactive (waiting). If it is inactive or failed, renewal stops. - Port 80 firewalled: same as Caddy's HTTP-01 problem — the ACME challenge cannot complete.
-
Missing deploy hook: the cert is renewed on disk but the server is not
reloaded. Verify with
openssl s_clientafter a renewal cycle. -
Certbot version too old: Let's Encrypt has deprecated older protocol
endpoints. Very old certbot installations may fail to communicate with the current ACME
directory.
certbot --versionshould be reasonably recent.
Why external monitoring catches what your box cannot
The fundamental problem with relying exclusively on on-box monitoring for cert expiry is: the systems that alert you are the same systems that fail when the cert expires.
- If your server is down, it cannot send you an expiry alert — but the cert is still expiring.
- If Caddy or nginx is crashed, its internal renewal check is not running.
- If a deployment broke the TLS listener, you won't get an alert from a process that is no longer running.
- If a subdomain has been orphaned (DNS exists, cert exists, no service manages it), nothing on your box is watching it.
External expiry monitoring — reading not_after from the CT log or via a TLS
handshake from outside your network — is independent of your server's state. Perimeter alerts
at 14 days and again at 3 days, giving you two intervention points before browsers start
blocking your users. The 14-day window is enough time to diagnose and fix almost any ACME
or certbot problem without service disruption.
Run the free one-shot report to see the current not_after
for every cert issued to subdomains of your domain, including subdomains you may have forgotten.
Continuous expiry monitoring is included in both the
Solo ($19/mo) and Agency ($49/mo) plans.
Frequently asked
Caddy is supposed to auto-renew — do I really need external monitoring?
Caddy's auto-renewal is reliable when the server is running and reachable. External monitoring catches the cases Caddy cannot: server outages, firewall changes that block ACME HTTP-01 challenges, orphaned subdomains that Caddy no longer manages, and anything that takes the renewal goroutine offline. The 14-day alert window gives you time to fix problems before users are affected.
What is the ACME HTTP-01 challenge and why does it fail?
The HTTP-01 challenge is Let's Encrypt's primary verification method. To prove you control a domain, Let's Encrypt sends an HTTP GET to port 80 of the domain requesting a specific token. Your server must respond with the correct key authorization string. It fails when port 80 is firewalled, when a proxy is misconfigured, when DNS points elsewhere during the check, or when the ACME client is not running. Use certbot's --dry-run flag or check Caddy logs (journalctl -u caddy) to diagnose.
How do I check the not_after date of a cert without Perimeter?
Use openssl s_client: echo | openssl s_client -connect yourapp.com:443 -servername yourapp.com 2>/dev/null | openssl x509 -noout -enddate. This performs a TLS handshake and prints the notAfter field. You can also query crt.sh directly for any domain to see the not_after from the CT log record, without connecting to the server at all.
My certbot renewed the cert but the server is still showing the old expired cert. Why?
certbot renews the certificate files on disk but does not restart or reload the web server on its own. You need a deploy hook: add post_hook = systemctl reload nginx (or caddy) to your renewal configuration in /etc/letsencrypt/renewal/yourapp.com.conf. Without this hook, the running process keeps the old cert in memory until it is restarted.
How far in advance does Perimeter alert on expiry?
Perimeter sends an alert at 14 days before expiry and a second alert at 3 days before expiry. The 14-day window gives you enough time to diagnose and fix ACME problems, rotate keys if needed, and test the new cert — all without service disruption. The 3-day alert is the last-chance warning if the 14-day issue was not resolved.
See what the internet already knows about your domain
Free one-shot report — no signup, no credit card. Built entirely on public data.
Run the free report