The automation wraps the ACME protocol yet still obeys the same public rules: challenges are validated over the network, and when the origin is hidden behind a load balancer you may need the DNS challenge instead of the HTTP one.
One directive is enough
A bare caddy file-server (or a tls directive) turns automatic HTTPS on by default: Caddy generates a key pair and drives the ACME flow against a certificate authority to obtain a certificate, then reloads it into the running server. It stores the issued key and certificate in its own data store under an ID derived from the domain, so the next start reuses them instead of re-issuing on every boot. You never author the protocol steps, but you still own the boundaries: a domain must be publicly reachable, or you switch to a DNS challenge with a provider plugin so the validation runs where it can actually answer.
Watch, renew, reload
Renewal is not a scheduled batch job you babysit; Caddy’s in-process watcher checks each certificate and re-issues automatically before expiry, typically with ample margin, then swaps the new certificate in without a full restart. A long-running server therefore does not slowly drift into serving an expired cert. What operators still own is the external contract: at renewal time the domain must keep resolving to this server (or the DNS challenge must keep passing), so an unrelated DNS or CDN change can silently break renewals you assumed were handled.
Not every gate needs it
It shines on a small site you control end to end. Once a third-party CDN fronts the server, the CDN answers port 443 for the domain, so an HTTP challenge validated against that host can go to the wrong place; that is exactly what the DNS challenge exists to solve. And when a managed CDN already terminates TLS, Caddy’s own automatic acceptance may be redundant anyway. The judgment call that decides it: who terminates TLS and who can answer the challenge. That tells you whether to keep Caddy’s automation or hand it off.
When it breaks
Port 80 is the first suspect. The default HTTP challenge must reach Caddy on port 80, so if another server or a stale container already owns that port, issuance fails at the challenge step while the rest of the configuration looks right. The logs name the failed challenge; free the port or move that domain to the DNS challenge.
DNS-challenge credentials are the slow-burning variant. The provider API token works on day one, then expires or gets rotated, and renewal quietly breaks months later, far from the change that caused it. Treat the token like any other monitored secret and re-test issuance after every rotation.
Fleet-wide restarts cause the third failure: every instance waking up at once can stampede the authority and trip rate limits, so renewals are jittered rather than fired on a shared tick. Do not defeat that by wiping Caddy’s storage, which is what lets a restart reuse certificates instead of re-issuing them.
The last trap is environment confusion. Testing against the production endpoint spends the real rate-limit budget, while shipping a staging issuer leaves browsers distrusting the certificate. Keep staging for experiments and check which issuer signed the served certificate before declaring victory.
Check it quickly
Save the following as renew_window.py in a scratch directory and run python3 renew_window.py. It uses only the standard library to model the renewal rule: a certificate becomes due once its remaining lifetime drops below thirty days, and the window opens at not_after minus thirty days. Four synthetic certificates, fresh, due, exactly on the boundary, and expired, pin the behavior with assertions, and the script prints each expiry date, remaining days, and renewal decision.
"""Simulate Caddy-style renewal scheduling using only the standard library.
A certificate is due for renewal once its remaining lifetime drops below
RENEW_BEFORE. The window opens at (not_after - RENEW_BEFORE) and closes at
not_after; anything past not_after is already expired.
"""
from datetime import datetime, timedelta, timezone
RENEW_BEFORE = timedelta(days=30)
def renewal_status(*, issued_at, lifetime_days, now):
"""Return (not_after, remaining, renew_now, window_opens)."""
not_after = issued_at + timedelta(days=lifetime_days)
remaining = not_after - now
window_opens = not_after - RENEW_BEFORE
renew_now = remaining < RENEW_BEFORE
return not_after, remaining, renew_now, window_opens
def describe(name, issued_at, lifetime_days, now):
not_after, remaining, renew_now, window_opens = renewal_status(
issued_at=issued_at, lifetime_days=lifetime_days, now=now
)
print(f"{name}: not_after={not_after.date()} remaining={remaining.days}d "
f"window_opens={window_opens.date()} renew_now={renew_now}")
return not_after, remaining, renew_now, window_opens
def main():
now = datetime.now(timezone.utc)
# Fresh certificate: 80 of 90 days left, renewal window still closed.
_, remaining, renew_now, _ = describe(
"fresh", now - timedelta(days=10), 90, now)
assert remaining.days == 80, remaining
assert renew_now is False
# Aging certificate: 20 days left, inside the 30-day renewal window.
_, remaining, renew_now, window_opens = describe(
"due", now - timedelta(days=70), 90, now)
assert remaining.days == 20, remaining
assert renew_now is True
assert window_opens <= now, "window should already be open"
# Boundary: exactly 30 days left does not trigger (< 30 is the rule).
_, remaining, renew_now, _ = describe(
"boundary", now - timedelta(days=60), 90, now)
assert remaining.days == 30, remaining
assert renew_now is False
# Expired certificate: negative remaining time always triggers renewal.
_, remaining, renew_now, _ = describe(
"expired", now - timedelta(days=100), 90, now)
assert remaining.days < 0, remaining
assert renew_now is True
print("PASS: fresh waits, due and expired renew, boundary stays quiet")
if __name__ == "__main__":
main()
The run ends with PASS: fresh waits, due and expired renew, boundary stays quiet. The boundary case stays quiet because the rule is strictly less than thirty days. This checks date arithmetic rather than a live ACME flow, so in production also read the served certificate’s real not_after and watch one genuine renewal succeed in the logs.
Further reading
Sources
- Caddy automatic HTTPS, explains automatic issuance and renewal, checked 2026-06-01.
- Let’s Encrypt challenge types, describes HTTP-01 and DNS-01 validation, checked 2026-09-17.
- Let’s Encrypt rate limits, documents issuance quotas and the staging environment, checked 2026-09-17.