Kubernetes Gateway API: from zero to hero
Ingress is feature-frozen and ingress-nginx is retired. Here is its successor end to end: four typed resources, the platform-vs-app split, canary, TLS, multi-team, and the mesh, built on one running shop.
Viktor Vedmich · 14 min read
In March 2026, ingress-nginx was retired. About half of all Kubernetes clusters ran it, and now there are no more releases and no more CVE patches, ever. That is the forcing function that turns “I should look at Gateway API eventually” into “I need to understand this now.”
So let’s understand it. This is the companion writeup to a talk I give as a from-zero-to-hero walkthrough: we take one online shop and route all of its traffic the modern way, growing the design one concept at a time. It is vendor-neutral and current as of Gateway API v1.5 (February 2026). The slides are embedded throughout, so you can click through the animated ones yourself. A follow-up post will make all of this concrete on AWS.
One terminology note first, because it trips up everyone. This is the Gateway API, the Kubernetes standard. It is not “API Gateway”, which is a generic architectural pattern and also an AWS product. Different things.
Ingress is done. This is what replaced it.
I spend exactly one slide on the “why now”, and so will this section. Four facts:
- ingress-nginx is retired (March 2026). Roughly half of all clusters ran it. The repos are read-only and there will be no patches for any future CVE.
- Gateway API is the standard. It hit v1 GA in October 2023 and reached v1.5 in February 2026. It is SIG Network’s official successor to Ingress.
- Every implementation supports it. GKE, AKS, Istio, Cilium, Envoy Gateway, NGINX Gateway Fabric, and more. Seven implementations were already conformant with v1.5 on release day.
- The
IngressAPI still works, but it is feature-frozen. It is stable and will keep working for years. All new capability lands in Gateway API. If you are starting today, you start here.
That is the entire history portion. The rest is the “what” and the “how”.
How does a request even reach a Pod?
Before any YAML, here is the fundamental problem. A browser has a URL, shop.example.com. Your code runs in a Pod with an internal IP like 10.1.2.7 that changes on every restart and every scale event. You can never point DNS at a Pod. Something has to bridge that gap.
Kubernetes already solves half of this with a Service: a stable virtual IP and DNS name in front of a set of Pods. So inside the cluster, Service-to-Pod is handled. But the Service is internal. Something still has to take external traffic, on a real hostname and port, and get it to the right Service. That missing piece is exactly what Gateway API models: the typed, role-owned way to define the entry point and the routing. Ingress filled this slot before. Gateway API is the better-structured answer.
Why Ingress was not enough
It is worth being precise about what Ingress got structurally wrong, because Gateway API fixes each of these by design, not by adding more configuration.
- Everything lived in annotations. TLS, rewrites, timeouts, CORS, canary, all untyped strings. ingress-nginx had over a hundred of them. A misspelled key is silently ignored, and you find out at 3am.
- One blob, one owner. A developer who just wanted to add a path route had to edit the same object that carried the cluster’s TLS secrets and load balancer config. No separation of duties.
- Vendor lock-in by annotation. A
nginx.ingress.kubernetes.io/...key means nothing to Traefik or HAProxy. Switching controllers meant rewriting everything. - No native L7 expressiveness. Header routing, traffic weights, method matching, none of it was in the spec. Every controller bolted on its own version through, you guessed it, more annotations.
Keep these four in mind. Each one gets solved structurally below.
Gateway API in one breath
If you remember one sentence, this is it. Gateway API takes the single Ingress blob and splits it into typed resources, each owned by the right team, that attach to each other to describe traffic from the edge of the cluster all the way to your Pods.
Four properties fall out of that: it is role-oriented, everything is typed and validated, it is vendor-neutral so the same YAML works everywhere, and the same API covers both ingress (north-south) and service mesh (east-west). Now let’s earn that sentence by building the resources one at a time.
The four resources
Our running example is a deliberately simplified version of Google’s Online Boutique, so it maps to anything you have seen: five services. Four of them (web, products, cart, orders) are owned by the shop team. The fifth, payments, lives in a different team’s namespace. Hold onto that detail. It is the reason cross-namespace routing matters later.
1. GatewayClass: the template
A cluster-scoped template that names which controller implements gateways of this class. The cleanest analogy is StorageClass: you do not define a disk, you ask for a disk of class fast-ssd and a provisioner handles it. Same here. It decouples your routing config from the implementation, and it is created once by the infrastructure provider. Application teams never touch it.
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: prod
spec:
# the controller that will program real infrastructure
controllerName: example.net/gateway-controller2. Gateway: the entry point
This is the hero of the model. When you create a Gateway, the controller provisions a real load balancer or proxy. It has listeners (ports, protocols, TLS), and one Gateway is one front door to your cluster. It is also the ownership boundary for infrastructure concerns: TLS certificates live here, and allowedRoutes decides which namespaces are permitted to attach routes. That is the governance lever the platform team holds.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shop-gateway
namespace: infra
spec:
gatewayClassName: prod
listeners:
- name: https
port: 443
protocol: HTTPS
allowedRoutes:
namespaces:
from: Selector # All | Same | Selector
selector:
matchLabels:
gateway-access: "true"3. HTTPRoute: the rules
This is where developers live. It matches on hostname and path and forwards to a backend Service, and it connects to a Gateway through parentRefs. It is the only resource an application developer needs to touch: typed, schema-validated, and scoped to their own namespace. No cluster rights, no TLS access. Note that parentRefs points at the Gateway in the infra namespace, a cross-namespace reference that is allowed for attaching to Gateways and gated by that Gateway’s allowedRoutes.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
namespace: shop
spec:
parentRefs:
- name: shop-gateway
namespace: infra
hostnames: ["shop.example.com"]
rules:
- matches:
- path: { type: PathPrefix, value: / }
backendRefs:
- name: web
port: 80804. Service and Pods: the destination
The good news: you already know this one. It is a standard Kubernetes Service and Pods, nothing Gateway-API-specific. It matters in the chain because Pods are ephemeral, they restart and reschedule and their IPs change, while the Service is the durable thing. That is why an HTTPRoute targets a Service, never a Pod directly. backendRefs simply names a Service that already exists. It is the bridge from Gateway API into the Kubernetes you already run.
Who owns what
Here is the conceptual heart, the same chain colored by ownership:
Remember the Ingress problem: one blob, one owner, developers forced to touch cluster config. This three-way split is the structural fix. Each team gets exactly the surface it needs and nothing more.
The assembled journey
Now the payoff. The four resources plus Service and Pod, assembled, following one request from the browser through GatewayClass, the Gateway, the HTTPRoute, the Service, to a healthy Pod:
Hands-on: route the shop
Time to deploy. The Gateway API CRDs are not built into Kubernetes, you install them, and the great property is that this works on any Kubernetes 1.30 or newer. You do not upgrade Kubernetes to get a newer Gateway API, you just apply newer CRDs.
# 1. Install the Gateway API CRDs (Standard channel, GA resources)
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/\
releases/download/v1.5.0/standard-install.yaml
# 2. Verify the CRDs are present
kubectl get crd | grep gateway.networking.k8s.io
# 3. Install a controller (NGINX Gateway Fabric, Istio, Cilium,
# Envoy Gateway, or a cloud provider's). Each ships a GatewayClass.
kubectl get gatewayclass
# NAME CONTROLLER ACCEPTED
# prod example.net/gateway-controller TrueThe CRDs are the vocabulary; a controller is what actually watches these objects and programs real infrastructure. Verify it shows ACCEPTED: True and you are ready.
Next, the platform team creates the front door, one HTTP listener for now, with the governance label that controls who may attach:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shop-gateway
namespace: infra
spec:
gatewayClassName: prod
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"Then the shop team labels its namespace to fit that lock, and attaches a route. Two YAML files, two teams, neither needing the other’s permissions:
apiVersion: v1
kind: Namespace
metadata:
name: shop
labels:
gateway-access: "true" # the key that fits the Gateway's lock
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
namespace: shop
spec:
parentRefs:
- name: shop-gateway
namespace: infra
hostnames: ["shop.example.com"]
rules:
- backendRefs: [{ name: web, port: 8080 }] # no matcher = match allThen verify each seam, because this is the part people skip and then wonder why nothing works. Gateway API gives you rich status, which Ingress never did:
# Is the Gateway programmed and did it get an address?
kubectl get gateway shop-gateway -n infra
# NAME CLASS ADDRESS PROGRAMMED
# shop-gateway prod 203.0.113.10 True
# Did the route attach cleanly?
kubectl get httproute web -n shop \
-o jsonpath='{.status.parents[0].conditions}'
# type: Accepted status: "True"
# type: ResolvedRefs status: "True"
# Send a real request through the front door
curl -H "Host: shop.example.com" http://203.0.113.10/Three checks, three seams: infrastructure, attachment, traffic. To grow the shop, each app team adds more HTTPRoutes (/products to products, /cart to cart) in their own namespace, without ever touching the Gateway or calling the platform team. The Gateway is shared infrastructure; the routes are self-service.
HTTPRoute mastery
Path is just the start. Path matching comes in three types: Exact (one path only, good for health endpoints), PathPrefix (the prefix and everything under it, what you will use 90% of the time), and RegularExpression, which is powerful but marked implementation-specific in the spec, so always check your controller’s conformance before relying on it. That last one is a common migration trap from nginx.
Then there are the matchers Ingress never had, all first-class typed fields: header matching (route x-version: beta traffic to a new build), query parameter matching (?debug=true to a debug backend), and method matching (POST /orders to a writer service, GET /orders to a read replica). You can combine them: a single match can require a path AND a header AND a method together.
The crowd favorite is traffic splitting, canary built into the route itself:
rules:
- backendRefs:
- name: cart-v1
port: 8080
weight: 90
- name: cart-v2
port: 8080
weight: 10To roll forward you shift the weights: 90/10, then 50/50, then 0/100, watching your metrics at each step. Something looks wrong? Shift them back. For simple canary you need no Argo Rollouts and no Flagger, just the weights in your HTTPRoute. Those tools still help for automated analysis, but the primitive is built in.
Finally, filters transform a request as it passes through, and they are typed, no annotations. RequestHeaderModifier adds, sets, or removes headers. RequestRedirect sends a typed 301 or 302 with a real statusCode field. URLRewrite reshapes the path before forwarding, so the public URL stays /products while the service itself only ever sees /. There is also RequestMirror to shadow traffic to a second backend. Between matching, splitting, and filters, HTTPRoute covers the vast majority of real routing needs, all typed and portable.
Production: TLS, multi-team, cross-namespace
The shop is routing, but production needs more. First, TLS, and notice where it lives: on the Gateway listener, owned by the platform team, not in the developer’s route.
listeners:
- name: https
port: 443
protocol: HTTPS
hostname: shop.example.com
tls:
mode: Terminate # decrypt here, forward plain HTTP inside
certificateRefs:
- kind: Secret
name: shop-tls # owned by the platform team in inframode: Terminate means the Gateway decrypts and forwards plain HTTP to backends inside the cluster, and certificateRefs points at a Secret in the infra namespace. The shop developers writing HTTPRoutes never see the certificate, never manage rotation, never get cluster-secret access. One team owns crypto, another owns routing. There is also mode: Passthrough for end-to-end TLS, which pairs with TLSRoute.
Because the Gateway is the shared front door, many teams can attach to it independently: the shop team’s routes, a separate marketing team’s /promo and /campaigns from their namespace, support’s /help and /status from theirs. TLS is configured once, on that Gateway, and enforced for everyone. This is impossible to do cleanly with one Ingress object; here it is the natural shape of the API.
But all of those are trusted shop teams. The payments service we flagged at the start lives in another team’s namespace, and that needs an explicit grant:
The critical property: the shop team cannot grant itself access to payments. The payments team must opt in by creating a ReferenceGrant in their own namespace. That is a real security handshake, exactly what you want when money is involved. In Ingress this was simply not possible without hacks. ReferenceGrant graduated to the Standard GA channel in v1.5, so it is stable API now.
Beyond HTTP, and into the mesh
HTTPRoute has siblings. GRPCRoute (GA) handles gRPC with method-level matching. TLSRoute graduated to Standard GA in v1.5 for L4 TLS passthrough with SNI routing, so if you have seen older talks calling it experimental, that changed. TCPRoute and UDPRoute remain experimental. The two channels matter for production: Standard is stable and backward-compatible, Experimental makes no promises.
Then there is a whole second direction of traffic. GAMMA (Gateway API for Mesh Management and Administration) extends the same API to service-to-service traffic inside the cluster:
The key twist is that one change: the HTTPRoute’s parentRef points at a Service instead of a Gateway. So you get canary, header routing, and traffic splitting for internal calls too, using the identical HTTPRoute shape you already learned. Istio, Linkerd, and Cilium’s mesh all implement GAMMA, so it is vendor-neutral mesh policy. One API, both directions.
The whole shop, every concept
Here is everything on one screen, on our shop: the amber shop-gateway on 443 with TLS, the shop namespace with four HTTPRoutes (including the 90/10 cart canary), and the payments team’s separate namespace reachable only because the ReferenceGrant permits it.
The thing I want you to notice: it was the same five resources the entire time. We never added a new core concept after the four resources, we just composed them into richer scenarios. That compositionality is the elegance of Gateway API.
Operating it in 2026
Three things to know to run it well. First, what’s current: v1.5 (February 2026) is the biggest release yet, themed around graduating Experimental features to GA. The headline is ListenerSet, which lets listeners be defined independently and merged onto a Gateway, breaking the old 64-listener limit and letting platform teams delegate individual listeners to app teams. TLSRoute and ReferenceGrant both reached Standard, and there is now a native HTTPRoute CORS filter, so no more annotations for CORS. Cadence is about every four months, so expect v1.6 later in 2026.
Second, and this is the single most useful mental model: the spec is not the implementation. The API is vendor-neutral and versioned. A controller is what actually does the work, and each implementation chooses which parts of the spec it supports. So a field can be perfectly GA in the spec and still not work on your cluster because your controller has not implemented it. RegularExpression path matching is the classic example. Do not assume, check your controller’s conformance report. This one habit prevents most “why doesn’t my route work” surprises.
Third, the gotchas that catch people in real migrations:
- Path semantics differ from nginx. Case sensitivity, prefix behavior, and regex support are all subtly different. Audit every path rule, do not assume parity.
- Cross-namespace is default-deny. Forget the ReferenceGrant and you get
RefNotPermittedand a route that silently will not program. Remember it is a security feature. - Read the status conditions.
Accepted,ResolvedRefs,Programmed. When something is wrong,kubectl getthe route’s status and it usually tells you exactly what. - Not every annotation maps cleanly. Some nginx config-snippet tricks (raw rate limiting, custom Lua) have no typed Gateway API equivalent yet. Plan to move those to controller-specific policy resources.
Zero to hero
You now understand every core resource, how they compose, and how to take an application from a single exposed service to a secure, multi-team, canary-deploying system, all with the same five building blocks.
To start concretely: install Gateway API v1.5 on any Kubernetes 1.30 or newer, pick a conformant controller, and expose your first service exactly like we did with web. Read the status conditions, check your controller’s conformance, and grow from there.
Related
- Slides: Kubernetes Gateway API: from zero to hero. The full deck this post is built on. Click through the animated diagrams in presenter mode.
- Next up: the AWS implementation deck, making this vendor-neutral foundation concrete with load balancer controllers, ACM for certificates, and VPC Lattice for the mesh.