Add cert-manager with Cloudflare DNS-01 ClusterIssuers
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
# 12 — cert-manager: real, auto-renewing HTTPS
|
||||
|
||||
`demo.ahmza.com` works over plain HTTP (`docs/11-ingress.md`). This doc adds
|
||||
a real, browser-trusted HTTPS certificate for it — issued automatically,
|
||||
renewed automatically, never touched by hand again once it's working.
|
||||
|
||||
## The pieces
|
||||
|
||||
- **cert-manager** — watches for `Certificate` resources (or, more simply,
|
||||
one annotation on an `Ingress`), requests a certificate from an ACME
|
||||
provider, and keeps it renewed. Installed the same umbrella-chart way as
|
||||
MetalLB and Longhorn — see
|
||||
[`apps/cert-manager/Chart.yaml`](../apps/cert-manager/Chart.yaml).
|
||||
- **Let's Encrypt** — the free, automated certificate authority
|
||||
cert-manager talks to.
|
||||
- **DNS-01 challenge** — proves you control `ahmza.com` by creating a
|
||||
temporary DNS TXT record, via Cloudflare's API, rather than requiring
|
||||
Let's Encrypt to reach your server over the public internet. Chosen
|
||||
specifically because this cluster is currently LAN-only (see
|
||||
`docs/09-growing-up.md`) but will eventually be public — DNS-01 works
|
||||
identically either way, and also supports wildcard certificates
|
||||
(`*.ahmza.com`), unlike HTTP-01.
|
||||
- **A Cloudflare API token** — the one real secret this needs, stored
|
||||
through the SOPS+age pipeline from
|
||||
[`docs/10-secrets.md`](10-secrets.md), never in plaintext.
|
||||
|
||||
## What's already running
|
||||
|
||||
**`apps/cert-manager.yaml`** installs the chart plus two
|
||||
[`ClusterIssuer`](../apps/cert-manager/templates/)s — a cluster-wide
|
||||
resource telling cert-manager *how* to prove ownership and *which* ACME
|
||||
server to use:
|
||||
|
||||
- `letsencrypt-staging` — Let's Encrypt's staging environment. Generous
|
||||
rate limits, but its certificates aren't trusted by browsers. Use this
|
||||
first, always, for a new hostname.
|
||||
- `letsencrypt-prod` — the real thing. Much stricter rate limits; a
|
||||
repeated mistake here can temporarily lock you out of requesting real
|
||||
certificates for that hostname.
|
||||
|
||||
Both reference the same Secret, `cloudflare-api-token`, which doesn't
|
||||
exist as plaintext anywhere in this repo — it's a `SopsSecret`
|
||||
(`apps/cert-manager/templates/cloudflare-api-token.sops.yaml`), decrypted
|
||||
into a real Secret by `sops-secrets-operator`.
|
||||
|
||||
## One-time setup: the Cloudflare API token
|
||||
|
||||
1. In Cloudflare: **My Profile → API Tokens → Create Token → "Edit zone
|
||||
DNS"** template, scoped only to the zone(s) you actually need (e.g.
|
||||
`ahmza.com`) — not a broader token than necessary, and never the
|
||||
account's legacy Global API Key.
|
||||
2. On your machine — never in this chat, never in a file you might
|
||||
`git add` before encrypting:
|
||||
```sh
|
||||
cat > apps/cert-manager/templates/cloudflare-api-token.sops.yaml <<'EOF'
|
||||
apiVersion: isindir.github.com/v1alpha3
|
||||
kind: SopsSecret
|
||||
metadata:
|
||||
name: cloudflare-api-token
|
||||
namespace: cert-manager
|
||||
spec:
|
||||
secretTemplates:
|
||||
- name: cloudflare-api-token
|
||||
stringData:
|
||||
api-token: PASTE_THE_REAL_TOKEN_HERE
|
||||
EOF
|
||||
```
|
||||
3. Edit that file, replacing `PASTE_THE_REAL_TOKEN_HERE` with the real
|
||||
token — still plaintext on disk at this point, still not staged.
|
||||
4. Encrypt it in place, immediately:
|
||||
```sh
|
||||
sops --encrypt --in-place apps/cert-manager/templates/cloudflare-api-token.sops.yaml
|
||||
```
|
||||
5. Confirm `apiVersion`, `kind`, and `metadata` are still readable and only
|
||||
`stringData.api-token`'s value is ciphertext (see
|
||||
[`docs/10-secrets.md`](10-secrets.md) for what this should look like),
|
||||
*then* `git add`, commit, and push.
|
||||
|
||||
Note this file must live under `apps/cert-manager/templates/`, not the
|
||||
chart root — `apps/cert-manager/` has a `Chart.yaml`, so Argo CD treats it
|
||||
as a Helm source and only applies files inside `templates/` (the exact
|
||||
mistake `docs/10-secrets.md`'s own pipeline test caught).
|
||||
|
||||
## Requesting the certificate
|
||||
|
||||
`apps/demo/ingress.yaml` already carries the
|
||||
`cert-manager.io/cluster-issuer: letsencrypt-staging` annotation and a
|
||||
`tls:` block. Once the token above is in place and synced, cert-manager
|
||||
notices, creates a `Certificate`, and works through a DNS-01 challenge
|
||||
against Cloudflare automatically.
|
||||
|
||||
```sh
|
||||
kubectl -n demo get certificate
|
||||
kubectl -n demo describe certificate demo-tls
|
||||
```
|
||||
|
||||
`READY` should become `True` within a minute or two. If it doesn't:
|
||||
|
||||
```sh
|
||||
kubectl -n cert-manager get challenges -A
|
||||
kubectl -n cert-manager logs deploy/cert-manager -f
|
||||
```
|
||||
|
||||
Once staging genuinely works — confirm with `curl -k` (accepting the
|
||||
untrusted staging cert just to prove the handshake completes) — switch
|
||||
`apps/demo/ingress.yaml`'s annotation to `letsencrypt-prod`, push, and
|
||||
resync. This second request against the *real* Let's Encrypt server is
|
||||
what your browser will actually trust.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Cloudflare API token created, scoped to only the zone(s) needed
|
||||
- [ ] `cloudflare-api-token.sops.yaml` encrypted correctly (metadata
|
||||
readable, `stringData` values ciphertext) and committed
|
||||
- [ ] `cert-manager` Application `Synced`/`Healthy`
|
||||
- [ ] `kubectl -n cert-manager get clusterissuer` shows both issuers `READY`
|
||||
- [ ] Certificate issued successfully against `letsencrypt-staging` first
|
||||
- [ ] Switched to `letsencrypt-prod`, certificate reissued and trusted
|
||||
- [ ] `https://demo.ahmza.com` loads with a real, browser-trusted padlock,
|
||||
no `-k`/`--insecure` needed
|
||||
|
||||
You've now built every piece `docs/09-growing-up.md` described as "more
|
||||
of the same pattern" — a new app, a new file, a push. This is the whole
|
||||
shape of the repo, end to end.
|
||||
Reference in New Issue
Block a user