🪝 yoink

Cloudflare Origin Certificates

Issue a 15-year Cloudflare Origin Certificate and lock your origin to Cloudflare's edge via Authenticated Origin Pulls.

If you're already on Cloudflare's edge, Origin Certificates are the simplest TLS story for your origin hosts: free, valid for 15 years, no ACME, no Let's Encrypt rate limits, no port-80 reachability requirement. Yoink supports them out of the box.

Why this and not Let's Encrypt?

  • No rate limits. LE caps you to 5 duplicate certs / week / domain. With multi-host and forgetful redeploys, hitting that is easy.
  • No port-80 / DNS-01 dance. Origin certs are issued out-of-band from Cloudflare's dashboard.
  • 15-year validity. Rotate annually if you prefer; otherwise set a calendar reminder for the expiry.
  • Works with locked-down origins. Your origin can be on a private network, behind WAF rules, etc. Only Cloudflare's edge needs to reach it, and it does so via TLS on port 443 using the origin cert.

The downside: only Cloudflare-fronted clients trust the cert. Origin certs are not for direct browser access.

Setup

Issue the cert in the Cloudflare dashboard

  1. Log into Cloudflare → your zone → SSL/TLS → Origin Server.
  2. Click Create Certificate. Defaults are fine: ECC, 15 years, hostnames *.example.com + example.com (or list each host explicitly).
  3. Copy the Origin Certificate (PEM, full chain) and the Private Key.

Add the cert + key to secrets.age

yoink secrets edit

In the editor, add:

CF_ORIGIN_CERT=-----BEGIN CERTIFICATE-----
MIIE...
-----END CERTIFICATE-----
CF_ORIGIN_KEY=-----BEGIN PRIVATE KEY-----
MIIE...
-----END PRIVATE KEY-----

Save and exit. The values land in secrets.age (committed to the repo, encrypted).

Wire it up in yoink.yaml

Single service, single cert (per-service):

services:
  - name: api
    image: ghcr.io/me/api
    tag: v1
    domain: api.example.com
    tls: cert                              # ← skip ACME, use the inline cert
    tls_cert_secret: CF_ORIGIN_CERT
    tls_key_secret: CF_ORIGIN_KEY
    run:
      port: 8080

Many services, one wildcard cert (*.example.com covers everything) via proxy-level config:

proxy:
  tls:
    cert_secret: CF_ORIGIN_CERT
    key_secret:  CF_ORIGIN_KEY

services:
  - name: api
    domain: api.example.com
    run: { port: 8080 }
  - name: web
    domain: example.com
    run: { port: 3000 }
  - name: admin
    domain: admin.example.com
    run: { port: 9090 }

Every routed service inherits the proxy-level cert. Per-service tls_cert_secret: still overrides for the rare different-cert case.

Note: proxy.email: isn't required when no service uses tls: auto. ACME is implicitly off when proxy.tls.cert_secret is set.

Origin-pull mTLS (lock your origin to Cloudflare's edge)

By default, anyone who knows your origin IP can hit it directly with a Host: header, bypassing Cloudflare's WAF, rate limits, bot blocks, etc. Origin-pull mTLS fixes that: the origin requires every request to present a Cloudflare-signed client certificate. Anything else gets a TLS handshake error.

Add the Origin Pull CA bundle to secrets.age

Cloudflare provides a static CA bundle that signs every cert their edge presents to your origin. Download it and seal it:

curl -sSL https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem
yoink secrets edit
CF_ORIGIN_PULL_CA=-----BEGIN CERTIFICATE-----
<bundle contents>
-----END CERTIFICATE-----

Enable Authenticated Origin Pulls in the Cloudflare dashboard

SSL/TLS → Origin Server → Authenticated Origin Pulls → toggle on.

Add client_auth: to proxy.tls: in yoink.yaml

proxy:
  tls:
    cert_secret: CF_ORIGIN_CERT
    key_secret:  CF_ORIGIN_KEY
    client_auth:
      mode: require_and_verify             # strict — reject anything not signed by the CA
      trust_pool_secret: CF_ORIGIN_PULL_CA

That applies to every routed service. From now on, requests that don't come through Cloudflare are dropped at the TLS layer; your origin simply doesn't appear to exist for them.

mode: options (default require_and_verify):

  • require_and_verify: strict; reject anything without a valid client cert. Recommended.
  • require: require a cert but skip verification (don't use this).
  • verify_if_given: accept anonymous; verify if present. Mixed-mode.
  • request: request but don't enforce. Almost never useful.

Verify

After yoink up, this should fail (no client cert):

curl -v https://your-origin-ip/health -H 'Host: api.example.com'
# → tls: handshake failure [step]

This should succeed (through Cloudflare's edge):

curl https://api.example.com/health
# → 200 OK [step]

Real client IP from CF-Connecting-IP

Cloudflare proxies, so by default your origin (and Caddy access logs, Coraza, any rate-limit handler) sees Cloudflare's edge IPs in RemoteAddr. That's almost never what you want; log analytics, geo-IP, app-side rate limits, and any IP-based abuse handling all collapse to "every request is from Cloudflare." The fix is two lines: a caddy plugin that auto-refreshes Cloudflare's IP ranges, plus a proxy.config_extra: block telling Caddy to trust those ranges as proxies and read the real IP from CF-Connecting-IP.

proxy:
  email: [email protected]
  tls:
    cert_secret: CF_ORIGIN_CERT
    key_secret:  CF_ORIGIN_KEY
    client_auth:
      mode: require_and_verify
      trust_pool_secret: CF_ORIGIN_PULL_CA
  xcaddy:
    plugins:
      - github.com/WeidiDeng/caddy-cloudflare-ip
  config_extra: |
    {
      "apps": {
        "http": {
          "servers": {
            "main": {
              "trusted_proxies": {"source": "cloudflare"},
              "client_ip_headers": ["CF-Connecting-IP"]
            }
          }
        }
      }
    }

Note the path: apps.http.servers.main.<…>. Yoink's rendered server is named main, not the Caddy convention srv0 you'll see in upstream docs and Caddyfile-adapted output. If you write srv0 instead, the deep-merge silently creates a second server config that listens on nothing, and your trusted_proxies is dead config.

caddy-cloudflare-ip is the Caddy module that registers a cloudflare source for trusted_proxies and refreshes the IP list periodically. Without it, trusted_proxies: {source: cloudflare} would fail at config load.

This pairs with the origin-pull mTLS above for a clean two-layer story: the origin only TLS-terminates for Cloudflare (mTLS), and within that pipe the real client IP propagates correctly via CF-Connecting-IP.

Cloudflare side

Make sure the zone's SSL mode is Full (strict) (Cloudflare → SSL/TLS → Overview). This is what asks Cloudflare to validate the origin cert.

Rotation

Origin certs are good for 15 years, but you'll probably want to rotate every 1–3 years anyway. When the time comes:

Generate a new cert in Cloudflare's dashboard

Same flow as the original issuance.

Paste the new values

yoink secrets edit, replace CF_ORIGIN_CERT + CF_ORIGIN_KEY with the new ones.

Commit and redeploy

git commit -am 'rotate cf origin cert'
yoink up

Yoink notices the cert bytes have changed, redeploys the proxy container (~10s), and the new cert is live.

For zero-downtime rotation, use yoink up --service yoink-proxy from one host at a time if you're multi-host.

Multi-host

Each host serves the same domain with the same cert. No Let's Encrypt rate-limit problem because no ACME is happening. Origin certs work the same on every host.

The cert + key live in secrets.age (one copy in the repo). Every host's proxy reads it from the same source. No Redis, no shared storage needed.

See also

On this page