Networking

Ingress & Ingress Controllers

● Intermediate ⏱ 15 min read

A Service of type LoadBalancer gives you one external IP for one service. For a cluster running a dozen services, that's a dozen cloud load balancers and a dozen bills. Ingress solves this: one load balancer at the cluster edge, one Ingress Controller that reads routing rules, and as many host/path rules as you need — all for free after the initial controller setup.

The Problem with LoadBalancers

Without Ingress, exposing multiple HTTP services to the internet looks like this: one LoadBalancer Service per app, each with its own cloud IP, each costing money. Routing between them requires DNS entries pointing to separate IPs. There's no shared TLS, no centralized auth, no path rewriting.

Without Ingress — one LB per service
Cloud LB #1
1.2.3.4:443
$$$
Cloud LB #2
1.2.3.5:443
$$$
Cloud LB #3
1.2.3.6:443
$$$
svc/web
svc/api
svc/auth
Every LoadBalancer Service provisions a separate cloud load balancer — expensive at scale

How Ingress Works

Ingress splits the problem into two pieces:

A single LoadBalancer Service exposes the Ingress Controller to the internet. All HTTP/HTTPS traffic flows in through that one IP, and the controller does the routing internally.

Ingress architecture — one LB, many services
Internet
client request
Cloud LoadBalancer
1.2.3.4:443
one LB · shared
Ingress Controller pod
nginx / traefik / envoy
reads Ingress rules · routes by host + path
app.example.com
svc/web
api.example.com
svc/api
app.com/auth
svc/auth
The Ingress Controller is the only pod the cloud LB talks to — everything else is internal routing
One cloud load balancer feeds all services. The Ingress Controller routes by host and path.
⚠️
An Ingress without a controller does nothing

Creating an Ingress resource in a cluster with no Ingress Controller installed has no effect. The resource sits there with no ADDRESS assigned. Check kubectl get ingress — if the ADDRESS column is empty, a controller is missing or misconfigured.

Ingress Controllers

Kubernetes ships no built-in Ingress Controller — you install one. The most widely used options:

ControllerProxyBest for
ingress-nginxnginxGeneral-purpose, most community resources, good annotation coverage
TraefikTraefikAutomatic cert renewal (Let's Encrypt), Docker/K8s-native config, middleware plugins
AWS ALB ControllerAWS ALBEKS clusters — uses native ALB, supports WAF and shield
GKE IngressGoogle LBGKE clusters — uses Google's global HTTP(S) LB natively
HAProxy IngressHAProxyHigh-throughput, bare-metal, fine-grained ACL control

Install ingress-nginx with Helm (works on any cluster including local kind/minikube):

shell
helm upgrade --install ingress-nginx ingress-nginx \
  --repo https://kubernetes.github.io/ingress-nginx \
  --namespace ingress-nginx --create-namespace \
  --set controller.service.type=LoadBalancer

On a local cluster (kind), use NodePort instead of LoadBalancer and add a host-network config — or use kubectl port-forward during development.

Host-based Routing

Route traffic to different Services based on the Host HTTP header. The most common pattern for multi-tenant clusters where each service owns a subdomain.

host-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: multi-host
  namespace: default
spec:
  ingressClassName: nginx
  rules:
  - host: app.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web
            port:
              number: 80
  - host: api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: api
            port:
              number: 8080

Path-based Routing

Route different URL paths on the same hostname to different backend Services. Useful for splitting a monolith into micro-services without changing the public API surface.

path-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: path-split
  namespace: default
spec:
  ingressClassName: nginx
  rules:
  - host: example.com
    http:
      paths:
      - path: /api
        pathType: Prefix      # matches /api, /api/v1, /api/users/…
        backend:
          service:
            name: api
            port:
              number: 8080
      - path: /static
        pathType: Prefix
        backend:
          service:
            name: cdn
            port:
              number: 80
      - path: /
        pathType: Prefix      # catch-all — must be last
        backend:
          service:
            name: web
            port:
              number: 80
Routing decision tree — host then path
incoming request
GET /api/v1/users
Host: example.com
rule match: host
example.com ✓
rule match: path (longest prefix wins)
/api ✓ (beats /)
/static
svc/cdn
/api ← matched
svc/api
/ (catch-all)
svc/web
Longest-prefix rule wins — /api beats / for the path /api/v1/users
The Ingress Controller evaluates host first, then path. The most specific (longest prefix) path rule wins.

pathType values:

pathTypeBehaviour
PrefixMatches the path and any sub-path (/api matches /api, /api/v1, /api/users)
ExactMatches only the exact path, case-sensitive
ImplementationSpecificInterpretation is up to the Ingress Controller — avoid unless you know the controller

TLS Termination

The Ingress Controller handles TLS termination — clients connect over HTTPS, the controller decrypts the traffic, and forwards plain HTTP to backend pods. You store the certificate and key in a TLS Secret.

tls-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: example-tls
  namespace: default
type: kubernetes.io/tls
data:
  tls.crt: <base64-encoded-cert>
  tls.key: <base64-encoded-key>
tls-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: secure-ingress
  namespace: default
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - example.com
    - api.example.com
    secretName: example-tls    # must contain tls.crt and tls.key
  rules:
  - host: example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web
            port:
              number: 80
TLS termination at the Ingress Controller
client
HTTPS :443
🔒 encrypted
Ingress Controller
TLS terminate
reads Secret: example-tls
🔓 decrypted here
backend pod
HTTP :8080
plain HTTP (in-cluster)
TLS cert lives in a Secret. The controller reads it, terminates HTTPS, forwards unencrypted to pods.
For end-to-end encryption use a service mesh (Istio/Linkerd) or ssl-passthrough mode.
TLS termination happens at the controller — backend pods receive plain HTTP inside the cluster network.

For automatic certificate provisioning (Let's Encrypt), install cert-manager and add an annotation:

metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod

cert-manager watches Ingress objects with that annotation, requests a certificate automatically, and stores it in the named Secret. Renewal is also automatic.

Annotations

Ingress annotations configure controller-specific behaviour that doesn't fit in the standard spec. They're controller-specific — annotations that work on nginx-ingress do nothing on Traefik or the AWS ALB controller.

common nginx-ingress annotations
metadata:
  annotations:
    # Force HTTPS — redirect HTTP to HTTPS
    nginx.ingress.kubernetes.io/ssl-redirect: "true"

    # Rewrite the URL path before forwarding to backend
    # /app/users → /users
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    # with path: /app(/|$)(.*)

    # Increase proxy timeouts (seconds)
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "120"

    # Enable CORS
    nginx.ingress.kubernetes.io/enable-cors: "true"
    nginx.ingress.kubernetes.io/cors-allow-origin: "https://app.example.com"

    # Rate limiting
    nginx.ingress.kubernetes.io/limit-rps: "10"

    # Basic auth
    nginx.ingress.kubernetes.io/auth-type: basic
    nginx.ingress.kubernetes.io/auth-secret: basic-auth-secret
    nginx.ingress.kubernetes.io/auth-realm: "Protected"
💡
Annotation sprawl is a real problem

Complex setups end up with 15–20 annotations per Ingress, each controller has different annotation keys, and there's no schema validation. This is one of the main reasons Gateway API exists — it moves configuration into typed resources instead of free-form strings.

IngressClass

When multiple Ingress Controllers are installed (e.g. nginx for public traffic, Traefik for internal), you use IngressClass to tell each Ingress resource which controller should handle it.

# Each controller registers an IngressClass
kubectl get ingressclass
# NAME    CONTROLLER             PARAMETERS   AGE
# nginx   k8s.io/ingress-nginx   <none>       2d
# traefik traefik.io/ingress     <none>       1d

# Reference it in your Ingress:
spec:
  ingressClassName: nginx    # or traefik

One IngressClass can be set as the cluster default (annotation ingressclass.kubernetes.io/is-default-class: "true") — Ingress objects without an explicit ingressClassName are handled by the default controller.

Ingress vs Gateway API

The Ingress spec was designed in 2015 for basic HTTP routing. It shows its age: no TCP/UDP support, no traffic splitting, no header manipulation without annotations, no multi-tenant separation between routes and gateways. Gateway API (GA in Kubernetes 1.31) is the official successor.

IngressGateway API
Protocol supportHTTP/HTTPS onlyHTTP, HTTPS, TCP, UDP, gRPC, TLS
Traffic splittingVia annotations (controller-specific)Native weight on routes
Header manipulationAnnotationsTyped filter fields
Multi-tenancySingle Ingress resource owns routesGateway owned by infra team; HTTPRoute owned by app teams
StatusStable, widely supportedGA in 1.31, growing support
MigrationMost major controllers have Gateway API support

For new clusters, prefer Gateway API if your controller supports it. For existing Ingress setups, there's no urgency — Ingress is not deprecated and will continue to work.

kubectl Commands

# Apply an Ingress
kubectl apply -f ingress.yaml

# List Ingress resources (ADDRESS column shows the assigned IP)
kubectl get ingress
kubectl get ingress -A    # all namespaces

# Describe an Ingress (shows rules, backend services, TLS, events)
kubectl describe ingress my-ingress

# Check which pods the Ingress Controller is routing to
kubectl get endpoints -n default web api auth

# Watch Ingress Controller logs (nginx-ingress)
kubectl logs -n ingress-nginx \
  -l app.kubernetes.io/component=controller \
  --tail=50 -f

# Test routing with curl (override Host header)
curl -H "Host: api.example.com" http://<INGRESS_IP>/v1/health

# List IngressClasses
kubectl get ingressclass

# Delete an Ingress
kubectl delete ingress my-ingress