BlogKubernetes

Kubernetes Ingress TLS with cert-manager and Let's Encrypt

Issue and auto-renew TLS certificates for Ingress with cert-manager, ACME, and a ClusterIssuer, and debug stuck orders before ACME rate-limits you.

Mar 25, 2025·4 min readIntermediate·By the SecOpsLog team · command-tested

An Ingress without TLS is a welcome mat. You terminate HTTPS at the edge so browsers stop warning, cookies stay secure, and you are not shipping credentials over cleartext inside the cluster boundary. cert-manager automates the boring part: talk to Let's Encrypt (or your private ACME), store the cert as a Secret, and renew it before it expires. The part that bites teams is not the happy path — it is a Certificate stuck in False while ACME rate-limits quietly stack up.

This walkthrough wires a ClusterIssuer, an Ingress that asks for a certificate, and the three kubectl checks that tell you whether ACME is progressing or stuck. Pair it with the networking lessons in Kubernetes fundamentals if Ingress itself is still fuzzy.

North-south path: user → Ingress → Service → Pods

TLS terminates at the Ingress controller. Everything after that is in-cluster routing to Ready pods.

North-south traffic path from external user through Ingress, Service and EndpointSlice to Pods An external user sends an HTTPS request to the Ingress controller which terminates TLS, then routes in-cluster to a ClusterIP Service, an EndpointSlice of live pod IPs, and the backing Pods. HTTPS · encrypted route host / path select load-balance External User GET :443 client Ingress TLS terminated ingress controller single HTTPS front door Service ClusterIP · vIP stable virtual IP never changes Endpoint Slice live pod IPs real · changing Pods app backends
HTTPS (TLS) In-cluster routing Backend pods
cert-manager ACME path for an Ingress

Each box is a Kubernetes object or external hop. If any box stalls, the Certificate stays unready and browsers keep failing.

1Ingress askstls.hosts + secretName2Certificate CRcert-manager creates it3Order + ChallengeACME http-01 or dns-014Let's Encryptissues leaf + chain5Secret writtentls.crt / tls.key6Ingress reloadscontroller picks Secret7Renew ~30d earlysame loop, no tickets

Install cert-manager and create a ClusterIssuer

Install from the official manifests (pin a version — floating latest on your certificate factory is a supply-chain joke). Then declare a ClusterIssuer that uses HTTP-01 against the Ingress controller that faces the internet. The solver's ingress.class must match the controller actually receiving traffic — nginx, traefik, and AWS ALB each need different solver config.

cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-account
solvers:
- http01:
ingress:
class: nginx

Use letsencrypt-staging first (acme-staging-v02) while you debug. Staging certificates are untrusted in browsers, but they do not burn your production rate limit when you fat-finger a Challenge. Only switch to prod once kubectl describe challenge shows State: valid end to end.

Annotate the Ingress so a Certificate appears

The cleanest pattern: put hosts under spec.tls and let cert-manager's ingress-shim create the Certificate. The annotation pins which issuer to use. The secretName you declare is where cert-manager writes tls.crt and tls.key — the Ingress controller reads the same Secret on reload.

ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
namespace: prod
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts: [shop.example.com]
secretName: shop-tls
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shop
port:
number: 80
bash — is the Certificate ready?live
kubectl get certificate -n prod shop-tls
NAME READY SECRET AGE
shop-tls True shop-tls 2m
kubectl describe certificate -n prod shop-tls | tail -20
Events should show Order → Challenge → CertificateIssued

When READY stays False

Read the Certificate, then the Order, then the Challenge. HTTP-01 needs the ACME client to reach http://shop.example.com/.well-known/acme-challenge/... from the public internet. DNS mistakes, a WAF blocking that path, or the wrong ingress class are the usual villains — not cert-manager itself. curl -v the challenge URL from outside the cluster before you blame the controller.

For wildcard certs or internal-only hostnames, switch the ClusterIssuer solver to dns-01 against Route53, Cloudflare, or your DNS provider. HTTP-01 cannot validate *.example.com, and it fails when the service is reachable only inside the VPC.

Challenge debug cheat sheet
Symptoms
Certificate Ready=False for >5m
Challenge Pending forever
404 on /.well-known/acme-challenge
NXDOMAIN for the host
Checks
kubectl describe challenge -n prod
curl -v the challenge URL from outside
Ingress class matches solver
DNS A/AAAA points at the LB
ACME rate limits are real
Let's Encrypt caps failed validations and duplicate certificates per hostname. Debug on staging. If you already tripped prod limits, wait — hammering apply will not help. For internal services, consider dns-01 with a private zone or Vault PKI instead of burning public ACME quota.

Where this goes next

TLS at the Ingress is table stakes. Next: mTLS between services, short-lived certs from Vault PKI, and NetworkPolicies so a compromised pod cannot roam. The Kubernetes security (CKS) path covers admission, network policy, and runtime guards together.

Go deeper in a courseKubernetes security (CKS-aligned)NetworkPolicy, TLS, admission, and runtime hardening in one track.View course

Related posts