cert-manager¶
Who this page is for: in plain English, cert-manager is the thing that gets TLS certificates for your cluster and renews them before they expire, so nobody has to remember a date in a calendar. This page is production depth, aimed at readers already comfortable with Pods, Services, and Deployments. New to Kubernetes? Start with the Beginner track.
cert-manager is a CNCF Graduated project (it graduated in 2024, the foundation's highest maturity tier) that automates issuance and renewal of TLS certificates inside Kubernetes. Before it existed, certificate rotation was a manual, calendar-driven chore - someone had to remember an expiry date, generate a CSR, get it signed, and roll the new cert into a Secret before the old one lapsed. Missed rotations are one of the most common causes of preventable production outages, and they tend to happen at the worst time: a cert that was fine for eleven months fails silently until the exact hour it expires.
cert-manager turns certificates into Kubernetes-native, declarative resources. You describe what you want - "this Service needs a cert for api.example.com, signed by Let's Encrypt" - and cert-manager handles the ACME protocol, challenge validation, renewal scheduling, and Secret updates for the life of the cluster. It's the de facto standard: almost every Ingress controller, service mesh, and Gateway API implementation assumes cert-manager is available for TLS.
Architecture¶
flowchart TD
User[Certificate resource\ncreated by user or Ingress annotation] --> CertController[certificates-controller]
CertController --> CR[CertificateRequest]
CR --> Issuer{Issuer / ClusterIssuer}
Issuer -->|ACME| Order[Order]
Order --> Challenge[Challenge\nHTTP-01 or DNS-01]
Challenge -->|solve| ACMEServer[ACME server\ne.g. Let's Encrypt]
ACMEServer -->|signed cert| Order
Issuer -->|CA / self-signed| Sign[local signing]
Order --> CR
Sign --> CR
CR --> Secret[(Kubernetes Secret\ntls.crt + tls.key)]
Secret --> Ingress[Ingress / Gateway\nterminates TLS]
Webhook[cert-manager-webhook] -.validates CRDs.-> CertController
CAInjector[cainjector] -.injects CA bundles.-> Webhooks[other admission webhooks]
cert-manager runs as three deployments: the main controller (reconciles all the CRDs below), the webhook (validates and mutates cert-manager's own CRDs, and pluggable DNS-01 solvers), and cainjector (injects CA bundles into other components' webhook configurations and CRDs - including cert-manager's own webhook at bootstrap).
The resource chain¶
This is the part people find confusing at first: creating one Certificate triggers a chain of intermediate resources, each with its own status you can inspect when something is stuck.
| Resource | Scope | Purpose |
|---|---|---|
Issuer / ClusterIssuer |
namespaced / cluster | Defines how to get a cert signed - ACME account, CA keypair, or self-signed |
Certificate |
namespaced | Declares what cert you want (DNS names, issuer, Secret name, renewal window) |
CertificateRequest |
namespaced | A single signing request derived from a Certificate; contains the CSR |
Order |
namespaced (ACME only) | Tracks an in-progress ACME order against the CA |
Challenge |
namespaced (ACME only) | One domain-validation challenge (HTTP-01 or DNS-01) that must be solved to complete an Order |
Certificate -> CertificateRequest -> (Order -> Challenge, for ACME) -> signed cert -> Secret. Non-ACME issuers (self-signed, CA, Vault) skip Order/Challenge entirely - the CertificateRequest is signed directly.
Issuer is namespace-scoped: it can only issue certs for Certificate resources in the same namespace. ClusterIssuer is cluster-scoped and can be referenced from any namespace - the usual choice for a shared Let's Encrypt account.
ACME issuers: HTTP-01 and DNS-01¶
ACME (the protocol Let's Encrypt uses) requires you to prove control of a domain before it will sign a cert for it. cert-manager automates that proof via two challenge types.
HTTP-01: cert-manager spins up a temporary Pod + Service + Ingress that serves a token at http://<domain>/.well-known/acme-challenge/<token>. The ACME server fetches that URL over plain HTTP on port 80. Simple, works with any Ingress controller, but has two hard limits: it needs port 80 reachable from the public internet, and it cannot issue wildcard certificates - ACME requires DNS-01 for *.example.com.
DNS-01: cert-manager creates a TXT record (_acme-challenge.example.com) via your DNS provider's API, and the ACME server queries DNS instead of HTTP. This is the only way to get wildcard certs, and it's also the only option when the workload isn't internet-reachable at all - internal services, private ACME CAs (like an internal Smallstep or step-ca instance), or clusters with no public ingress. The tradeoff is DNS propagation latency: some providers take 30-120 seconds to propagate a TXT record, and cert-manager's Challenge will sit in a pending state until its own DNS check confirms propagation.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform-team@example.com
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
ingressClassName: nginx
selector:
dnsZones:
- "public.example.com"
- dns01:
route53:
region: us-east-1
hostedZoneID: Z1234567890ABC
selector:
dnsZones:
- "example.com" # covers *.example.com wildcards
Multiple solvers with selector blocks let one ClusterIssuer route different domains (or all domains by default, with no selector) to different challenge mechanisms - HTTP-01 for a public subdomain, DNS-01 for the apex zone and its wildcards.
Certificate resource¶
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: api-example-com
namespace: production
spec:
secretName: api-example-com-tls
duration: 2160h # 90 days, Let's Encrypt's max
renewBefore: 360h # renew 15 days before expiry
privateKey:
algorithm: ECDSA
size: 256
dnsNames:
- api.example.com
- www.api.example.com
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
group: cert-manager.io
cert-manager watches every Certificate's expiry continuously and starts a renewal automatically once the time-to-expiry drops below renewBefore. The renewal creates a new CertificateRequest, walks the same issuance chain, and - critically - updates the existing Secret in place rather than creating a new one. The Secret name never changes across renewals.
Self-signed and CA issuers for internal/mTLS use cases¶
Not every cert needs a public CA. Internal service-to-service TLS, mTLS between workloads, and cluster-internal endpoints are usually better served by a private CA cert-manager manages itself:
# Step 1: a self-signed root, used only to mint the CA cert
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: selfsigned-bootstrap
namespace: pki
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-ca
namespace: pki
spec:
isCA: true
commonName: internal-root-ca
secretName: internal-ca-key-pair
privateKey:
algorithm: ECDSA
size: 384
issuerRef:
name: selfsigned-bootstrap
kind: Issuer
---
# Step 2: a CA issuer backed by that root, used for real workload certs
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca-issuer
spec:
ca:
secretName: internal-ca-key-pair
Any Certificate referencing internal-ca-issuer gets a cert signed by your internal root - no external calls, no rate limits, works fully air-gapped. This is the standard pattern for internal mTLS: mesh sidecars, database-to-app TLS, and internal APIs that shouldn't depend on public ACME infrastructure or leak internal hostnames to Certificate Transparency logs.
Ingress annotations vs. Gateway API¶
Ingress (annotation-driven): cert-manager watches Ingress objects for the cert-manager.io/cluster-issuer annotation and synthesizes a Certificate on your behalf:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [api.example.com]
secretName: api-example-com-tls
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 80
Gateway API (native integration): cert-manager's gateway-shim component watches Gateway resources directly and provisions a Certificate for each TLS listener, driven by the same cert-manager.io/cluster-issuer annotation on the Gateway and native support for Gateway.spec.listeners[].tls.certificateRefs. This is no longer an emerging feature - it's the standard path today, and cert-manager's own project guidance is actively steering users toward it: with ingress-nginx reaching end-of-life in March 2026, the cert-manager maintainers have published migration guidance recommending a move to Gateway API (or another Ingress controller in the short term) rather than continuing to build new dependencies on ingress-nginx:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: api-gateway
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: istio
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: api.example.com
tls:
mode: Terminate
certificateRefs:
- name: api-example-com-tls
Either way, the underlying mechanism is identical: a Certificate gets created, cert-manager issues it, and a Secret is populated. The annotation approach just saves you from writing the Certificate YAML by hand.
Renewal mechanics and the consumer reload problem¶
cert-manager renews and rewrites the Secret's contents in place - same name, same namespace, new tls.crt/tls.key bytes. That's efficient, but it creates an operational gap: updating a Secret does not restart Pods that mount it, and most Ingress controllers/proxies cache TLS certs in memory after their first read.
- Ingress controllers like ingress-nginx and most Gateway API implementations watch mounted Secrets via the Kubernetes API and reload automatically - verify this is true for whatever you run before assuming it "just works."
- Application containers that read a mounted cert file directly generally do not watch for file changes; the kubelet does update the mounted file (with the usual
subPathcaveat -subPathmounts never update, same problem as ConfigMaps and Secrets generally), but the process holding the file open in memory won't pick up the new bytes without help. - The common fix is a controller like Reloader or Wave that watches Secrets for changes and triggers a rolling restart of dependent Deployments, or building the app to watch the file with
fsnotifyand re-read it.
This is the single most common cause of "cert-manager renewed the cert but the outage happened anyway" incidents - the renewal worked; nothing told the consuming process to reload.
trust-manager: distributing CA bundles cluster-wide¶
trust-manager is a companion project (from the same team) that solves a different problem: getting a CA bundle - your internal root, or a combined bundle of internal + public CAs - distributed as a ConfigMap into every namespace that needs to trust it (for verifying client certs, or for apps that need to trust an internal CA when calling other internal services).
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
name: internal-ca-bundle
spec:
sources:
- useDefaultCAs: false
- secret:
name: internal-ca-key-pair
key: tls.crt
target:
configMap:
key: ca-bundle.pem
namespaceSelector:
matchLabels:
trust-bundle: "true"
Any namespace labeled trust-bundle: "true" gets a ca-bundle.pem ConfigMap it can mount, kept in sync automatically if the source CA rotates. Without trust-manager, distributing an internal CA bundle to every namespace is a manual copy-paste job that goes stale.
The cert-manager team has announced a longer-term plan to move trust-manager toward a new trust-manager.io/v1alpha2 ClusterBundle resource (replacing today's trust.cert-manager.io/v1alpha1 Bundle and dropping trust-manager's webhook dependency), but as of this writing that redesign hadn't shipped - the Bundle API shown above remains the current, supported way to distribute trust bundles.
Troubleshooting a stuck Certificate¶
Work down the resource chain - the stuck object tells you where the problem is:
kubectl get certificate -n production api-example-com
kubectl describe certificate -n production api-example-com # Events show the current blocker
kubectl get certificaterequest -n production
kubectl describe certificaterequest -n production <name>
kubectl get order -n production
kubectl describe order -n production <name>
kubectl get challenge -n production
kubectl describe challenge -n production <name> # usually where the real error lives
Common failure modes:
| Symptom | Likely cause |
|---|---|
Certificate stuck Ready: False, no CertificateRequest created |
Issuer misconfigured or not Ready - check kubectl describe clusterissuer |
Challenge stuck in pending (HTTP-01) |
Ingress controller not routing /.well-known/acme-challenge/*, or port 80 blocked externally |
Challenge stuck in pending (DNS-01) |
DNS propagation delay, or the DNS provider API credentials/IAM permissions are wrong |
Order fails with urn:ietf:params:acme:error:rateLimited |
Let's Encrypt rate limits hit - 50 certs/registered domain/week, 5 duplicate certs/week. Use the staging ACME server (acme-staging-v02.api.letsencrypt.org) while iterating |
| Cert issues but never appears live | Consumer reload problem - see above, not a cert-manager bug |
Everything looks Ready but browsers show old cert |
Client-side or CDN caching, not cert-manager |
Always point new ClusterIssuer configs at Let's Encrypt's staging endpoint first. Staging certs aren't trusted by browsers, but they let you validate the whole HTTP-01/DNS-01 flow without burning against production rate limits - a botched DNS-01 IAM policy that you retry ten times against production can lock you out of issuance for that domain for a week.
Common mistakes¶
- Using HTTP-01 for a domain that needs a wildcard. ACME flatly rejects wildcard HTTP-01 orders - switch to DNS-01 before you even try.
- Pointing straight at the production ACME server while debugging. Rate limits are unforgiving and shared across the whole registered domain; use staging first.
- Forgetting the consumer reload problem. cert-manager renewing the Secret is not the same as the running process using the new cert - verify your Ingress/proxy/app actually reloads on Secret change.
- One ClusterIssuer, one giant DNS zone credential, over-scoped IAM. Scope the DNS-01 solver's cloud credentials to only the hosted zone(s) it needs to write TXT records to, not the whole DNS account.
- Manually editing the TLS Secret. cert-manager owns it; a hand edit gets overwritten on the next reconcile, and diverges from what's tracked in the
Certificatespec. - Not setting
renewBeforegenerously enough for a manual/degraded DNS-01 provider - if propagation is slow or the provider has occasional API flakiness, a renewal attempt that starts too close to expiry can run out the clock.
Source Links¶
- cert-manager documentation
- cert-manager on GitHub and its releases
- cert-manager supported releases / upgrade policy
- CNCF project page: cert-manager
- ACME issuer configuration (HTTP-01 and DNS-01)
- Certificate resource reference
- trust-manager
- Let's Encrypt rate limits
- Kubernetes: Gateway API TLS configuration
Related Concepts¶
- Argo CD - GitOps delivery of Issuer/Certificate manifests alongside application config
- Istio - service mesh mTLS that often layers on top of cert-manager-issued CA certs
- Operators and CRDs - the controller pattern cert-manager itself is built on
- ConfigMaps and Secrets - the mount-update/reload problem that also applies to renewed TLS Secrets
- RBAC - scoping who can create Certificates against which Issuers
- Troubleshooting - debugging a Certificate stuck without a Ready condition