65.4 cert-manager and Let's Encrypt: free HTTPS that renews itself
Why: the problem
Browsers now mark plain HTTP pages as not secure, payment rules such as PCI DSS demand encryption, and HTTP/2 in browsers needs TLS. So every public host from 65.2 needs a certificate. Buying one per year and copying the files into a Secret by hand is how outages happen: the person who did it leaves, the reminder email goes to an old inbox, and one morning PhonePe-style checkout pages show a red warning because the certificate expired at midnight. cert-manager removes the human from that loop. It is a Kubernetes controller that adds a few objects: an Issuer or ClusterIssuer says where certificates come from, a Certificate says which names you need and which Secret to store them in, and cert-manager does the rest. With Let's Encrypt, a free public certificate authority, it uses the ACME protocol: Let's Encrypt asks you to prove you control the name, cert-manager answers the challenge, receives a certificate valid for 90 days and renews it automatically about 30 days before expiry. There are two challenge types. HTTP-01 asks you to serve a token at http://your-name/.well-known/acme-challenge/...; cert-manager creates a tiny solver pod and a temporary Ingress for it, so the name must already point at your controller on port 80. DNS-01 asks you to create a TXT record under _acme-challenge; cert-manager does that through the Route 53 API. DNS-01 is the only way to get a wildcard such as *.shop.example.com and works even for internal names, but needs DNS permissions. Always test with the Let's Encrypt staging server first, because production has rate limits, such as 50 certificates per registered domain per week, and a broken loop can lock you out for days. On kind we cannot pass a public challenge, so we first learn the objects with a self-signed ClusterIssuer and a local CA, which behave exactly the same from the Ingress side. Then we write the real staging and production ACME issuers with HTTP-01 and DNS-01 for EKS.
What: every term in plain English
- TLS certificate — A signed file that proves a server owns a name and carries its public key.
- cert-manager — Controller that requests, stores and renews certificates as Kubernetes objects.
- Issuer and ClusterIssuer — Where certificates come from; Issuer works in one namespace, ClusterIssuer in all.
- Certificate — Object listing DNS names, the issuer and the target Secret name.
- ACME — Protocol used by Let's Encrypt to prove control of a name and issue certificates.
- HTTP-01 — Challenge answered by serving a token on port 80 at /.well-known/acme-challenge/.
- DNS-01 — Challenge answered by a TXT record at _acme-challenge.name; needed for wildcards.
- Staging server — Let's Encrypt test endpoint with high limits and untrusted certificates.
- kubernetes.io/tls Secret — Secret with tls.crt and tls.key that Ingress controllers read.
- ingress-shim — cert-manager feature that creates a Certificate from Ingress annotations and the tls block.
How: step by step
- Install cert-manager from a pinned manifest. Three pods Running.
- Create a self-signed ClusterIssuer and a local CA. Issuers Ready.
- Request a Certificate for shop.example.com. Secret created.
- Use the Secret in an Ingress tls block. HTTPS works with the local CA.
- Let an annotation create the Certificate. ingress-shim at work.
- Write Let's Encrypt staging and production issuers. HTTP-01 ready for EKS.
- Add a DNS-01 Route 53 solver for a wildcard. *.shop.example.com.
- Check renewal and expiry. cmctl status.
Under the hood cert-manager creates a chain of objects, and knowing that chain makes every problem easy to trace. A Certificate creates a CertificateRequest holding a signing request. For ACME issuers the request creates an Order with Let's Encrypt, the Order creates one Challenge per name, and each Challenge creates either a solver pod with a temporary Ingress (HTTP-01) or a TXT record (DNS-01). When the Challenge is valid, the Order is finalised, the signed certificate comes back, and cert-manager writes it into the Secret. If anything is stuck, run kubectl describe on each object in that order; the Events at the bottom always name the failing step.
Example 1: Install cert-manager and issue a certificate from a local CA
Goal. Install cert-manager and issue a certificate from a local CA.
install.sh
# cert-manager-v1.19.1.yaml: the release manifest saved from the official cert-manager docs, pinned and reviewed
kubectl apply -f cert-manager-v1.19.1.yaml
kubectl wait -n cert-manager --for=condition=available deploy --all --timeout=180s
kubectl get pods -n cert-manager
local-ca.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: lab-ca
namespace: cert-manager
spec:
isCA: true
commonName: ch65-lab-ca
secretName: lab-ca
privateKey: {algorithm: ECDSA, size: 256}
issuerRef: {name: selfsigned, kind: ClusterIssuer}
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: lab-ca
spec:
ca:
secretName: lab-ca
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: shop-tls
namespace: shop
spec:
secretName: shop-tls
dnsNames: ["shop.example.com", "www.shop.example.com"]
duration: 2160h
renewBefore: 720h
issuerRef: {name: lab-ca, kind: ClusterIssuer}
What each line does
Three pods— controller, webhook and cainjector.selfsigned ClusterIssuer— Signs the CA certificate itself; only used to bootstrap.lab-ca— A CA certificate stored in a Secret, then used by the ca ClusterIssuer.duration and renewBefore— 90 days, renewed 30 days before expiry, matching Let's Encrypt behaviour.secretName shop-tls— Where tls.crt and tls.key land.
Green output
$ bash install.sh
deployment.apps/cert-manager condition met
deployment.apps/cert-manager-cainjector condition met
deployment.apps/cert-manager-webhook condition met
NAME READY STATUS
cert-manager-6c9b8d7f5-kq2xd 1/1 Running
cert-manager-cainjector-7f4b9c6d8-m8n2p 1/1 Running
cert-manager-webhook-5d8f7b9c4-t6w3z 1/1 Running
$ kubectl apply -f local-ca.yaml
$ kubectl get certificate -A
NAMESPACE NAME READY SECRET AGE
cert-manager lab-ca True lab-ca 15s
shop shop-tls True shop-tls 12s
$ kubectl get secret shop-tls -n shop -o jsonpath='{.type}'
kubernetes.io/tls
Expected result. cert-manager built a small CA and signed a 90-day certificate for two names into a TLS Secret, all from YAML. Swapping lab-ca for Let's Encrypt later changes only the issuerRef.
Example 2: Serve HTTPS from the Ingress and let an annotation create the Certificate
Goal. Serve HTTPS from the Ingress and let an annotation create the Certificate.
tls-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: storefront
namespace: shop
annotations:
cert-manager.io/cluster-issuer: lab-ca
spec:
ingressClassName: nginx
tls:
- hosts: ["shop.example.com", "www.shop.example.com"]
secretName: shop-web-tls
rules:
- host: shop.example.com
http:
paths:
- {path: /, pathType: Prefix, backend: {service: {name: web, port: {number: 80}}}}
test.sh
kubectl get secret lab-ca -n cert-manager -o jsonpath='{.data.ca\.crt}' | base64 -d > lab-ca.crt
curl -s --cacert lab-ca.crt --resolve shop.example.com:443:127.0.0.1 https://shop.example.com/
curl -sI --resolve shop.example.com:80:127.0.0.1 http://shop.example.com/ | head -3
echo | openssl s_client -connect 127.0.0.1:443 -servername shop.example.com 2>/dev/null | openssl x509 -noout -subject -issuer -dates
What each line does
cert-manager.io/cluster-issuer— ingress-shim sees this and creates a Certificate named after secretName.tls block— Tells the controller which Secret to use for which hosts.--cacert lab-ca.crt— We trust our lab CA only for this curl; no system change.308 redirect— Controllers usually redirect HTTP to HTTPS once a tls block exists.openssl s_client— Shows the subject, issuer and validity dates the server presents.
Green output
$ kubectl apply -f tls-ingress.yaml
$ kubectl get certificate -n shop
NAME READY SECRET AGE
shop-tls True shop-tls 3m
shop-web-tls True shop-web-tls 9s
$ bash test.sh
web says hi
HTTP/1.1 308 Permanent Redirect
Location: https://shop.example.com
subject=
issuer=CN=ch65-lab-ca
notBefore=Oct 25 02:20:11 2026 GMT
notAfter=Jan 23 02:20:11 2027 GMT
Expected result. The annotation alone produced a Certificate, the controller served it on 443 and redirected port 80. The dates show a 90-day life, and cert-manager will renew it in December without anyone remembering.
Example 3: Let's Encrypt issuers for EKS: staging, production and a DNS-01 wildcard
Goal. Let's Encrypt issuers for EKS: staging, production and a DNS-01 wildcard.
letsencrypt.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: platform-team@example.com
privateKeySecretRef: {name: le-staging-account}
solvers:
- http01:
ingress:
ingressClassName: nginx
---
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: le-prod-account}
solvers:
- selector:
dnsZones: ["shop.example.com"]
dns01:
route53:
region: ap-south-1
hostedZoneID: Z0123456789EXAMPLE
- http01:
ingress:
ingressClassName: nginx
wildcard.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: stores-wildcard
namespace: shop
spec:
secretName: stores-wildcard-tls
dnsNames: ["stores.shop.example.com", "*.stores.shop.example.com"]
issuerRef: {name: letsencrypt-prod, kind: ClusterIssuer}
What each line does
Staging first— Same flow, untrusted certificates, generous limits; switch the issuerRef once it works.email— Let's Encrypt sends expiry warnings here if renewal ever breaks.privateKeySecretRef— cert-manager creates the ACME account key in this Secret; never copy it into Git.dns01 route53— For names in the shop zone it writes _acme-challenge TXT records; the cert-manager service account needs a scoped IAM role through IRSA, like ExternalDNS.http01 fallback— Other names use a temporary solver Ingress on port 80.
Green output
$ kubectl apply -f letsencrypt.yaml
$ kubectl get clusterissuer
NAME READY AGE
letsencrypt-prod True 20s
letsencrypt-staging True 20s
$ kubectl apply -f wildcard.yaml
$ kubectl get challenges -n shop
NAME STATE DOMAIN AGE
stores-wildcard-1-2391077436-0 pending stores.shop.example.com 30s
$ kubectl describe certificate stores-wildcard -n shop | tail -3
Normal Issuing 2m cert-manager-certificates-issuing The certificate has been successfully issued
$ cmctl status certificate stores-wildcard -n shop | head -4
Name: stores-wildcard
Conditions:
Ready: True, Reason: Ready, Message: Certificate is up to date and has not expired
Renewal Time: 2026-12-24T03:10:42Z
Expected result. On EKS the DNS-01 solver proved control through Route 53 and a trusted wildcard certificate landed in the Secret. Renewal is scheduled for 24 December 2026 at 08:40 IST, about 30 days before expiry.
Every command, step by step (kind lab with kubectl)
-
Install
kubectl apply -f cert-manager-v1.19.1.yamlWhat it does: Controller, webhook, cainjector.
deployment.apps/cert-manager created -
Wait
kubectl wait -n cert-manager --for=condition=available deploy --all --timeout=180sWhat it does: Ready.
condition met -
CA
kubectl apply -f local-ca.yamlWhat it does: Issuers and certs.
clusterissuer.cert-manager.io/lab-ca created -
Certs
kubectl get certificate -AWhat it does: Ready True.
shop shop-tls True shop-tls -
Ingress
kubectl apply -f tls-ingress.yamlWhat it does: tls block.
ingress.networking.k8s.io/storefront configured -
HTTPS
curl -s --cacert lab-ca.crt --resolve shop.example.com:443:127.0.0.1 https://shop.example.com/What it does: Trusted by our CA.
web says hi -
Redirect
curl -sI --resolve shop.example.com:80:127.0.0.1 http://shop.example.com/What it does: 308.
HTTP/1.1 308 Permanent Redirect -
Dates
openssl x509 -noout -datesWhat it does: 90 days.
notAfter=Jan 23 02:20:11 2027 GMT -
Issuers
kubectl apply -f letsencrypt.yamlWhat it does: EKS only.
clusterissuer.cert-manager.io/letsencrypt-prod created -
Status
cmctl status certificate stores-wildcard -n shopWhat it does: Renewal time.
Ready: True
When it is red 1
Certificate stuck with READY False and an order pending
Cause. Challenge cannot complete: DNS not pointing at the controller, or port 80 blocked.
Fix, step by step
- kubectl get challenges -A and describe.
- curl the /.well-known path from outside.
When it is red 2
429 urn:ietf:params:acme:error:rateLimited
Cause. Too many production requests for one domain.
Fix, step by step
- Use the staging server while testing.
- Wait for the limit window to pass.
When it is red 3
x509: certificate signed by unknown authority
Cause. Staging or lab CA certificate used where a public one is expected.
Fix, step by step
- Switch issuerRef to letsencrypt-prod.
When it is red 4
DNS-01 AccessDenied on route53:ChangeResourceRecordSets
Cause. cert-manager service account lacks the IAM role.
Fix, step by step
- Attach a scoped role through IRSA or Pod Identity.
When it is red 5
Ingress serves the controller's fake certificate
Cause. Secret name in tls block differs from the Certificate's secretName, or Secret not yet created.
Fix, step by step
- kubectl get secret -n shop.
- Match the names.
Practice tasks
Easy Task 1: Install
Why: Setup.
What to do: Install cert-manager.
- Three pods.
Solution
kubectl get pods -n cert-managerEasy Task 2: CA
Why: Bootstrap.
What to do: Apply local-ca.yaml.
- selfSigned then ca.
Solution
kubectl get clusterissuerEasy Task 3: Cert
Why: Issue.
What to do: Check shop-tls is Ready.
- Secret type.
Solution
kubectl get certificate -n shopMedium Task 4: HTTPS
Why: Serve.
What to do: Apply tls-ingress.yaml and curl with --cacert.
- --resolve on 443.
Solution
curl -s --cacert lab-ca.crt --resolve shop.example.com:443:127.0.0.1 https://shop.example.com/Medium Task 5: openssl
Why: Inspect.
What to do: Read issuer and dates.
- s_client.
Solution
openssl s_client -connect 127.0.0.1:443 -servername shop.example.comMedium Task 6: Renew
Why: Force.
What to do: Renew early and compare dates.
- cmctl renew.
Solution
cmctl renew shop-tls -n shopHard Task 7: Issuers
Why: Write.
What to do: Staging and production with HTTP-01 and DNS-01.
- Scoped IAM.
Solution
kubectl get clusterissuerHard Task 8: PhonePe
Why: Main task.
What to do: merchant.example.com end to end.
- Lab CA on kind.
Solution
kubectl get certificate -AHomework: try at home
For PhonePe's merchant dashboard, install cert-manager on kind, build a lab CA, serve merchant.example.com over HTTPS with an annotation-created certificate and a 308 redirect, prove the issuer and dates with openssl, then write staging and production Let's Encrypt issuers including a DNS-01 Route 53 solver for *.merchant.example.com.
Recap
- cert-manager turns certificates into Kubernetes objects that renew themselves.
- Issuer or ClusterIssuer says where; Certificate says what names and which Secret.
- HTTP-01 needs port 80 reachable; DNS-01 needs DNS rights and allows wildcards.
- Test against Let's Encrypt staging before production to avoid rate limits.
- An annotation on the Ingress is enough for ingress-shim to create the Certificate.
FAQ
Q. Do I still need cert-manager with AWS ACM?
Not for certificates terminated on an ALB or NLB, since ACM renews those itself. You still need cert-manager for TLS inside the cluster, for controllers like NGINX or Traefik, and for internal mTLS.
Q. Where is the private key stored?
In the Kubernetes Secret named by secretName. Encrypt Secrets at rest and limit who can read them with RBAC.
Q. What happens if renewal fails?
The old certificate keeps working until expiry. cert-manager retries with backoff, and Let's Encrypt emails the account address. Alert on certmanager_certificate_expiration_timestamp_seconds.
Q. Can cert-manager issue certificates from a private CA such as AWS Private CA or Vault?
Yes, through external issuers or the built-in Vault issuer.
Interview questions
-
Explain HTTP-01 versus DNS-01.
Model answer: HTTP-01 serves a token on port 80 and cannot do wildcards; DNS-01 writes a TXT record, works for wildcards and private names, but needs DNS API rights.
-
How does cert-manager know when to renew?
Model answer: From renewBefore, or by default at two thirds of the certificate's life.
-
Issuer or ClusterIssuer?
Model answer: ClusterIssuer for a shared Let's Encrypt account across namespaces; Issuer when a team must own its own CA.
-
How would you debug a stuck certificate?
Model answer: Follow the chain: Certificate, CertificateRequest, Order, Challenge, and describe each.
Quick quiz
Q1. How long does a Let's Encrypt certificate last?
90 days.
Q2. Which challenge is needed for a wildcard?
DNS-01.
Q3. Why use staging first?
Production rate limits.
Q4. Which annotation makes ingress-shim act?
cert-manager.io/cluster-issuer.
Q5. What type is the resulting Secret?
kubernetes.io/tls.
Samajla ka?