At Oh Dear , our status pages can run on a customer’s own domain, and we’ve always served on-demand ACME certificates for them: someone points their domain at us, and Caddy provisions a Let’s Encrypt certificate for it automatically. We’re now adding a second option, letting customers bring their own certificate. For that, Caddy doesn’t provision anything; it fetches the certificate from an HTTP backend in our app.
While building that, I noticed something about how Caddy fetches those certificates: there’s no caching. Every new SNI that comes in triggers another fetch to our backend, for a certificate that almost never changes.
It’s not in production yet. But rather than re-fetch a certificate on every HTTPS connection, I built a small Caddy module to sit in that path: caddy-get-certificate-cache . Small, purposeful, and exactly what we need. It’s open source under Apache-2.0.
The setup#
We’ve run Caddy in front of our customers’ domains for years. I wrote about the original status-page setup back in 2019 , and that one uses on-demand ACME, with Caddy provisioning each certificate itself.
Customer-provided certificates work differently. Caddy doesn’t own the certificate, our app does. So Caddy uses the stock tls.get_certificate.http
getter: on a handshake it does a GET to our endpoint with the SNI as a query parameter, and our app hands back the matching PEM bundle.
It’s a clean design. Our app owns the certificates, Caddy just serves them.
get_certificate runs on every handshake#
Here’s the catch I ran into. certmagic
, the certificate library underneath Caddy, does not cache the result of a get_certificate manager. By design, it calls the manager on every handshake. There’s an open issue about it: caddyserver/caddy#7038
.
For a getter that reads off disk, that’s fine. For one that does an HTTP request, it means a backend round trip on every handshake, for a certificate that changes maybe once every few months.
A busy domain does a lot of handshakes. None of them need a fresh certificate, but every one of them would hit our app to ask. For a feature I haven’t even shipped yet, that’s a cheap thing to fix before it becomes a problem.
The module#
It’s a drop-in alternative to the stock http getter that wraps the same HTTP fetch in an in-process cache. It still fetches from our backend, it just doesn’t do it on every handshake. On a hit, the hot path is a map lookup. Microseconds, no network. The module ID is tls.get_certificate.cached_http.
The Caddyfile looks almost identical to the stock getter, you just swap http for cached_http:
tls {
get_certificate cached_http https://app.example.com/cert {
ttl 1h
negative_ttl 60s
cache_dir /var/cache/caddy-certs
}
on_demand
}
The design choice I cared about most: it honours the Cache-Control header our backend sets on the response. The s-maxage value decides how long Caddy holds onto a certificate, which keeps control where it belongs, in our app. We decide per response how long a certificate is good for, and Caddy respects it. If you’d rather not rely on the header, you can pin a fixed ttl in the Caddyfile instead.
The rest of the behaviour:
- HTTP 200: the PEM bundle is parsed and cached, for the
s-maxageduration or your explicitttl. - HTTP 204 (our app’s way of saying “no certificate for this name”): cached as a negative entry for
negative_ttl, so an unknown domain doesn’t hammer the backend on repeat. - Anything else, or a parse error: returned as an error and not cached, so a transient upstream blip self-heals on the next handshake instead of getting pinned for a TTL.
Two more things that matter under load. Concurrent cache misses for the same name are coalesced into a single upstream request with singleflight , so a burst of handshakes for a cold domain triggers one fetch, not hundreds. And the fetch deliberately doesn’t inherit the handshake’s context: if the client that triggered the cold lookup disconnects, the fetch keeps running so the other handshakes waiting behind it don’t get cancelled. Each fetch gets its own 10 second timeout instead.
There’s an optional cache_dir that persists bundles to disk, so a restart comes back warm instead of fetching everything from the backend again.
Gotchas#
A few things worth knowing before you reach for it.
It caches private keys. If you set cache_dir, point it at a directory only the Caddy user can read. The module writes 0600 files in a 0700 directory, but it only enforces that mode when it creates the directory itself.
It’s a plugin, so it’s compiled into Caddy. Upgrading Caddy means rebuilding with xcaddy and the module included. Keep your previous binary around for a quick rollback.
And entries loaded from disk on startup get a fresh TTL on load, because I don’t persist the original Cache-Control. So a warm-restart entry re-validates within one TTL of coming back up. Good enough, and simpler than serialising expiry timestamps.
Get it#
It’s on GitHub under Apache-2.0: github.com/ohdearapp/caddy-get-certificate-cache . Build a Caddy with it included:
xcaddy build --with github.com/ohdearapp/caddy-get-certificate-cache
It’s also listed on Caddy’s site as the tls.get_certificate.cached_http
module, so you can find it on the download page
and tick it on without running xcaddy yourself.
It’s not in production yet, we’re still building the customer-provided-certificate feature it’s for. But it’s small, it does one thing, and it’s open source if you’re fronting an HTTP get_certificate endpoint with Caddy and want the same pure win we’re after: stop fetching a certificate on every connection.