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.
kubectlconfigured 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 withkubectl get ingressclassand replace the name where it appears. - A domain name with an A record pointing
your_domainto the public IP of the ingress controller (its load balancer or node address). Let's Encrypt must reachhttp://your_domainfor 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-manageris the controller that requests and renews certificates.cert-manager-webhookvalidates cert-manager resources when you apply them.cert-manager-cainjectorinjects CA bundles into webhooks and API services.
TipTo pin a version, add
--version vX.Y.Zwith a release listed at github.com/cert-manager/cert-manager/releases. Upgrade later withhelm upgrade cert-manager jetstack/cert-manager -n cert-manager --reuse-values --version vX.Y.Z.
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 reachhttp://your_domain/.well-known/acme-challenge/.... Check the A record, that port 80 is open to the internet, and that the solveringressClassNamematches 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 withkubectl logs -n cert-manager deploy/cert-manager.429 Too Many RequestsorrateLimited. You hit a Let's Encrypt production rate limit, usually by retrying a broken setup. Switch back toletsencrypt-staginguntil issuance works, then return to production after the limit window passes.- Ingress serves a default certificate. The
secretNamein 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.
