Ingress & the Gateway API

One entry point for many Services, why Ingress stalled as a spec, and how Gateway API's role-oriented model replaces annotation soup — with a migration path that does not require a flag day.

advanced 19 min lesson hands-on task included

A type: LoadBalancer Service gives you one cloud load balancer per Service, each with its own IP and its own bill. Ingress and Gateway API exist to put one entry point in front of many Services and route by hostname and path.


Topic 1: Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts: [shop.example.com]
      secretName: shop-tls
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /api(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: api
                port:
                  name: http
          - path: /
            pathType: Prefix
            backend:
              service:
                name: frontend
                port:
                  number: 80

The Ingress object does nothing on its own. It is a request that an ingress controller — nginx, Traefik, HAProxy, an AWS ALB controller — must be running to fulfil. An Ingress on a cluster with no controller sits there with no address, forever. That is the single most common “my Ingress does not work”.

kubectl get ingressclass
kubectl get pods -A | grep -i ingress
kubectl get ingress web        # ADDRESS empty = no controller acting on it

pathType matters:

TypeBehaviour
ExactExact string match
PrefixMatch on path segments/api matches /api/v1 but not /apifoo
ImplementationSpecificWhatever the controller wants — usually regex

Use Prefix unless you need regex. ImplementationSpecific is where portability dies.


Topic 2: Why Ingress Stopped Being Enough

Ingress reached networking.k8s.io/v1 in 1.19 and has barely changed since, because its design has structural limits:

1. It only really models HTTP. No TCP, no UDP, no gRPC as a first-class concept.

2. Everything beyond basic routing is an annotation. Timeouts, retries, rewrites, header manipulation, canary weights, rate limits, CORS, auth — all vendor-specific annotations. An Ingress is therefore not portable: moving from nginx to Traefik means rewriting every annotation, and the annotations are not validated, so a typo silently does nothing.

3. No separation of concerns. One object mixes infrastructure decisions (which load balancer, which TLS certificate, which port) with application routing (send /api to this Service). In a real organisation those belong to different people with different permissions, and Ingress gives you no way to split them.

4. No traffic splitting. Canary by percentage requires — you guessed it — controller-specific annotations.


Topic 3: Gateway API

Gateway API went GA in v1.0 (Oct 2023) and is now at v1.4 (Oct 2025). It is the successor, and it is installed as CRDs, not built into the API server:

kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml
kubectl get crd | grep gateway

Its central idea is three objects for three roles:

# 1. INFRASTRUCTURE PROVIDER — "this cluster has an nginx-based gateway implementation"
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: nginx
spec:
  controllerName: gateway.nginx.org/nginx-gateway-controller
---
# 2. CLUSTER OPERATOR — "here is a gateway, its ports, and its certificates"
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: prod-gateway
  namespace: infra
spec:
  gatewayClassName: nginx
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - name: wildcard-example-tls
      allowedRoutes:
        namespaces:
          from: Selector                    # WHO may attach routes here
          selector:
            matchLabels: { gateway-access: "true" }
---
# 3. APPLICATION DEVELOPER — "route my traffic"
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api
  namespace: payments
spec:
  parentRefs:
    - name: prod-gateway
      namespace: infra
  hostnames: ["shop.example.com"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: /api }
          headers:
            - name: x-api-version
              value: "2"
      filters:
        - type: RequestHeaderModifier
          requestHeaderModifier:
            set:
              - name: x-forwarded-prefix
                value: /api
      backendRefs:
        - name: api-v2
          port: 8080
          weight: 90                        # ← native traffic splitting
        - name: api-canary
          port: 8080
          weight: 10
      timeouts:
        request: 30s

What that buys you:

Role separation with real permissions. The platform team owns the Gateway (certificates, ports, which load balancer). Application teams own HTTPRoute objects in their own namespaces. allowedRoutes controls who may attach — enforced by the API, not by convention.

Portable, typed configuration. Header matching, rewrites, redirects, timeouts, retries and traffic weights are fields in the spec, validated by the API server. No annotations.

Cross-namespace routing with explicit permission (a ReferenceGrant in the target namespace), rather than the ambient trust Ingress assumes.

More than HTTP: HTTPRoute, GRPCRoute (GA in 1.1), TLSRoute, TCPRoute, UDPRoute.

Traffic splitting is native — the canary above needs no controller-specific anything.


Topic 4: Ingress vs Gateway API

CapabilityIngressGateway API
HTTP routingYesYes
TCP / UDP / TLS passthroughNoYes
gRPC as a first-class typeNoGRPCRoute
Traffic splitting / canaryAnnotationsNative weight
Header/method/query matchingAnnotationsNative
Header rewrite, redirectAnnotationsNative filters
Timeouts, retriesAnnotationsNative
Role separationNoneGatewayClass / Gateway / Route
Cross-namespaceImplicitExplicit ReferenceGrant
Portable between implementationsIn practice, noYes
Status reportingMinimalPer-route conditions

That last row is underrated: an HTTPRoute reports whether it was accepted by the Gateway and why not:

kubectl get httproute api -o jsonpath='{.status.parents[0].conditions}' | jq
# [{"type":"Accepted","status":"False","reason":"NoMatchingListenerHostname", ...}]

Ingress gives you nothing comparable — a misconfigured Ingress is simply ignored.


Topic 5: Migration and Choosing

You do not need a flag day. Both can serve simultaneously through the same controller, and several implementations offer conversion tooling (ingress2gateway).

A workable sequence:

  1. Install the Gateway API CRDs and an implementation (many controllers already support both).
  2. Create one Gateway alongside the existing Ingress.
  3. Migrate one low-risk HTTPRoute and verify.
  4. Move the rest incrementally; retire the Ingress objects last.

When to stay on Ingress: a small cluster, simple host/path routing, an existing controller that works, and no need for canaries or non-HTTP protocols. Ingress is not deprecated and is not going away. But it is frozen — every new capability lands in Gateway API, so anything greenfield should start there.

When a service mesh instead? Ingress and Gateway API handle north-south traffic (into the cluster). A mesh handles east-west (service-to-service): mTLS between pods, per-service retries and circuit breaking, and distributed tracing without application changes. They are complementary, not alternatives, and a mesh is a substantial operational commitment — adopt it for a specific requirement, not for completeness.


Topic 6: Debugging

# Is a controller running and is the class right?
kubectl get ingressclass
kubectl get gatewayclass
kubectl get pods -n ingress-nginx

# Did the object get an address?
kubectl get ingress web
kubectl get gateway prod-gateway -o wide

# Gateway API tells you WHY, in status
kubectl get gateway prod-gateway -o jsonpath='{.status.conditions}' | jq
kubectl get httproute api -o jsonpath='{.status.parents}' | jq

# Does the backend Service actually have endpoints?
kubectl get endpointslices -l kubernetes.io/service-name=api

# The controller's own view
kubectl logs -n ingress-nginx deploy/ingress-nginx-controller --tail=100
kubectl exec -n ingress-nginx deploy/ingress-nginx-controller -- cat /etc/nginx/nginx.conf | grep -A10 'shop.example.com'

# Bypass DNS and test the routing directly
curl -sS -H 'Host: shop.example.com' http://<ingress-ip>/api/healthz -v
SymptomCause
Ingress ADDRESS emptyNo controller, or ingressClassName does not match one
404 from the controllerNo rule matched — check host and path, and pathType
503Backend Service has no endpoints — pods not ready
TLS warningsSecret missing, wrong namespace, or not type kubernetes.io/tls
Works by IP, not by hostnameDNS is not pointing at the load balancer
Route silently ignoredIngress: nothing tells you. Gateway: read status.parents[].conditions

The curl -H 'Host: ...' trick is the fastest way to separate DNS problems from routing problems: if it works by IP with the right Host header, your routing is correct and DNS is the issue.

Try it yourself: Create an HTTPRoute whose hostnames do not match any listener on the Gateway. Read status.parents[0].conditions and note that the API tells you NoMatchingListenerHostname explicitly. Then do the equivalent with an Ingress and observe that nothing at all is reported.

Common mistake: Expecting an Ingress to work with no controller installed. The object is accepted by the API server because it is valid — validity and being acted upon are different things, and Kubernetes will happily store a request that nobody is listening for.