A cluster full of Services is invisible from the outside world by default — ClusterIP Services only route traffic within the cluster, and running a LoadBalancer Service per application gets expensive and unwieldy fast. Ingress solves the “one entry point, many backend routes” problem, and NGINX remains the most widely deployed Ingress controller for doing it.
Ingress vs Ingress Controller: A Distinction Worth Being Precise About
An Ingress resource is just a Kubernetes API object describing desired routing rules — hostnames, paths, TLS. On its own, it does nothing. An Ingress controller is the actual running component (NGINX, in this case) that watches Ingress resources and configures itself accordingly. Without a controller installed, creating Ingress objects has zero effect — a very common point of confusion for people new to the pattern.
Installing the NGINX Ingress Controller
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 \
--set controller.service.type=LoadBalancer
Verify:
kubectl -n ingress-nginx get pods,svc
NAME READY STATUS
pod/ingress-nginx-controller-6d7f9c8b7f-abcde 1/1 Running
NAME TYPE EXTERNAL-IP
service/ingress-nginx-controller LoadBalancer 34.120.10.5
That external IP (or hostname, on AWS) is what DNS records should point at — manually, or automatically via ExternalDNS.
Basic Ingress Resource
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
namespace: production
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 80
kubectl apply -f myapp-ingress.yaml
kubectl get ingress -n production
NAME CLASS HOSTS ADDRESS PORTS
myapp nginx app.example.com 34.120.10.5 80
Path-Based Routing to Multiple Services
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: multi-service
namespace: production
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend-api
port:
number: 8080
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 80
TLS with cert-manager
Automating certificate issuance is the standard pattern rather than manually managing TLS Secrets:
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--set crds.enabled=true
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
ingressClassName: nginx
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
namespace: production
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts: ["app.example.com"]
secretName: myapp-tls
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 80
kubectl describe certificate myapp-tls -n production
Status:
Conditions:
Type: Ready
Status: True
Reason: Ready
Rate Limiting and Common NGINX Annotations
metadata:
annotations:
nginx.ingress.kubernetes.io/limit-rps: "10"
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
These annotations are the escape hatch for the many NGINX behaviors that don’t have a first-class field in the Ingress spec — worth knowing since a lot of real production tuning happens entirely through annotations.
RBAC for the Ingress Controller
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: ingress-nginx
rules:
- apiGroups: ["", "networking.k8s.io"]
resources: ["ingresses", "services", "endpoints", "secrets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses/status"]
verbs: ["update"]
Backend Service and Deployment
For completeness, what the Ingress is actually routing to:
apiVersion: v1
kind: Service
metadata:
name: myapp
namespace: production
spec:
selector:
app: myapp
ports:
- port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: myapp
image: registry.example.com/myapp:1.0.0
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
Monitoring the Ingress Controller
NGINX Ingress exposes Prometheus metrics natively:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: ingress-nginx
namespace: ingress-nginx
spec:
selector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
endpoints:
- port: metrics
Useful PromQL:
# Request rate by ingress
sum(rate(nginx_ingress_controller_requests[5m])) by (ingress)
# 5xx error rate
sum(rate(nginx_ingress_controller_requests{status=~"5.."}[5m])) by (ingress)
# p95 request latency
histogram_quantile(0.95, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket[5m])) by (le, ingress))
High Availability
Run multiple controller replicas rather than one, and add a PodDisruptionBudget so cluster maintenance doesn’t drop the entry point entirely:
apiVersion: apps/v1
kind: Deployment
metadata:
name: ingress-nginx-controller
spec:
replicas: 3
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: ingress-nginx-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
Troubleshooting
# Confirm the Ingress was actually picked up by the controller
kubectl -n ingress-nginx logs deploy/ingress-nginx-controller | grep myapp
# Check backend service has ready endpoints
kubectl get endpoints myapp -n production
# 502/504 from NGINX usually means backend pods aren't passing readiness
kubectl describe pod -l app=myapp -n production
Canary Deployments via Ingress Annotations
NGINX Ingress supports a lightweight canary mechanism directly through annotations, without needing a full service mesh — useful for teams that want basic traffic splitting without adopting Istio or Linkerd purely for that purpose:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-canary
namespace: production
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "20"
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp-v2
port:
number: 80
This is layered alongside the primary Ingress pointing at myapp — 20% of traffic matching the same host gets routed to myapp-v2 instead, letting a new version absorb a small, controlled fraction of real production traffic before a full rollout. Canary annotations also support header-based and cookie-based splitting (canary-by-header, canary-by-cookie), useful for routing specific internal users or a QA team to the new version deliberately rather than by random weight.
Basic Authentication and IP Allowlisting
For internal-only endpoints that don’t warrant a full auth system, NGINX Ingress annotations cover the common cases directly:
metadata:
annotations:
nginx.ingress.kubernetes.io/auth-type: basic
nginx.ingress.kubernetes.io/auth-secret: basic-auth-secret
nginx.ingress.kubernetes.io/whitelist-source-range: "203.0.113.0/24,198.51.100.0/24"
htpasswd -c auth admin
kubectl create secret generic basic-auth-secret --from-file=auth -n production
Combining both — basic auth plus an IP allowlist — is a common, low-effort way to expose an internal admin panel or metrics endpoint through the same Ingress controller already handling public traffic, without standing up a separate internal-only load balancer.
Custom Error Pages and Default Backends
By default, NGINX Ingress returns its own generic 404/503 pages for unmatched routes or unavailable backends. Overriding this with a branded or more informative error page is a small but noticeable production polish detail:
apiVersion: apps/v1
kind: Deployment
metadata:
name: custom-error-backend
namespace: ingress-nginx
spec:
replicas: 2
selector:
matchLabels:
app: custom-error-backend
template:
metadata:
labels:
app: custom-error-backend
spec:
containers:
- name: error-backend
image: registry.example.com/custom-errors:1.0.0
helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--set controller.customErrorPageBackend=custom-error-backend.ingress-nginx.svc
Gateway API: Where Ingress Is Headed
Worth being aware of even in an NGINX-focused article: the Kubernetes project has been developing Gateway API as the eventual successor to Ingress, designed specifically to address Ingress’s reliance on vendor-specific annotations (exactly the nginx.ingress.kubernetes.io/* annotations used throughout this article) for anything beyond the most basic routing. Gateway API expresses the same concepts — and considerably more, including native traffic splitting and richer header-based routing — as first-class, portable API fields rather than controller-specific annotations:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: myapp
namespace: production
spec:
parentRefs:
- name: nginx-gateway
hostnames: ["app.example.com"]
rules:
- backendRefs:
- name: myapp
port: 80
NGINX supports Gateway API through a separate controller (NGINX Gateway Fabric) rather than the classic Ingress controller covered in this article. For new projects without a heavy existing investment in Ingress annotations, it’s worth at least evaluating Gateway API directly — but the classic NGINX Ingress controller remains extremely widely deployed, well-documented, and fully supported, and migrating an existing large Ingress-annotation-heavy setup is rarely urgent purely for its own sake.
Choosing Between Multiple Ingress Controllers in One Cluster
Larger clusters sometimes run more than one Ingress controller simultaneously — NGINX for general application traffic, alongside a specialized controller for a particular protocol or vendor requirement. ingressClassName is exactly the mechanism that makes this coexistence unambiguous:
kubectl get ingressclass
NAME CONTROLLER
nginx k8s.io/ingress-nginx
traefik traefik.io/ingress-controller
Each Ingress resource’s ingressClassName field determines which controller actually claims and configures it, letting different teams or different traffic types be routed through entirely separate, independently-scaled Ingress controllers within the same cluster without conflict.
Common Mistakes
- Creating Ingress resources without ever installing a controller, then wondering why nothing routes.
- Forgetting
ingressClassName, which — depending on cluster defaults — can leave an Ingress unclaimed by any controller. - Not automating TLS via cert-manager, leading to expired certificates causing unplanned outages.
- Running a single Ingress controller replica, making it a single point of failure for literally all external traffic to the cluster.
Summary
NGINX Ingress remains one of the most battle-tested ways to expose HTTP(S) services from a cluster: install the controller once, then define routing declaratively via Ingress resources per application, with TLS automated through cert-manager and traffic behavior tuned through annotations. Running it with multiple replicas and a PodDisruptionBudget is non-negotiable in production, since it sits directly in the path of every request the cluster serves.
References
- NGINX Ingress Controller documentation: https://kubernetes.github.io/ingress-nginx/
- Kubernetes Ingress concept docs: https://kubernetes.io/docs/concepts/services-networking/ingress/
- cert-manager documentation: https://cert-manager.io/docs/
- CNCF landscape: https://landscape.cncf.io/