cert-manager: Certificates as Cluster Resources🔗
Part of a Learning Path
This article is a step in the Put Your Kubernetes App on the Internet pathway on bradpenney.io. It assumes the Gateway API front door and the ACME protocol — how a machine proves domain ownership — from the networking side of this pathway.
The Gateway article left you holding a promise: its HTTPS listener references a Secret called example-com-tls, and something, somehow, has to put a valid certificate in it, and keep putting one there, every renewal, forever. Do that by hand and you've built the exact calendar-driven outage the ACME protocol exists to end.
cert-manager is how a cluster keeps that promise. It applies the same move Kubernetes applies to everything: a certificate stops being a file someone fetches and becomes desired state (a resource declaring which names, issued by whom, stored where), with a controller reconciling reality to match, through every issuance and renewal, for as long as the cluster runs. This article wires it up: the two resources that model issuance, the one Gateway annotation that automates the whole front door, and the debug chain for the day a certificate won't issue.
What You'll Learn
By the end of this article, you'll understand:
- The two-resource model — Issuers as platform policy, Certificates as requests
- The Gateway integration — one annotation, and listener Secrets fill and renew themselves
- The debug chain —
Certificate → CertificateRequest → Order → Challenge, and where failures actually live - Staging discipline in cluster terms — two ClusterIssuers, and which environments may touch production
- Internal PKI — the same resources with your own CA as the signer
flowchart TD
G["Gateway listener<br/>certificateRefs: example-com-tls<br/>+ cert-manager annotation"]
CM["cert-manager<br/>reconcile loop"]
CI["ClusterIssuer<br/>letsencrypt-prod (platform policy)"]
CA["Let's Encrypt<br/>ACME challenges"]
S["Secret: example-com-tls<br/>kubernetes.io/tls — filled and refilled"]
G -->|watched by| CM
CI -->|how to issue| CM
CM <-->|prove & receive| CA
CM -->|writes| S
S -->|mounted by| G
style G fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
style CM fill:#326CE5,stroke:#cbd5e0,stroke-width:2px,color:#fff
style CI fill:#1a202c,stroke:#cbd5e0,stroke-width:2px,color:#fff
style CA fill:#4a5568,stroke:#cbd5e0,stroke-width:2px,color:#fff
style S fill:#2f855a,stroke:#cbd5e0,stroke-width:2px,color:#fff
Two Resources: Policy and Request🔗
cert-manager adds two CRD layers to the cluster, and they divide the same way Gateway API's resources do, into platform policy and request:
The Issuer (platform-owned) says where certificates come from and how to prove ownership. ClusterIssuer is the cluster-wide variant you'll usually want:
- Let's Encrypt's production endpoint. A
letsencrypt-stagingtwin of this resource, pointed at the staging endpoint, belongs beside it in the platform config; see the discipline note below. - Where expiry warnings go if renewal somehow fails repeatedly: a team inbox, not a person.
- The ACME account key (cert-manager creates it): not a certificate, just the cluster's standing identity with the CA.
- How HTTP-01 challenges get answered: cert-manager briefly attaches a temporary HTTPRoute to your existing Gateway to serve the token, then removes it. A
dns01solver block (with DNS provider credentials) goes here instead when you need wildcards or internal names.
The Certificate (the request) says which names, stored where:
| What cert-manager acts on | |
|---|---|
- The output: a standard
kubernetes.io/tlsSecret, the exact shape every TLS consumer mounts, chain included. cert-manager rewrites it at every renewal. - A wildcard means this Certificate needs a DNS-01 solver on its issuer; HTTP-01 physically can't prove a wildcard.
Like everything on the platform, these are manifests in the config repo, delivered by GitOps: the platform/cert-manager/ directory of the edge-stack artifact.
The Payoff: Annotate the Gateway, Delete the Chore🔗
Here's the part that closes the loop with the front door. You usually don't even write the Certificate resource: cert-manager can generate it from the Gateway itself. Enable the integration the same way Traefik's own Gateway API support got turned on: a flag on the controller Deployment.
| platform/cert-manager/cert-manager.yaml — the controller Deployment, one arg added | |
|---|---|
- Tells cert-manager to watch Gateways for the annotation below — the same declarative-flag pattern as Traefik's
--providers.kubernetesgateway=true. Both live in the vendored manifest, not a Helmvalues.yaml.
With the flag set:
- The whole integration. cert-manager watches Gateways carrying this annotation.
- cert-manager reads each listener's
hostnameto know which names the certificate must cover. - cert-manager creates a
Certificatetargeting this Secret name, runs the ACME dance, and fills it — the Secret the Gateway article told you to have "somehow" now creates and renews itself. The platform annotates once; every future listener on this Gateway gets the same treatment.
That's the end state worth naming: certificate expiry stops being an event. Not "we get reminded": the concept disappears from the calendar.
Two ClusterIssuers, one discipline
Let's Encrypt's production endpoint has rate limits that can lock a domain out for days. In cluster terms the discipline is structural: the platform config carries both a letsencrypt-staging and a letsencrypt-prod ClusterIssuer, ephemeral and test environments annotate their Gateways with staging (untrusted certs, unlimited patience), and only long-lived environments reference prod. The annotation is the control point.
When It Doesn't Issue: Follow the Chain🔗
cert-manager decomposes every issuance into a chain of resources — Certificate → CertificateRequest → Order → Challenge — and debugging means walking it, outermost in, until the story changes. Here's the walk the way it actually happens, twelve minutes after a new listener shipped:
-
09:14 — The report.
stats.example.comis serving Traefik's default certificate. Start at the outermost resource: -
09:15 — One link down. Twelve minutes is too long.
kubectl describe certificatepoints at aCertificateRequest, which is waiting on an ACMEOrder: -
09:16 — The Challenge names it. The innermost resource holds the real story, as it almost always does:
-
09:18 — The actual cause.
no such host: the listener shipped, but nobody created the DNS record, and the CA can't fetch a token from a name that doesn't resolve. The fix isn't in cert-manager at all — it's a missing record. (Or, as the next article argues, a controller that would have created it automatically.) -
09:31 — Resolved, hands-free. Record created. With no further human input, the Challenge retries on its own schedule and passes, and the chain unwinds:
Ordervalid,CertificateRequestissued,READY: True, real certificate served. Nobody had to tell cert-manager to try again; reconcilers don't wait to be asked twice.
That walk is every "cert won't issue" incident: outermost resource in, until a Reason names the cause; and the cause is almost never cert-manager itself. Port 80 blocked at the load balancer, DNS credentials rejected, a record that doesn't exist yet: the Challenge tells you which.
Internal Names: The CA Issuer🔗
ACME with a public CA assumes the CA can see your domain, but *.internal.corp and service-to-service certificates front names that will never be public. cert-manager handles them with different issuer types: a CA issuer signs with a private CA keypair you provide, and Vault/Venafi issuers delegate to enterprise PKI. Same Certificate resources, same Secrets, same renewal loop; only the signer changes. On-prem platforms get the by-now-familiar lesson: the machinery doesn't change, only who fulfills the request.
Common Pitfalls🔗
The Challenge sits pending with TXT-record errors. Check, in order: the DNS provider credentials Secret the solver references (wrong key or under-scoped permissions shows as Forbidden in the challenge events); split-horizon DNS, where the record landed in your internal zone but the public zone the CA queries never saw it; or plain propagation lag to a slow secondary. The challenge's describe output names which.
The Secret holds a fresh certificate; openssl s_client against the live endpoint shows the old one. The consumer isn't reloading: Traefik watches Secret changes, but a CDN or external LB in front may hold its own cached copy that isn't cert-manager's to update. Compare what's served against what's in the Secret, then find the layer holding the stale copy. Also check for two issuers fighting over one Secret: flapping contents is the tell.
The Certificate issued fine — into a Secret the listener isn't actually referencing. Compare the listener's certificateRefs.name against the Certificate's secretName character by character (and namespace by namespace): the annotation-generated name must match what the listener mounts. Traefik serving its built-in default certificate is the classic symptom of a reference mismatch, not an issuance failure.
Practice Exercises🔗
Exercise 1: Walk the Chain
A new listener's certificate has shown READY: False for an hour. Walk the debugging chain and name the likely culprit given this finding: the Challenge is pending, HTTP-01, and its events say the self-check for http://shop.example.com/.well-known/acme-challenge/<token> times out.
Solution
Chain: Certificate (False) → CertificateRequest → Order (pending) → Challenge (pending, with the actual reason). A timing-out HTTP-01 self-check means the token URL isn't reachable from the internet: either shop.example.com doesn't resolve to the Gateway's address yet (DNS record missing or still cached; plain TTL math), or port 80 is closed at the LoadBalancer/firewall because someone exposed only 443. HTTP-01 requires the CA to fetch over plain HTTP; an HTTPS-only edge breaks issuance while looking "more secure." Fix the port-80 path (serving only the challenge plus a redirect is fine) or switch the solver to DNS-01.
Exercise 2: The Preview-Environment Pipeline
Your CI creates a full preview environment per merge request — each with its own hostname on *.preview.example.com — and tears it down after review. Design the certificate strategy: which ClusterIssuer, which challenge, and what one change removes most of the issuance load entirely?
Solution
Preview environments should never touch the production ACME endpoint: per-domain rate limits make CI-driven issuance a self-inflicted outage. Options, best first: (1) a wildcard certificate (*.preview.example.com, DNS-01, issued once under the production issuer and shared by every preview environment via the platform Gateway's listener), which removes per-environment issuance entirely (the "one change"); (2) if per-environment certs are truly required, annotate preview Gateways with the staging ClusterIssuer and accept the browser warning internally. The wildcard-on-the-Gateway pattern is the platform/app split paying off again: one platform-owned certificate, unlimited app-team environments underneath it.
Exercise 3: Who Owns Which Resource?
Your platform runs one Gateway with the cert-manager.io/cluster-issuer annotation. An app team asks for pay.example.com with TLS. List what already exists, what the app team creates, and what cert-manager creates on its own — and name the one thing that would force a platform change.
Solution
Already exists (platform): cert-manager itself, both ClusterIssuers, and the annotated Gateway with a listener whose hostname covers pay.example.com. App team creates: exactly what the Gateway article taught — an HTTPRoute in their namespace (plus their Service). cert-manager creates on its own: the Certificate, the CertificateRequest/Order/Challenge chain, and the filled kubernetes.io/tls Secret the listener references; no human writes any of them. The platform change trigger: a hostname outside every existing listener's coverage (say pay.example.io): new listener, new certificate coverage, and that's a platform-owned edit to the Gateway, exactly where the ownership line belongs.
Quick Recap🔗
| Concept | What to Know |
|---|---|
| Desired state | A Certificate declares names/issuer/Secret; the controller reconciles issuance and renewal forever |
| Issuer vs Certificate | Platform policy (how certs are issued) vs. request (which names, stored where) |
| Gateway annotation | cert-manager.io/cluster-issuer on the Gateway → Certificates generated from listeners automatically |
| Two ClusterIssuers | letsencrypt-staging for ephemeral/test, letsencrypt-prod for long-lived; the annotation is the control point |
| Debug chain | Certificate → CertificateRequest → Order → Challenge; the Challenge names the real failure |
| Reference mismatch | READY: True + default cert served = listener certificateRefs ≠ Certificate secretName |
| CA issuer | Same resources, your own signer: internal PKI without new machinery |
What's Next?🔗
The front door now feeds itself certificates. One hand-made artifact remains between users and your app (the DNS record pointing your domain at the Gateway), and external-dns deletes that ticket with the same reconcile-loop move this article just made.
Further Reading🔗
Official Documentation🔗
- cert-manager documentation - Installation, issuer types, and configuration
- cert-manager: Gateway API usage - The annotation integration used in this article
Related Learning🔗
- Automating TLS Certificates: ACME and Let's Encrypt (networking.bradpenney.io) - The protocol this controller runs: challenges, rate limits, and the 90-day design
- TLS Basics (networking.bradpenney.io) - What the issued certificates are, chain and all
- Deploying Platform Services with Flux and OCI Artifacts (gitops.bradpenney.io) - How
platform/cert-manager/reaches the cluster
Related Articles🔗
- Gateway API with Traefik: The Standard Front Door - The listeners whose Secrets this article fills
- Pointing Your Domain at the Cluster with external-dns - The other controller in the closed loop