Oh Dear

Bring your own certificate to your status page

Published on June 10, 2026 by Mattias Geniar

Your status page can run on your own domain. Until now, though, it served a TLS certificate you had no control over: a Let's Encrypt one we requested for it. For most teams that's fine. For an organization with its own PKI, or rules about exactly which certificates are allowed to front its domains, it wasn't.

There's a new Certificate tab in your status page settings. Once you've verified you own the domain, you can upload your own certificate and private key, the ones your organization approves and manages, or download an origin certificate we generate for the domain. Either way, you decide what your status page presents.

The most common reason you'll reach for this is Cloudflare, so let's start there.

Why Full (strict) broke #

Cloudflare's SSL/TLS modes differ in one way that matters here: whether Cloudflare checks the certificate your origin serves.

Cloudflare mode What it checks at the origin Our status page
Flexible Nothing (plain HTTP to origin) Not recommended
Full TLS, but ignores the certificate Worked already
Full (strict) TLS and a valid, trusted certificate Error 526

For status pages behind Cloudflare, where a Let's Encrypt validation does not work, our status page origins served a self-signed certificate. That's fine for Full, but Full (strict) wants a certificate it can validate against a trusted chain. A self-signed one fails that check, and Cloudflare answers visitors with a 526 instead of your status page.

Your two options #

Open the Certificate tab, verify the domain, and pick whichever fits:

  • Upload your own. Bring a certificate and key you already control, from your own PKI, an internal CA, or a Cloudflare Origin CA cert. Paste the certificate, the private key, and an optional chain. We serve exactly that.
  • Download our origin certificate. Don't want to manage one yourself? We generate a certificate for your domain. You download the public half and tell Cloudflare to trust it as a custom origin CA. Done.

We validate an upload before it's ever served. The checks live in ValidateUploadedOriginCertificate:

$parsed = @openssl_x509_parse($certificate);

if (@openssl_x509_check_private_key($certificate, $privateKey) !== true) {
    $this->fail('That private key does not match the certificate.');
}

if (! $this->coversDomain($parsed, $domain)) {
    $this->fail("That certificate is not valid for {$domain}.");
}

A key that doesn't match the cert, a certificate that doesn't cover your domain, or one that's already expired gets rejected at upload time with a clear message, so you can only upload valid certificates.

A few things we locked down #

This is certificate handling, so the boring parts matter.

  • Domain verification gates everything. You can't upload or activate a certificate for a domain until your team has verified it owns that domain.
  • Private keys are encrypted at rest and never leave. You can't download a key you uploaded, and the key column is hidden from serialization and encrypted in the database. The only thing that ever goes out is the public certificate.
  • Exactly one active certificate per domain. A transaction, a row lock, and a (domain, source) unique index together make sure the certificate we serve is never ambiguous, even under a race.

What serves the certificate #

Our status pages run on Caddy. When a TLS handshake comes in for a custom domain, Caddy asks our app which certificate to serve via a get_certificate HTTP lookup, keyed on the SNI hostname. The endpoint is a single invokable controller:

$verification = $this->verifyStatusPagePointsToUs->executeCached($domain);

$pem = $this->resolveActiveOriginCertificate->execute($domain, $verification->classification);

if ($pem === null) {
    return $this->notServed();
}

return response($pem, Response::HTTP_OK)
    ->header('Content-Type', 'application/x-pem-file')
    ->header('Cache-Control', "public, s-maxage={$this->cacheSeconds}");

ResolveActiveOriginCertificateAction prefers your active uploaded certificate, falls back to the self-signed one we generate for proxied domains, and returns nothing when no certificate should be served. Clean precedence, one source of truth.

The Caddy caching piece that made this practical #

There's a catch. Caddy's get_certificate module calls that HTTP endpoint on every single handshake, and the underlying certmagic library doesn't cache the result by design. Certificates barely ever change, but we'd be hammering our own backend on every new TLS connection for a lookup that returns the same bytes all day.

So we wrote a drop-in caching wrapper for Caddy and open-sourced it: caddy-get-certificate-cache. It registers as tls.get_certificate.cached_http and adds an in-process cache in front of the HTTP getter.

tls {
    get_certificate cached_http https://app.example.com/cert {
        ttl          1h
        negative_ttl 60s
        cache_dir    /var/cache/caddy-certs
    }
}

It honors s-maxage from our responses (the same header you saw on the controller above), caches "no certificate here" answers separately with a shorter negative_ttl, and uses singleflight so a burst of concurrent handshakes for the same domain collapses into one upstream request. Hot-path certificate lookups go from a network round trip to a microsecond map read.

That caching is what makes per-handshake origin certificates cheap enough to run in production, instead of a self-inflicted load test on every TLS connection.

Try it #

Open your status pages, pick one, and head to the Certificate tab:

  1. Verify you own the domain.
  2. Upload your own certificate and key, or download the origin certificate we generate.

If you're doing this for Cloudflare, set the SSL/TLS mode to Full (strict) once the certificate is in place. The full walkthrough, including how to fix an existing Error 526, lives in Add a custom domain to your Oh Dear status page.

Not on Oh Dear yet? You can try everything free for 10 days, no credit card needed.

Start using Oh Dear today!

  • Access to all features
  • Cancel anytime
  • No credit card required
  • First 10 days free

More updates

Want to get started? We offer a no-strings-attached 10 day trial. No credit card required.

Start monitoring

You're all set in
less than a minute!