Kubernetes

Ingress Controllers, Path Routing & TLS with cert-manager

Intermediate45 min to complete14 min read

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)
Kubernetes
Ingress
TLS
cert-manager
nginx
HTTPS
Let's Encrypt

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-service
  • app.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

bash
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=2

Wait for the controller pod to be ready:

bash
kubectl wait --for=condition=ready pod \
  -l app.kubernetes.io/component=controller \
  -n ingress-nginx \
  --timeout=120s

Get the external IP that the ingress controller is listening on:

bash
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/TCP

This 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:

bash
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=5678

Verify they're running:

bash
kubectl get deployments
# NAME   READY   UP-TO-DATE   AVAILABLE
# api    1/1     1            1
# web    1/1     1            1

Step 3: Create the Ingress Resource

bash
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
28EOF

The 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:

bash
kubectl get ingress app-ingress
# NAME          CLASS   HOSTS                 ADDRESS         PORTS   AGE
# app-ingress   nginx   app.yourdomain.com    52.14.123.45    80      30s

Test with curl (replace with your actual domain once DNS propagates):

bash
curl http://app.yourdomain.com/api
# hello from the api

curl http://app.yourdomain.com/web
# nginx default page HTML

Step 4: PathType Options

Three path types determine how the path is matched:

  • Prefix: matches if the request path begins with the specified path. /web matches /web, /web/, /web/page.
  • Exact: matches only if the request path is exactly the specified value. /web does 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

bash
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=true

Wait for cert-manager to be ready:

bash
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     Running

Step 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:

bash
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
16EOF

The 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:

bash
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
16EOF

Step 7: Add TLS to the Ingress

Update the Ingress to request a certificate and redirect HTTP to HTTPS:

bash
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
34EOF

The 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:

bash
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   45s

If it stays False, describe it:

bash
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 issued

Verify the certificate is valid:

bash
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 GMT

Test the HTTP-to-HTTPS redirect:

bash
curl -I http://app.yourdomain.com/api
# HTTP/1.1 308 Permanent Redirect
# Location: https://app.yourdomain.com/api

cert-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

bash
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-nginx

What's Next

  • Kubernetes Gateway API — the successor to Ingress with better multi-tenancy, HTTP matching, and traffic splitting support

Official References

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.