The first time I tried exposing a handful of services in a cluster, I created a LoadBalancer Service for each one and watched my cloud bill creep up with every new microservice. That’s the problem Ingress solves — a single entry point that intelligently routes HTTP and HTTPS traffic to many services based on hostnames and paths. In this article I’ll explain what Ingress actually is, why you need a controller (Kubernetes doesn’t ship with one by default), and walk through a complete setup using NGINX Ingress Controller, including TLS.
Ingress vs. Ingress Controller: The Distinction That Confuses Everyone
An Ingress resource is just a Kubernetes object — a set of routing rules you define in YAML. It does nothing on its own. An Ingress controller is the actual piece of software (usually a reverse proxy like NGINX, Traefik, or HAProxy) that watches for Ingress resources and configures itself to implement those rules. Without a controller running in your cluster, creating an Ingress resource has zero effect — it just sits there.
Prerequisites
- A working Kubernetes cluster (I’ll assume a cloud provider like AWS/GKE/AKS, but this also works on kind/minikube with adjustments)
kubectlconfigured against your cluster- Helm installed (optional but recommended for install)
- A DNS name you can point at your cluster, if you want real external access
Step 1: Install the NGINX Ingress Controller
The easiest way is via Helm:
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace
Verify it deployed correctly:
kubectl get pods -n ingress-nginx
Expected output:
NAME READY STATUS RESTARTS AGE
ingress-nginx-controller-7f9d9c6c4d-2plq9 1/1 Running 0 90s
On a cloud provider, this Helm chart provisions a LoadBalancer Service that gives you a public IP or hostname:
kubectl get svc -n ingress-nginx
NAME TYPE EXTERNAL-IP PORT(S)
ingress-nginx-controller LoadBalancer 34.111.22.187 80:31820/TCP,443:31821/TCP
That external IP is now your single entry point for everything.
Step 2: Deploy a Sample Application
Let’s give ourselves something to route to:
# app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-app
spec:
replicas: 2
selector:
matchLabels:
app: hello-app
template:
metadata:
labels:
app: hello-app
spec:
containers:
- name: hello-app
image: gcr.io/google-samples/hello-app:1.0
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: hello-app-service
spec:
selector:
app: hello-app
ports:
- port: 80
targetPort: 8080
kubectl apply -f app-deployment.yaml
Step 3: Create an Ingress Resource
Now the actual routing rule:
# hello-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: hello.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello-app-service
port:
number: 80
kubectl apply -f hello-ingress.yaml
kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
hello-ingress nginx hello.example.com 34.111.22.187 80 45s
Point hello.example.com at that IP in your DNS provider (an A record), and once it propagates:
curl http://hello.example.com
Step 4: Route Multiple Services by Path or Host
This is where Ingress really pays off. You can route different paths of the same host to different services:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: multi-path-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
ingressClassName: nginx
rules:
- host: myapp.example.com
http:
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: api-service
port:
number: 80
- path: /web(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: web-service
port:
number: 80
Or route entirely different hostnames to different backends within a single Ingress:
spec:
ingressClassName: nginx
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
- host: admin.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: admin-service
port:
number: 80
Step 5: Add TLS with cert-manager
Manually managing TLS certificates gets tedious fast, so I always install cert-manager to automate Let’s Encrypt certificates:
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set installCRDs=true
Create a ClusterIssuer:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: you@example.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: nginx
Then reference it in your Ingress, adding a tls block:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
tls:
- hosts:
- hello.example.com
secretName: hello-tls
rules:
- host: hello.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello-app-service
port:
number: 80
Apply it and cert-manager will automatically request, validate, and store a certificate as a Secret named hello-tls, renewing it before expiry.
Step 6: Debugging Ingress Issues
If traffic isn’t reaching your service, work through this checklist:
kubectl describe ingress hello-ingress
kubectl logs -n ingress-nginx deploy/ingress-nginx-controller
kubectl get endpoints hello-app-service
Empty endpoints usually means your Service selector doesn’t match any Pod labels. A 404 from NGINX itself (not your app) usually means the host header didn’t match any Ingress rule — check DNS and make sure you’re hitting the right hostname.
Security and Performance Best Practices
- Restrict access with
nginx.ingress.kubernetes.io/whitelist-source-rangefor internal-only services. - Enable rate limiting via
nginx.ingress.kubernetes.io/limit-rpsto protect against abuse. - Terminate TLS at the Ingress layer and keep internal traffic on ClusterIP Services for simplicity, unless you have strict mTLS requirements (in which case look at a service mesh like Istio or Linkerd).
- Set resource requests/limits on the Ingress controller Pods themselves — they handle real traffic and shouldn’t be starved.
- Use
NetworkPoliciesto restrict which namespaces can reach backend Services directly, bypassing Ingress.
Common Mistakes
- Forgetting
ingressClassName, which on newer clusters with multiple controllers installed leads to no controller picking up the resource at all. - Using
pathType: Prefixwhen you meantExact, leading to unexpected route matches. - Not setting up DNS before testing, then wrongly assuming the Ingress itself is broken.
Choosing Between Ingress Controllers
NGINX Ingress Controller is what I reach for by default because it’s mature, widely documented, and handles the vast majority of routing needs out of the box. But it’s worth knowing the landscape:
- Traefik — has a friendlier native dashboard and is popular in Docker/Kubernetes hybrid shops; configuration feels more modern but has a smaller annotation ecosystem than NGINX.
- HAProxy Ingress — favored where extreme performance and fine-grained load-balancing algorithms matter more than feature breadth.
- Istio Gateway / Gateway API — increasingly the direction the ecosystem is heading; the Kubernetes Gateway API is a newer, more expressive standard intended to eventually supersede the Ingress API, with better support for multiple protocols beyond HTTP and cleaner separation between infrastructure and application routing concerns.
- Cloud-native controllers (AWS Load Balancer Controller, GKE Ingress) — provision the cloud provider’s native load balancer directly rather than running a proxy Pod, which can simplify operations at the cost of some portability.
If you’re starting a new project today, I’d at least evaluate the Gateway API alongside classic Ingress, since it addresses several long-standing Ingress limitations (like multi-protocol support and clearer multi-team ownership models) more cleanly.
Rate Limiting and Abuse Protection
Beyond basic routing, Ingress is a natural place to protect backends from abusive traffic before it ever reaches your Pods:
metadata:
annotations:
nginx.ingress.kubernetes.io/limit-rps: "10"
nginx.ingress.kubernetes.io/limit-burst-multiplier: "5"
This limits each client IP to roughly 10 requests per second with a burst allowance, rejecting excess requests with a 503 before they ever hit your application containers — meaningfully cheaper than scaling Pods to absorb abusive traffic.
Canary Routing at the Ingress Layer
NGINX Ingress also supports weighted canary releases without needing a separate tool like Argo Rollouts, useful for lightweight gradual rollouts:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-canary
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
ingressClassName: nginx
rules:
- host: hello.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello-app-v2-service
port:
number: 80
This routes roughly 10% of traffic to the new version’s Service while the primary Ingress continues serving the rest, letting you validate a change against a small slice of real traffic before committing further.
Path Matching Pitfalls Worth Knowing
pathType behaves differently than many people expect on first use:
- Exact — the request path must match the specified path precisely, character for character.
- Prefix — matches based on a slash-separated URL path prefix;
/apimatches/api,/api/, and/api/v1/users, but not/apiv2. - ImplementationSpecific — matching behavior depends entirely on the controller, which is why regex-based rewrites (as used in the multi-path example above) require this type with NGINX specifically.
Getting pathType wrong is a very common source of “my Ingress rule matches everything” or “my Ingress rule matches nothing” bugs, so I always test with a couple of representative real request paths before trusting a rule in production.
Monitoring Ingress Traffic
The NGINX Ingress Controller exposes Prometheus metrics by default, which I’d wire up alongside whatever monitoring stack you’re already running (see the dedicated Prometheus guide):
kubectl get svc -n ingress-nginx ingress-nginx-controller-metrics
Key metrics worth alerting on: nginx_ingress_controller_requests broken down by status code (watch for a sustained rise in 5xx), and nginx_ingress_controller_request_duration_seconds for latency regressions — both give you visibility into problems at the edge before they’re reported by users.
Summary
Setting up Ingress in Kubernetes comes down to three pieces: install a controller (NGINX is the most common and battle-tested), define routing rules via Ingress resources, and layer TLS on top with cert-manager for real production use. Once this is running, you get a single, cost-efficient entry point that can route dozens of services by host or path, with centralized TLS termination and rate limiting.
