Kubernetes Gateway API: from zero to hero
ingress-nginx is retired and the Ingress API is frozen. A walkthrough of its successor on one running shop: typed resources, the platform-vs-app split, canary, TLS, cross-namespace references, and the mesh.
Viktor Vedmich · 18 min read
In March 2026, ingress-nginx was retired. By the Kubernetes project’s own estimate, it ran in about half of all cloud native environments. There will be no more releases, no bug fixes, and no patches for security vulnerabilities. That is what turns “I should look at Gateway API eventually” into “I need to understand this now.”
This post accompanies a talk I give as a from-zero-to-hero walkthrough. We take one online shop, start with a single route, and grow the design one concept at a time until it has TLS, a canary, several teams, and a cross-namespace dependency. The examples are 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 the names are easy to mix up. This is Gateway API, the Kubernetes networking standard. It is not an “API gateway”, which is a generic architectural component, and it is not Amazon API Gateway, the AWS service. Different things.
ingress-nginx is retired. The Ingress API is frozen.
I spend exactly one slide on the “why now”, and so will this section. Four facts:
- ingress-nginx is retired (March 2026). The repositories are read-only, and there will be no fix for any future CVE. Existing deployments keep running, which is exactly why they are easy to forget. To check whether you run it:
kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginx. - Gateway API is the standard. Its core resources reached
v1in October 2023, and v1.5 shipped in February 2026. It is SIG Network’s official successor to Ingress. - Implementations are everywhere. GKE, AKS, Istio, Cilium, Envoy Gateway, NGINX Gateway Fabric, and more. As of April 21, 2026, seven implementations were fully conformant with v1.5.
- The
IngressAPI is frozen. It is GA, Kubernetes has no plans to remove it, and it will get no further changes. All new capability lands in Gateway API. If you are starting today, 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. When a Pod is replaced, during a rollout or after an eviction, its replacement may come up with a different IP. You could put a Pod IP in DNS, but it would not stay correct for long. 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. Our shop uses ClusterIP Services, which are reachable only inside the cluster. NodePort and LoadBalancer Services can expose a single Service externally, but they give you no shared front door with hostnames, paths, and routing rules. That missing piece is what Gateway API models: a typed way to define the entry point and the routing, split by who owns what. 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 addresses each of these in the model itself, not by adding more configuration.
- Most behavior lived in annotations. Rewrites, timeouts, CORS, canary: all untyped strings. ingress-nginx had over a hundred of them. TLS did have a native
spec.tlsfield, but almost everything beyond host, path, and TLS did not. A misspelled annotation key is silently ignored, and you find out at 3am. - One object, one owner. A developer who just wanted to add a path route edited the same object that held the TLS configuration and the load balancer behavior. There was no clean way to split those between teams.
- Vendor lock-in by annotation. A
nginx.ingress.kubernetes.io/...key means nothing to Traefik or HAProxy. Switching controllers meant finding an equivalent for every annotation and retesting every route. - Limited L7 routing in the spec. Host and path routing are built in. Header routing, traffic weights, method matching: none of that was in the API. Every controller bolted on its own version, usually through, you guessed it, more annotations.
Keep these four in mind. Each one gets addressed below.
Gateway API in one breath
If you remember one sentence, this is it. Gateway API takes the single Ingress object and splits it into typed resources, each owned by the team responsible for it, that attach to each other to describe how traffic gets from the edge of the cluster to your Pods.
A few properties fall out of that. It is role-oriented: separate resources make separate ownership practical, and RBAC enforces it. It is typed: the API server validates every field against a schema, and the controller reports in status whether it could apply the configuration. It is vendor-neutral: Core features behave the same on every controller conformant with the relevant profile, and the conformance report tells you which Extended features yours supports. And the same routing 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 shop inspired by Google’s Online Boutique, with 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 references matter later.
1. GatewayClass: the template
A cluster-scoped resource that names, through controllerName, which controller implements gateways of this class. The cleanest analogy is StorageClass: you do not build a disk, you ask for a disk of class fast-ssd and its provisioner handles it. Same here. It decouples your routing config from the implementation, and the infrastructure team manages it. 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 or configures a real load balancer or proxy. It has listeners (ports, protocols, TLS settings), and one Gateway is one front door to your cluster. It is also the ownership boundary for infrastructure concerns: listeners reference the TLS certificates, and allowedRoutes decides which namespaces may attach routes. That is the governance lever the platform team holds. We start with plain HTTP; the production section adds HTTPS.
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 # All | Same | Selector
selector:
matchLabels:
gateway-access: "true"3. HTTPRoute: the rules
This is where developers live. An HTTPRoute matches on hostname and path, forwards to a backend Service, and connects to a Gateway through parentRefs. In this HTTP example, it is the one Gateway API resource an application developer needs: typed, schema-validated, and scoped to their own namespace. They need RBAC rights on HTTPRoutes in that namespace, not on the Gateway, and never on the certificate’s private key. Note that parentRefs points at the Gateway in the infra namespace. That cross-namespace attachment is permitted by the Gateway’s allowedRoutes, not by a ReferenceGrant.
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 its Pods, nothing Gateway-API-specific. It matters in the chain because Pods are ephemeral: they get replaced and their IPs change, while the Service stays put. That is why an HTTPRoute targets a Service, never a Pod. backendRefs simply names a Service that already exists and its port. 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 object, one owner, developers forced to touch shared config. This three-way split is the structural fix. The resources do not grant permissions by themselves; RBAC does that. But now there is a clean line to draw RBAC along, and each team gets exactly the surface it needs.
The assembled journey
Now the payoff. GatewayClass, Gateway, and HTTPRoute are configuration: together they tell the controller how to program the data plane. The request itself never passes through a Kubernetes object. It hits the load balancer or proxy the Gateway describes, which matches it against the HTTPRoute rules and forwards it to a healthy Pod behind the Service:
Hands-on: route the shop
Time to deploy. The Gateway API CRDs are not built into Kubernetes; you install them separately, and v1.5 works on any Kubernetes 1.30 or newer. You can upgrade Gateway API on its own, as long as your Kubernetes version meets the new release’s minimum. You do need a controller that supports the version and features you use, so follow its compatibility guide when you upgrade the CRDs.
Before applying the examples, install a controller, create the infra and shop namespaces, and deploy the web Service on port 8080. The prod class and the example.net/gateway-controller name are placeholders; use the GatewayClass your controller provides.
# 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) and create or pick its GatewayClass.
kubectl get gatewayclass
# NAME CONTROLLER ACCEPTED
# prod example.net/gateway-controller TrueThe CRDs are the vocabulary; the controller is what watches these objects and programs real infrastructure. Check that the GatewayClass shows ACCEPTED: True. That only confirms the controller accepted the class; the next checks confirm that traffic flows.
Next, the platform team creates the front door, one HTTP listener for now, with the label selector 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 namespace gets the label that fits that lock, and the shop team attaches a route. One detail matters here: the label is the authorization, so a cluster administrator sets it, not the team asking for access. If application teams can edit their own namespace labels, they can let themselves in.
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 matches = PathPrefix /, within hostnamesThen 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=\
'{range .status.parents[*].conditions[*]}{.type}={.status}{"\n"}{end}'
# Accepted=True
# ResolvedRefs=True
# Send a real request through the front door
ADDR=$(kubectl get gateway shop-gateway -n infra \
-o jsonpath='{.status.addresses[0].value}')
curl -H "Host: shop.example.com" "http://$ADDR/"Three checks, three seams: infrastructure, attachment, traffic. To grow the shop, the team adds more HTTPRoutes (/products to products, /cart to cart) in its own namespace. As long as the listener already admits that namespace, nobody touches the Gateway and nobody files a ticket with 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 matches one path only, good for health endpoints. PathPrefix matches whole path segments: /products matches /products/42 but not /products-old, and it is what most of your routes will use. RegularExpression is powerful, but its support and syntax are implementation-specific, so read your controller’s documentation and test your expressions before relying on it. That last one is a common migration trap from nginx.
Then there are the matchers Ingress never had, all typed fields. Header matching sends x-version: beta traffic to a new build. Query parameter matching sends ?debug=true to a debug backend. Method matching sends POST /orders to a write service and GET /orders to a read service. Conditions inside one matches entry are ANDed, so a single match can require a path, a header, and a method together. Separate entries are ORed.
The crowd favorite is traffic splitting, a 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 a manual canary you need no Argo Rollouts and no Flagger, just the weights in your HTTPRoute. Those tools still earn their place when you want metric analysis and promotion automated, but the primitive is built in.
Finally, filters process a request as it passes through, and they are typed fields too, not annotations. RequestHeaderModifier adds, sets, or removes request headers. RequestRedirect returns a redirect with the code you set in statusCode, such as 301 or 302. URLRewrite can rewrite the path before forwarding, so the public URL stays /products while the service sees /. RequestMirror sends a copy of the traffic to a second backend. Some filters are Extended features, so check which ones your controller supports.
Production: TLS, multi-team, cross-namespace
The shop is routing, but production needs more. First, TLS, and notice where it lives: on a Gateway listener, owned by the platform team, not in the developer’s route. First create the shop-tls Secret in infra with a certificate for shop.example.com. Then replace the HTTP listener with the HTTPS one below (if you keep HTTP, add a redirect to HTTPS). It keeps the same allowedRoutes selector, otherwise it would only admit routes from the Gateway’s own namespace:
listeners:
- name: https
port: 443
protocol: HTTPS
hostname: shop.example.com
tls:
mode: Terminate # the client's TLS ends at the gateway
certificateRefs:
- kind: Secret
name: shop-tls # owned by the platform team in infra
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"mode: Terminate means the client’s TLS connection ends at the gateway. The connection from the gateway to the backend is a separate decision: in this example it is plain HTTP, and BackendTLSPolicy can add TLS to the backend if your controller supports it. certificateRefs points at the shop-tls Secret in the infra namespace. The shop developers writing HTTPRoutes never touch the private key and never manage rotation. One team owns crypto, another owns routing. If TLS has to end at the backend instead, use a listener with protocol: TLS, mode: Passthrough, and a TLSRoute.
Because the Gateway is the shared front door, many teams can attach to it independently: the shop team’s routes, a marketing team’s /promo and /campaigns from its namespace, support’s /help and /status from its own. Each route must satisfy the listener’s allowedRoutes and hostname, and requests through the HTTPS listener use its TLS settings. One shared entry point, routes owned by their teams: here that is simply 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 the shop’s HTTPRoute needs to reference it as a backend. That reference needs an explicit grant:
The critical property: the shop team cannot grant itself access to payments. The payments team opts in by creating a ReferenceGrant in its own namespace, and with RBAC set up per namespace, nobody else can create it there. That is a real two-party handshake, exactly what you want when money is involved. Keep its scope in mind: ReferenceGrant authorizes the reference from the route, nothing more. It does not authenticate payment requests and it does not replace network policy. ReferenceGrant has been in the Standard channel since v0.6.0, and v1.5 promoted it to API version v1 with the full GA contract.
Beyond HTTP, and into the mesh
HTTPRoute has siblings. GRPCRoute (GA) handles gRPC with matching on service and method. TLSRoute reached the Standard channel and v1 in v1.5: it routes TLS connections by SNI, with either Passthrough or Terminate on the listener. If you have seen older talks calling it experimental, that changed. TCPRoute and UDPRoute were still experimental in v1.5. The two channels matter for production: Standard carries compatibility guarantees, while Experimental resources and fields can change incompatibly.
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 parentRefs entry points at a Service instead of a Gateway. For the Mesh profile that is a ClusterIP Service, referenced with kind: Service and group: "". With a mesh that implements GAMMA, you get header routing and weighted canaries for internal calls too, using the same HTTPRoute shape you already learned. Istio, Linkerd, and Cilium’s mesh implement GAMMA; check which features your version supports. 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 Service in another team’s namespace, which the shop’s route may reference only because the ReferenceGrant permits it.
The thing I want you to notice: the core never changed. GatewayClass, Gateway, HTTPRoute, Service, and Pods carried the whole story. TLS added a Secret reference, and payments added a ReferenceGrant, but each of those does one narrow job. Everything else was composition. 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 was released on February 27, 2026, the biggest release yet, themed around promoting Experimental features to Standard. The headline is ListenerSet: listeners can be defined independently and merged onto a Gateway, which lets platform teams delegate listeners to app teams and lets a shared Gateway take more than 64 listeners. The Gateway has to opt in through allowedListeners; by default it accepts no ListenerSets. TLSRoute and ReferenceGrant reached v1, and the HTTPRoute CORS filter entered the Standard channel, so CORS no longer needs annotations on controllers that support it.
Update, October 2026: Gateway API v1.6 shipped on June 30, 2026. TCPRoute and UDPRoute graduated to the Standard channel, and Experimental resources moved to a separate API group,
gateway.networking.x-k8s.io. Everything in this post still applies.
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 does the actual work. Core features are mandatory within the conformance profiles a controller declares. Extended features are optional, and RegularExpression path matching is implementation-specific by design. Here is the dirty secret: a field can sit in the Standard channel, fully GA, and your controller still may not support it, because it is an Extended feature. An unsupported filter, for example, can get the whole route rejected with Accepted=False. Do not assume. Check the conformance report for your controller version, and for implementation-specific behavior, read its docs and test the rules you depend on. This one habit heads off most “why doesn’t my route work” surprises.
Third, the gotchas that catch people in real migrations:
- Test path behavior, not just syntax. Ingress
Prefixand Gateway APIPathPrefixboth match whole path segments, so plain prefixes usually carry over. Regex rules and ingress-nginx annotations that changed path handling do not. Audit those one by one. - Know which permission you are missing. Attaching a route to a Gateway in another namespace is governed by
allowedRoutes. Referencing a backend in another namespace needs a ReferenceGrant. Without it, the route reportsResolvedRefs=Falsewith reasonRefNotPermitted, and requests for that backend get HTTP 500. - Read the right status conditions. On the HTTPRoute, check
AcceptedandResolvedRefsfor each parent. On the Gateway, checkProgrammed. When something is wrong, the condition message 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. Check your controller’s policy resources, and plan a redesign where there is no match.
Zero to hero
We started with one web service and ended with a shared HTTPS front door, self-service routes for several teams, a weighted canary, and an explicitly authorized reference to payments, all on the same handful of building blocks.
To start concretely: pick compatible versions of Kubernetes (1.30 or newer), Gateway API, and a conformant controller. Expose your first service exactly like we did with web, check the status conditions, and send a real request. Once that request reaches the app, add the rest of the routes.
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.