cert-manager is the standard Kubernetes controller for TLS certificates. It requests certificates from an issuer such as Let's Encrypt, stores them as Kubernetes Secrets and renews them before they expire. In this tutorial you will install cert-manager with Helm, create staging and production Let's Encrypt ClusterIssuers, protect an Ingress with a certificate issued automatically through the HTTP-01 challenge, and issue a wildcard certificate with the DNS-01 challenge.

Prerequisites

To follow this tutorial, you will need:

  • A running Kubernetes cluster, such as a CubePath managed Kubernetes cluster or a self-managed cluster on VPS or bare metal.
  • kubectl configured with cluster-admin permissions and Helm 3 installed on your workstation.
  • An ingress controller installed and reachable from the internet on ports 80 and 443. The examples use the IngressClass traefik; list yours with kubectl get ingressclass and replace the name where it appears.
  • A domain name with an A record pointing your_domain to the public IP of the ingress controller (its load balancer or node address). Let's Encrypt must reach http://your_domain for HTTP-01 validation.
  • For the wildcard section only: the domain's DNS hosted on Cloudflare and an API token with Zone - DNS - Edit permission for that zone.

Check that the domain resolves to your ingress address before you continue:

dig +short your_domain
203.0.113.10

Step 1 - Installing cert-manager with Helm

cert-manager publishes an official Helm chart. The crds.enabled=true value makes the chart install the CustomResourceDefinitions (Certificate, Issuer, ClusterIssuer and others) together with the controller.

Add the Jetstack repository and install the chart into its own namespace:

helm repo add jetstack https://charts.jetstack.io --force-update
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true

Verify that the three components are running:

kubectl get pods -n cert-manager
NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-7d9f8c6b5d-8kq2z              1/1     Running   0          45s
cert-manager-cainjector-5c7b9d8f6c-l4m7p   1/1     Running   0          45s
cert-manager-webhook-6f8d7c9b5c-x2n9w      1/1     Running   0          45s
  • cert-manager is the controller that requests and renews certificates.
  • cert-manager-webhook validates cert-manager resources when you apply them.
  • cert-manager-cainjector injects CA bundles into webhooks and API services.

Step 2 - Creating Let's Encrypt ClusterIssuers

An Issuer works in a single namespace, while a ClusterIssuer can issue certificates for any namespace. Two ClusterIssuers are a good default: staging, which issues untrusted certificates with generous rate limits for testing, and production, which issues trusted certificates with strict rate limits.

Create the manifest:

nano cluster-issuers.yaml

Replace [email protected] with an address where you can receive notices from Let's Encrypt, and traefik with your IngressClass:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
    - http01:
        ingress:
          ingressClassName: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
    - http01:
        ingress:
          ingressClassName: traefik

privateKeySecretRef is the Secret where cert-manager stores the ACME account key it registers. The http01 solver tells cert-manager to answer challenges by creating a temporary Ingress with your IngressClass.

Apply the file and check that both issuers registered their ACME account:

kubectl apply -f cluster-issuers.yaml
kubectl get clusterissuer
NAME                  READY   AGE
letsencrypt-prod      True    10s
letsencrypt-staging   True    10s

Step 3 - Deploying a test application

To have something to protect, deploy a basic NGINX web server in a new namespace:

kubectl create namespace web
kubectl create deployment hello --image=nginx:stable -n web
kubectl expose deployment hello --port=80 -n web

Confirm the Service has an endpoint:

kubectl get endpointslices -n web -l kubernetes.io/service-name=hello
NAME          ADDRESSTYPE   PORTS   ENDPOINTS     AGE
hello-8xk2p   IPv4          80      10.42.1.15    20s

Step 4 - Issuing a certificate for an Ingress

The simplest way to use cert-manager is the cert-manager.io/cluster-issuer annotation on an Ingress. cert-manager reads the tls section, creates a Certificate resource for the listed hosts and stores the result in the named Secret. The ingress controller then serves that Secret.

Start with the staging issuer so that a configuration mistake does not use up production rate limits:

nano hello-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello
  namespace: web
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
  ingressClassName: traefik
  tls:
  - hosts:
    - your_domain
    secretName: hello-tls
  rules:
  - host: your_domain
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: hello
            port:
              number: 80

Apply it and watch the certificate:

kubectl apply -f hello-ingress.yaml
kubectl get certificate -n web -w

After 30 to 90 seconds the certificate becomes ready. Press Ctrl+C to stop watching:

NAME        READY   SECRET      AGE
hello-tls   False   hello-tls   5s
hello-tls   True    hello-tls   48s

Check the issuer of the certificate being served. Staging certificates come from an untrusted test CA, so this command uses openssl instead of a browser:

echo | openssl s_client -connect your_domain:443 -servername your_domain 2>/dev/null | openssl x509 -noout -issuer -dates
issuer=C=US, O=(STAGING) Let's Encrypt, CN=(STAGING) ...
notBefore=Sep 25 09:12:03 2026 GMT
notAfter=Dec 24 09:12:02 2026 GMT

The (STAGING) issuer proves the whole flow works. Now switch to production by changing the annotation:

kubectl annotate ingress hello -n web cert-manager.io/cluster-issuer=letsencrypt-prod --overwrite

cert-manager notices that the issuer changed and requests a new certificate. When kubectl get certificate -n web shows READY True again, test with curl, which now trusts the certificate:

curl -I https://your_domain
HTTP/2 200
server: nginx/1.28.0
content-type: text/html

Step 5 - Issuing a wildcard certificate with DNS-01

Let's Encrypt only issues wildcard certificates such as *.your_domain through the DNS-01 challenge, where cert-manager proves control of the domain by creating a TXT record. This example uses Cloudflare; cert-manager also supports Route 53, Google Cloud DNS, Azure DNS, DigitalOcean and RFC 2136 providers such as PowerDNS or BIND.

Store the Cloudflare API token in a Secret. For a ClusterIssuer, the Secret must live in the cert-manager namespace:

kubectl create secret generic cloudflare-api-token \
  --namespace cert-manager \
  --from-literal=api-token='your_cloudflare_api_token'

Create a DNS-01 ClusterIssuer:

nano letsencrypt-dns.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-dns-account-key
    solvers:
    - dns01:
        cloudflare:
          apiTokenSecretRef:
            name: cloudflare-api-token
            key: api-token

Instead of an Ingress annotation, request the wildcard directly with a Certificate resource. This gives you full control over names, the target Secret and the renewal window:

nano wildcard-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard
  namespace: web
spec:
  secretName: wildcard-tls
  issuerRef:
    name: letsencrypt-dns
    kind: ClusterIssuer
  dnsNames:
  - your_domain
  - "*.your_domain"

Apply both files and wait for the certificate:

kubectl apply -f letsencrypt-dns.yaml
kubectl apply -f wildcard-cert.yaml
kubectl get certificate wildcard -n web -w

DNS-01 is slower than HTTP-01 because cert-manager waits for the TXT record to propagate. After one to three minutes you should see:

NAME       READY   SECRET         AGE
wildcard   True    wildcard-tls   2m10s

Any Ingress in the web namespace can now use secretName: wildcard-tls in its tls section without an issuer annotation.

Step 6 - Checking automatic renewal

cert-manager renews a certificate when two thirds of its lifetime has passed, which for 90-day Let's Encrypt certificates means about 30 days before expiry. You can see the scheduled renewal for any certificate:

kubectl describe certificate hello-tls -n web

Look at the Status section of the output:

Status:
  Conditions:
    Message:               Certificate is up to date and has not expired
    Reason:                Ready
    Status:                True
    Type:                  Ready
  Not After:               2026-12-24T09:12:02Z
  Not Before:              2026-09-25T09:12:03Z
  Renewal Time:            2026-11-24T09:12:02Z

No cron job or manual step is needed: the controller handles renewal and updates the Secret, and ingress controllers pick up the new certificate automatically.

Troubleshooting

cert-manager creates a chain of resources for each certificate: Certificate, then CertificateRequest, then ACME Order and Challenge. When a certificate stays READY False, walk down that chain:

kubectl describe certificate hello-tls -n web
kubectl get certificaterequest,order,challenge -n web
kubectl describe challenge -n web

Common causes:

  • HTTP-01 challenge stuck in pending. Let's Encrypt cannot reach http://your_domain/.well-known/acme-challenge/.... Check the A record, that port 80 is open to the internet, and that the solver ingressClassName matches your controller.
  • Waiting for DNS-01 challenge propagation. The TXT record is not visible yet, or the API token cannot edit the zone. Check the token permissions and the cert-manager logs with kubectl logs -n cert-manager deploy/cert-manager.
  • 429 Too Many Requests or rateLimited. You hit a Let's Encrypt production rate limit, usually by retrying a broken setup. Switch back to letsencrypt-staging until issuance works, then return to production after the limit window passes.
  • Ingress serves a default certificate. The secretName in the Ingress does not match the certificate's Secret, or the Secret is in another namespace. Secrets are only usable from their own namespace.

Conclusion

You installed cert-manager, configured staging and production Let's Encrypt issuers, secured an Ingress with a single annotation, issued a wildcard certificate with DNS-01 and confirmed that renewal is scheduled automatically. Next, you can add a Prometheus alert on the certmanager_certificate_expiration_timestamp_seconds metric, issue internal certificates from a private CA with a CA issuer, or use cert-manager to rotate a service mesh's identity certificates.