How to Implement Ingress Controllers with NGINX in Kubernetes

How to Implement Ingress Controllers with NGINX in Kubernetes

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/
Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Kubernetes Federation

How to Set Up Kubernetes Federation

Next Post
How to Set Up a Kubernetes CI/CD Pipeline

How to Set Up a Kubernetes CI/CD Pipeline

Related Posts