Manually buying and renewing certificates is how sites go down on a Sunday. cert-manager runs inside the cluster, requests certificates from an authority such as Let’s Encrypt (free, automated), stores them in Secrets, and renews them before they expire. Your Ingress just references the Secret.
Versions in use on this date. cert-manager 1.6 (October 2021) with Kubernetes 1.23; version 1.7 is expected shortly. Release 1.6 removed the long-deprecated v1alpha2, v1alpha3 and v1beta1 API versions, so everything below uses cert-manager.io/v1. Let’s Encrypt’s current chain is rooted in ISRG Root X1; the old DST Root CA X3 expired on 30 September 2021, which broke very old clients.
How a certificate is issued
flowchart TB
ING[Ingress with annotation<br/>cert-manager.io/cluster-issuer] --> CERT[Certificate resource<br/>created by ingress-shim]
CERT --> CR[CertificateRequest]
CR --> ISS[ClusterIssuer letsencrypt-prod<br/>ACME account]
ISS --> ORD[Order]
ORD --> CH[Challenge]
CH -->|HTTP-01: temp pod and ingress path| LE[Lets Encrypt validates over port 80]
CH -->|DNS-01: TXT record in Route 53| LE
LE -->|signed certificate| SEC[Secret app-tls]
SEC --> NGX[ingress-nginx serves HTTPS]
CERT -.->|renews at two thirds of lifetime<br/>about 30 days before expiry| CR
Install
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.6.1 --set installCRDs=true
kubectl -n cert-manager get pods
# optional smoke test
cat <<'EOF2' | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata: { name: cm-test }
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata: { name: selfsigned, namespace: cm-test }
spec: { selfSigned: {} }
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: { name: test, namespace: cm-test }
spec: { secretName: test-tls, dnsNames: [test.example.com], issuerRef: { name: selfsigned, kind: Issuer } }
EOF2
kubectl -n cm-test get certificate; kubectl delete ns cm-test
Pin the chart version and manage CRDs deliberately in production (installCRDs=true is convenient; some teams apply CRDs separately so Helm upgrades never delete them).
Issuers: staging first
Let’s Encrypt has strict rate limits (for example, 50 certificates per registered domain per week and 5 duplicate certificates per week). Test with the staging endpoint, which issues untrusted certificates but has generous limits.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: letsencrypt-staging }
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef: { name: letsencrypt-staging-account }
solvers:
- http01: { ingress: { class: nginx } }
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: letsencrypt-prod }
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ops@example.com
privateKeySecretRef: { name: letsencrypt-prod-account }
solvers:
- http01: { ingress: { class: nginx } }
Use an Ingress annotation; cert-manager creates the Certificate and Secret:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
namespace: shop
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging # switch to letsencrypt-prod when it works
spec:
ingressClassName: nginx
tls:
- hosts: [shop.example.com]
secretName: shop-tls
rules:
- host: shop.example.com
http:
paths:
- { path: /, pathType: Prefix, backend: { service: { name: web, port: { number: 80 } } } }
kubectl -n shop get certificate,certificaterequest,order,challenge
kubectl -n shop describe certificate shop-tls
When the staging certificate shows Ready=True, change the annotation to letsencrypt-prod, delete the shop-tls Secret so a new one is issued, and verify with curl -vI https://shop.example.com.
HTTP-01 or DNS-01?
| HTTP-01 | DNS-01 | |
|---|---|---|
| Proof | File served at /.well-known/acme-challenge/ on port 80 |
TXT record _acme-challenge.<host> |
Wildcards (*.example.com) |
No | Yes |
| Needs public port 80 | Yes | No (works for internal hosts) |
| Credentials | None | DNS API access (Route 53, Cloudflare…) |
For Route 53 on EKS, give cert-manager’s service account an IAM role (IRSA) allowing route53:ChangeResourceRecordSets on the hosted zone, then:
solvers:
- dns01:
route53: { region: ap-south-1, hostedZoneID: Z123EXAMPLE }
selector: { dnsZones: ["example.com"] }
Debugging
Follow the chain in order: Certificate, CertificateRequest, Order, Challenge.
kubectl -n shop describe challenge
kubectl -n cert-manager logs deploy/cert-manager --tail=50
kubectl -n shop get ingress -o wide # the solver creates a temporary ingress
curl -v http://shop.example.com/.well-known/acme-challenge/test # reachable from the internet?
dig +short TXT _acme-challenge.shop.example.com
Frequent causes: DNS not yet pointing to the ingress; port 80 blocked or redirected before validation (HTTP-01 follows redirects to HTTPS poorly with some setups); a NetworkPolicy blocking the solver pod; the wrong class; rate limits (error text says so); missing IAM permission for DNS-01.
Operate it
- Alert before expiry: cert-manager exports
certmanager_certificate_expiration_timestamp_seconds; alert at under 14 days. - Keep issuers and Certificates in Git; use
Certificateresources directly for non-Ingress uses (mTLS, internal services with a private CA issuer). - Back up the ACME account Secrets, and watch for upgrade notes between versions.
- Let’s Encrypt certificates last 90 days and are renewed automatically at about two-thirds of their life.