Ingress Controllers, Path Routing & TLS with cert-manager
Set up an nginx ingress controller, configure host and path-based routing to multiple backends, and automate TLS certificate provisioning with cert-manager and Let's Encrypt. Covers IngressClass, annotations, and HTTPS redirect.
Before you begin
- kubectl and Helm installed
- A Kubernetes cluster with a LoadBalancer (EKS
- GKE) or MetalLB on bare metal
- A domain name you control (for TLS certificate provisioning)
Without an ingress controller, exposing 10 services to the internet means 10 separate cloud load balancers. At $15–25/month each on AWS, that's $150–250/month just for load balancing. An ingress controller is one load balancer in front of a Layer-7 proxy that routes incoming requests to the right service based on hostname and URL path. cert-manager handles TLS certificates automatically — provisioning, renewing, and rotating them without manual intervention.
What You'll Build
An nginx ingress controller routing two paths to two backends:
app.yourdomain.com/api→ api-serviceapp.yourdomain.com/web→ web-service
cert-manager provisioning a Let's Encrypt TLS certificate for app.yourdomain.com, automatically renewing 30 days before expiry.
Step 1: Install nginx Ingress Controller
1helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
2helm repo update
3
4helm install ingress-nginx ingress-nginx/ingress-nginx \
5 --namespace ingress-nginx \
6 --create-namespace \
7 --set controller.replicaCount=2Wait for the controller pod to be ready:
kubectl wait --for=condition=ready pod \
-l app.kubernetes.io/component=controller \
-n ingress-nginx \
--timeout=120sGet the external IP that the ingress controller is listening on:
kubectl get svc -n ingress-nginx ingress-nginx-controller --watch
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
# ingress-nginx-controller LoadBalancer 10.96.1.100 52.14.123.45 80:31234/TCP,443:31235/TCPThis EXTERNAL-IP is the IP you'll point your DNS A record to. Create an A record for app.yourdomain.com pointing to this IP before proceeding to the TLS steps. DNS propagation takes 1–15 minutes depending on your provider.
Step 2: Deploy Two Backend Services
Deploy simple backends to route to:
1kubectl create deployment web --image=nginx:1.25 --port=80
2kubectl expose deployment web --port=80
3
4kubectl apply -f - <<EOF
5apiVersion: apps/v1
6kind: Deployment
7metadata:
8 name: api
9spec:
10 replicas: 1
11 selector:
12 matchLabels:
13 app: api
14 template:
15 metadata:
16 labels:
17 app: api
18 spec:
19 containers:
20 - name: http-echo
21 image: hashicorp/http-echo:latest
22 args: ["-text=hello from the api", "-listen=:5678"]
23 ports:
24 - containerPort: 5678
25EOF
26kubectl expose deployment api --port=5678 --target-port=5678Verify they're running:
kubectl get deployments
# NAME READY UP-TO-DATE AVAILABLE
# api 1/1 1 1
# web 1/1 1 1Step 3: Create the Ingress Resource
1kubectl apply -f - <<EOF
2apiVersion: networking.k8s.io/v1
3kind: Ingress
4metadata:
5 name: app-ingress
6 annotations:
7 nginx.ingress.kubernetes.io/rewrite-target: /$2
8spec:
9 ingressClassName: nginx
10 rules:
11 - host: app.yourdomain.com
12 http:
13 paths:
14 - path: /web(/|$)(.*)
15 pathType: ImplementationSpecific
16 backend:
17 service:
18 name: web
19 port:
20 number: 80
21 - path: /api(/|$)(.*)
22 pathType: ImplementationSpecific
23 backend:
24 service:
25 name: api
26 port:
27 number: 5678
28EOFThe rewrite-target: /$2 combined with the capture group (/|$)(.*) in the path strips the /web or /api prefix before forwarding to the backend. Without this, your backend receives /web/some-page instead of /some-page, which breaks most applications.
Verify the Ingress was configured:
kubectl get ingress app-ingress
# NAME CLASS HOSTS ADDRESS PORTS AGE
# app-ingress nginx app.yourdomain.com 52.14.123.45 80 30sTest with curl (replace with your actual domain once DNS propagates):
curl http://app.yourdomain.com/api
# hello from the api
curl http://app.yourdomain.com/web
# nginx default page HTMLStep 4: PathType Options
Three path types determine how the path is matched:
Prefix: matches if the request path begins with the specified path./webmatches/web,/web/,/web/page.Exact: matches only if the request path is exactly the specified value./webdoes NOT match/web/.ImplementationSpecific: behavior defined by the ingress controller. nginx uses this for regex paths, which is why we used it above with the capture group pattern.
For most backends that handle their own routing (React apps, REST APIs), use Prefix. For specific endpoints that must match exactly, use Exact.
Step 5: Install cert-manager
1# Recommended: OCI registry (canonical source as of cert-manager v1.15+)
2helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
3 --namespace cert-manager \
4 --create-namespace \
5 --version v1.15.0 \
6 --set crds.enabled=true
7
8# Alternative: legacy Helm repo (still works, but OCI is now the primary source)
9# helm repo add jetstack https://charts.jetstack.io && helm repo update
10# helm install cert-manager jetstack/cert-manager \
11# --namespace cert-manager --create-namespace --set crds.enabled=trueWait for cert-manager to be ready:
1kubectl wait --for=condition=ready pod \
2 -l app.kubernetes.io/instance=cert-manager \
3 -n cert-manager \
4 --timeout=120s
5
6kubectl get pods -n cert-manager
7# NAME READY STATUS
8# cert-manager-... 1/1 Running
9# cert-manager-cainjector-... 1/1 Running
10# cert-manager-webhook-... 1/1 RunningStep 6: Create a ClusterIssuer
A ClusterIssuer tells cert-manager how to obtain certificates. Start with the Let's Encrypt staging environment to avoid rate limits while testing:
1kubectl apply -f - <<EOF
2apiVersion: cert-manager.io/v1
3kind: ClusterIssuer
4metadata:
5 name: letsencrypt-staging
6spec:
7 acme:
8 email: your-email@example.com
9 server: https://acme-staging-v02.api.letsencrypt.org/directory
10 privateKeySecretRef:
11 name: letsencrypt-staging-key
12 solvers:
13 - http01:
14 ingress:
15 ingressClassName: nginx
16EOFThe HTTP-01 challenge works by having cert-manager create a temporary path (/.well-known/acme-challenge/...) on your Ingress. Let's Encrypt verifies domain ownership by fetching that path over HTTP. For this to work, app.yourdomain.com must already resolve to your ingress controller's IP.
Once you've verified staging works, create the production issuer:
1kubectl apply -f - <<EOF
2apiVersion: cert-manager.io/v1
3kind: ClusterIssuer
4metadata:
5 name: letsencrypt-prod
6spec:
7 acme:
8 email: your-email@example.com
9 server: https://acme-v02.api.letsencrypt.org/directory
10 privateKeySecretRef:
11 name: letsencrypt-prod-key
12 solvers:
13 - http01:
14 ingress:
15 ingressClassName: nginx
16EOFStep 7: Add TLS to the Ingress
Update the Ingress to request a certificate and redirect HTTP to HTTPS:
1kubectl apply -f - <<EOF
2apiVersion: networking.k8s.io/v1
3kind: Ingress
4metadata:
5 name: app-ingress
6 annotations:
7 cert-manager.io/cluster-issuer: "letsencrypt-prod"
8 nginx.ingress.kubernetes.io/ssl-redirect: "true"
9 nginx.ingress.kubernetes.io/rewrite-target: /$2
10spec:
11 ingressClassName: nginx
12 tls:
13 - hosts:
14 - app.yourdomain.com
15 secretName: app-tls-secret
16 rules:
17 - host: app.yourdomain.com
18 http:
19 paths:
20 - path: /web(/|$)(.*)
21 pathType: ImplementationSpecific
22 backend:
23 service:
24 name: web
25 port:
26 number: 80
27 - path: /api(/|$)(.*)
28 pathType: ImplementationSpecific
29 backend:
30 service:
31 name: api
32 port:
33 number: 5678
34EOFThe annotation cert-manager.io/cluster-issuer: letsencrypt-prod triggers cert-manager to provision a certificate. The secretName: app-tls-secret is where cert-manager stores the TLS keypair. nginx reads that Secret and serves HTTPS automatically.
Step 8: Verify the Certificate
Watch the certificate being provisioned:
1kubectl get certificate
2# NAME READY SECRET AGE
3# app-tls-secret False app-tls-secret 15s
4
5# After 30-60 seconds:
6kubectl get certificate
7# NAME READY SECRET AGE
8# app-tls-secret True app-tls-secret 45sIf it stays False, describe it:
kubectl describe certificate app-tls-secret
# Events:
# Normal Issuing Issuing certificate as Secret does not exist
# Normal Generated Stored new private key in temporary Secret
# Normal Requested Created new CertificateRequest resource
# Normal Issuing The certificate has been successfully issuedVerify the certificate is valid:
curl https://app.yourdomain.com/api -v 2>&1 | grep -E "subject|issuer|expire"
# subject: CN=app.yourdomain.com
# issuer: C=US, O=Let's Encrypt, CN=R11
# expire date: Aug 22 10:30:00 2026 GMTTest the HTTP-to-HTTPS redirect:
curl -I http://app.yourdomain.com/api
# HTTP/1.1 308 Permanent Redirect
# Location: https://app.yourdomain.com/apicert-manager renews certificates at 2/3 of their duration by default. For Let's Encrypt's standard 90-day certificates, this means renewal triggers around day 60 — roughly 30 days before expiry. You never need to touch it again.
Common Mistakes to Avoid
Missing ingressClassName: nginx — if you omit this field, the Ingress resource is created but no controller claims it. The ADDRESS field stays empty and requests are never routed. Always specify ingressClassName.
Pointing DNS too late — Let's Encrypt's HTTP-01 challenge requires your domain to resolve to your ingress controller before the challenge runs. If DNS isn't propagated when cert-manager fires the challenge, the certificate request fails. Set up the DNS A record before applying the TLS Ingress.
Using the production issuer during testing — Let's Encrypt rate-limits failed certificate requests at 5 per hour per domain. If you misconfigure something and trigger repeated failures with the production issuer, you'll be locked out for an hour. Use letsencrypt-staging to iterate, then switch to letsencrypt-prod once it works.
rewrite-target: / without capture group — if you write rewrite-target: / with a path: /api (no capture group), requests to /api/users are rewritten to / instead of /users. Always use the (/|$)(.*) capture group pattern and rewrite-target: /$2.
TLS secret name collisions — if two Ingresses reference the same secretName in their tls block, cert-manager only issues one certificate. The second Ingress will serve the first domain's certificate, causing a TLS name mismatch for the second domain. Use a unique secretName per domain.
Cleanup
1kubectl delete ingress app-ingress
2kubectl delete clusterissuer letsencrypt-prod letsencrypt-staging
3kubectl delete deployment web api
4kubectl delete svc web api
5helm uninstall cert-manager -n cert-manager
6helm uninstall ingress-nginx -n ingress-nginx
7kubectl delete namespace cert-manager ingress-nginxWhat's Next
- Kubernetes Gateway API — the successor to Ingress with better multi-tenancy, HTTP matching, and traffic splitting support
Official References
- cert-manager Documentation — full reference including DNS-01 challenges for wildcard certs, private CAs, and renewal configuration
- nginx Ingress Annotations — complete list of
nginx.ingress.kubernetes.io/annotations for rate limiting, auth, rewrites, and more - Let's Encrypt Rate Limits — understand what limits apply before hitting them in production
- Ingress API Reference — Ingress spec, path types, and TLS configuration
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.