# Bring your own certificate to your status page

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`:

```php
$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:

```php
$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](https://ma.ttias.be/caching-get-certificate-lookups-in-caddy/). It registers as `tls.get_certificate.cached_http` and adds an in-process cache in front of the HTTP getter.

```caddyfile
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](/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](/docs/status-pages/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.