DevOps

cert-manager on Kubernetes — HTTPS that renews itself (2026)

Install cert-manager, set up ClusterIssuers, choose HTTP-01 vs DNS-01, and debug failures. Verified against cert-manager 1.21 and Let's Encrypt rate limits.

Renewing TLS certificates manually is one of those tasks that works fine until it doesn’t — a cert expires on a Sunday night, half the platform goes down, and the runbook says “renew via the vendor portal, then update the K8s Secret, then restart the pods.” cert-manager removes that entire class of incident. This guide covers install, ClusterIssuer setup, when to use HTTP-01 vs DNS-01, and the specific failure modes that trip most teams up.

What cert-manager does

cert-manager is a Kubernetes controller that requests, renews, and rotates TLS certificates automatically. It supports Let’s Encrypt (the most common source), HashiCorp Vault, CyberArk, private PKI, and other issuers. In the ACME/Let’s Encrypt flow:

  1. You declare a Certificate resource (or an Ingress with an annotation)
  2. cert-manager talks to the CA (Let’s Encrypt) to request the cert
  3. Proves domain ownership via a challenge (HTTP-01 or DNS-01)
  4. Stores the issued cert in a Kubernetes Secret
  5. Renews automatically before expiry (default: when 2/3 of the certificate’s lifetime has elapsed — e.g., 30 days before expiry on a 90-day Let’s Encrypt cert)

Zero manual steps after the initial setup.

Install cert-manager (Helm, current version)

As of September 2026 the current release is cert-manager 1.21.2 (support window: 4 months per release, N+1 supported per cert-manager release policy — always at least two supported versions in flight).

Compatibility: Kubernetes 1.33 through 1.36. Older or newer clusters may need a different cert-manager version.

Install via Helm:

helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --version v1.21.2 \
  --set crds.enabled=true

crds.enabled=true installs the Custom Resource Definitions (Certificate, Issuer, ClusterIssuer, Order, Challenge, etc.) as part of the chart. Older docs sometimes recommend installing CRDs separately with a kubectl apply -f https://.../cert-manager.crds.yaml step — that’s fine but no longer necessary with the crds.enabled flag.

Verify install:

kubectl get pods -n cert-manager

Should show three Running pods: cert-manager, cert-manager-cainjector, cert-manager-webhook.

ClusterIssuer — always start with staging

ClusterIssuer is a cluster-scoped issuer any namespace can use. Issuer is the same thing scoped to one namespace. Most teams use ClusterIssuer exclusively — one issuer for the whole cluster, referenced from any workload.

Set up two ClusterIssuers: staging and production. This is the single most important lesson buried in cert-manager’s docs — Let’s Encrypt’s production limits are aggressive. Test everything against staging first.

Staging ClusterIssuer

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: YOUR_EMAIL@example.com
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx

Production ClusterIssuer

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: YOUR_EMAIL@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx

Same structure, different URL. Apply both. Point your first Ingress at letsencrypt-staging, verify the certificate issues cleanly, then switch the annotation to letsencrypt-prod.

HTTP-01 vs DNS-01 — pick the right solver

Let’s Encrypt needs to verify you control the domain. Two ways to prove it:

HTTP-01 (easier, most common)

cert-manager creates a temporary Ingress route serving a challenge file at /.well-known/acme-challenge/<token>. Let’s Encrypt hits your public URL, sees the token, marks the domain verified.

Requirements:

  • Public HTTP (port 80) reachable from Let’s Encrypt’s servers
  • An Ingress controller (nginx-ingress, Traefik, Contour) that cert-manager can inject a route into

Use when: simple domains, no wildcards, cluster is publicly reachable on port 80.

Fails when: your cluster is behind a firewall, on private IPs, or IPv6-only.

DNS-01 (required for wildcards, works everywhere)

cert-manager writes a temporary TXT record to your DNS zone. Let’s Encrypt queries the TXT record via DNS. No public HTTP needed.

Requirements:

  • API access to your DNS provider (Cloudflare, Route53, Google Cloud DNS, etc.)
  • API token/credentials scoped to only the specific zone

Use when:

  • You need a wildcard cert (*.YOUR_DOMAIN.com) — DNS-01 is the only way
  • Cluster isn’t publicly reachable on port 80
  • You want to issue certs for internal services

Example — Cloudflare DNS-01 solver:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod-dns
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: YOUR_EMAIL@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-dns-key
    solvers:
    - dns01:
        cloudflare:
          apiTokenSecretRef:
            name: cloudflare-api-token
            key: api-token
      selector:
        dnsZones:
        - "YOUR_DOMAIN.com"

Security note: the Cloudflare API token should have Zone.DNS edit permissions ONLY for the specific zone — not global account access. Store it in a Kubernetes Secret created outside git (Sealed Secrets, External Secrets Operator, or kubectl create secret from a local file that’s .gitignored).

Dual-solver ClusterIssuer

For clusters that serve some public domains via HTTP-01 and some internal zones via DNS-01, one ClusterIssuer can hold both solvers with a selector block on each. cert-manager picks the right solver based on the domain in the Certificate request.

First cert — the walkthrough

Two ways to request a cert. Pick one.

Annotate your Ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
  namespace: default
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-staging"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - myapp.YOUR_DOMAIN.com
    secretName: myapp-tls
  rules:
  - host: myapp.YOUR_DOMAIN.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: myapp
            port:
              number: 80

Apply. cert-manager sees the annotation + tls block, creates a Certificate behind the scenes, runs the challenge, stores the cert in myapp-tls. Total time: 30-90 seconds for HTTP-01 in the happy path.

Option B — Explicit Certificate resource

For more control (custom durations, DNS-01, wildcards):

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: myapp-cert
  namespace: default
spec:
  secretName: myapp-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
  - myapp.YOUR_DOMAIN.com
  - api.myapp.YOUR_DOMAIN.com
  duration: 2160h    # 90 days (Let's Encrypt max)
  renewBefore: 720h  # renew 30 days before expiry

When it fails — the debug chain

cert-manager creates a chain of resources for every certificate request:

Certificate -> CertificateRequest -> Order -> Challenge

The failure is always at one specific point. Start at the top and work down — the first resource with an error is where the actual problem lives.

Debug commands (run in order)

# 1. Is the Certificate ready?
kubectl describe certificate myapp-cert -n default

Look at the Status.Conditions and Events sections. If Ready: True, done. If not, note the error.

# 2. What CertificateRequest was created?
kubectl get certificaterequest -n default
kubectl describe certificaterequest <name> -n default

Shows request-level errors (e.g., ClusterIssuer misconfigured).

# 3. What Order was created?
kubectl get order -n default
kubectl describe order <name> -n default

Shows the ACME order state (pending, valid, invalid).

# 4. What Challenge is failing?
kubectl get challenge -n default
kubectl describe challenge <name> -n default

Shows the actual challenge attempt — HTTP-01 GET result, DNS-01 TXT lookup result.

Ninety percent of the time, the Challenge output tells you exactly what’s wrong.

Common failure modes

Failure 1 — HTTP-01 challenge unreachable

Symptom: Challenge is pending forever. Describe shows something like Waiting for HTTP-01 challenge propagation: failed to perform self check GET request 'http://myapp.YOUR_DOMAIN.com/.well-known/acme-challenge/xyz': Get "...": context deadline exceeded.

Root cause: Let’s Encrypt (or cert-manager’s own self-check) can’t reach your Ingress on port 80. Common reasons:

  • DNS not resolving to your Ingress IP yet — wait for propagation
  • Cloud provider firewall blocking port 80 (allow HTTP inbound)
  • Ingress controller only listening on HTTPS (port 443) — HTTP-01 needs port 80 open
  • Ingress rewrites /.well-known/* to something else — exclude the ACME path

Fix: ensure port 80 is publicly reachable and the Ingress passes /.well-known/acme-challenge/* unmodified to cert-manager’s temporary solver pod.

Failure 2 — DNS-01 propagation timeout

Symptom: Challenge stays pending, describe shows waiting for DNS-01 challenge propagation.

Root cause: the TXT record was created but Let’s Encrypt’s DNS resolvers haven’t picked it up yet.

Fix: patience — DNS-01 typically takes 30-120 seconds; some providers up to 5 minutes. If it persists:

  • Verify the TXT record actually exists: dig _acme-challenge.YOUR_DOMAIN.com TXT
  • Check the DNS provider’s API credentials are correct
  • Increase --dns01-recursive-nameservers-only and --dns01-recursive-nameservers flags on the cert-manager Deployment if your provider has slow propagation

Failure 3 — Let’s Encrypt rate limits hit

Symptom: Order fails with too many certificates already issued for this exact set of domains.

Root cause: Let’s Encrypt’s production rate limits are strict:

  • 50 certificates per registered domain per week (refills at 1 cert every ~202 minutes)
  • 5 duplicate certificates per exact name set per week (global across all accounts)
  • Up to 1,152 consecutive authorization failures per identifier (refills at 1 per identifier per day; resets to zero on next successful auth)

A misconfigured cluster in a loop can burn through 50 in an hour.

Fix:

  • Immediately switch the Issuer annotation on the affected Ingress to letsencrypt-staging to stop consuming production quota
  • Wait for the weekly rate limit window to reset
  • Debug the underlying issue on staging (staging’s limits are effectively unlimited: 30,000 new certs per second)
  • Once fixed on staging, switch back to production for the final good cert

Prevention: ALWAYS start with staging. Move to production only after a clean pass on staging.

Failure 4 — subPath Secret mounts miss rotated certs

Symptom: Certificate renews successfully in the Secret, but pods keep serving the expired cert.

Root cause: pods that mount the TLS Secret using subPath don’t see updates to the underlying Secret after rotation — Kubernetes doesn’t propagate Secret changes into subPath mounts.

Fix:

  • Mount the Secret WITHOUT subPath if possible
  • OR use Reloader (github.com/stakater/Reloader) or Trust Manager to trigger pod rollouts on Secret changes
  • OR restart pods on a schedule (every 60 days is safe for 90-day certs)

This is one of the silent-failure classes that only shows up 60-89 days after initial success.

Failure 5 — Shared ACME account key across clusters

Symptom: Sporadic Order failures with order already exists or race conditions when multiple clusters use the same issuer email + account key.

Root cause: two cert-manager instances (dev cluster + prod cluster) using the same privateKeySecretRef name for the same issuer email conflict at the ACME layer.

Fix: use different privateKeySecretRef names per cluster (e.g., letsencrypt-prod-account-key-dev vs letsencrypt-prod-account-key-prod). Each gets its own ACME account.

Wildcard certificates

Only DNS-01 can issue wildcards. HTTP-01 cannot prove ownership of subdomains you haven’t provisioned yet.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-cert
  namespace: default
spec:
  secretName: wildcard-tls
  issuerRef:
    name: letsencrypt-prod-dns
    kind: ClusterIssuer
  dnsNames:
  - "*.YOUR_DOMAIN.com"
  - "YOUR_DOMAIN.com"    # apex, since wildcard doesn't cover it

Notice *.YOUR_DOMAIN.com doesn’t include YOUR_DOMAIN.com (the apex). Include both explicitly if you need the apex covered.

Production defaults worth setting

Before you consider cert-manager “done”:

  • Prometheus metrics enabled — cert-manager exposes /metrics on port 9402. Alert on certmanager_certificate_expiration_timestamp_seconds - time() < 21*24*60*60 (21 days) to catch renewal failures early.
  • CertificateRequest cleanup — the --enable-certificate-owner-ref=true flag on the cert-manager Deployment garbage-collects orphan CertificateRequests when a Certificate is deleted.
  • Auto-approval limits — for large clusters, tune --concurrent-workers to avoid overwhelming the ACME API.
  • Backup the Let’s Encrypt account private key — the Secret referenced by privateKeySecretRef. Losing it means creating a new ACME account and losing your rate limit history.
  • Trust Manager for CA distribution — if you also serve certificates from a private CA, use Trust Manager (a cert-manager sub-project) to distribute the CA bundle to workloads.

Bottom line

cert-manager removes an entire category of on-call incident: TLS certs that silently expire. Install it once via Helm, create two ClusterIssuers (staging + production), point Ingresses at staging first with a cert-manager.io/cluster-issuer annotation, verify the challenge passes, then flip to production. HTTP-01 works for most cases; use DNS-01 for wildcards or when port 80 isn’t reachable. When something fails, walk the Certificate → CertificateRequest → Order → Challenge chain — the first resource with an error tells you exactly what’s wrong. Set up Prometheus alerts on impending expiry so a rotation that silently breaks doesn’t become a Sunday-night outage.

Skip the boilerplate

DevOps YAML Pack

40+ production-ready configs — Kubernetes, Docker, Terraform, Ansible, Helm, GitHub Actions. Every file commented. Copy, edit two lines, ship. MIT license, no watermarks.

Get the pack — ₹499 →