Gateway API: What Changes After Ingress
Ingress has carried the same three or four fields for a decade, and everything else got bolted on through controller-specific annotations. Gateway API splits that patched model into GatewayClass, Gateway and HTTPRoute. Here is what problem it actually solves, why cross-namespace routing needs explicit permission, and when plain Ingress is still enough.
Open an Ingress object and the fields you see are sparse: a host, a path, a backend service, maybe a TLS block. Most of the actual configuration lives under annotations, and what those annotations mean depends entirely on which Ingress controller you run. A rule using nginx.ingress.kubernetes.io/rewrite-target does nothing once you switch to Traefik. The moment you want weighted traffic splitting, header-based routing, or TCP/UDP traffic management, you step outside the spec and into a controller-specific CRD or annotation set. Ingress was never designed for that; it standardized the common denominator of HTTP routing and left the rest to vendors.
Gateway API closes that gap by splitting the model into three resources, and the bigger change it brings is not a feature list but a boundary of authority.
One resource, three responsibilities
With Ingress, a single YAML file mixes 'which load balancer implements this' with 'which port and certificate is listening' with 'which path goes to which service.' That's fine for a small team, but once platform and application teams split, this coupling becomes an authorization problem: giving an application team the right to write path rules also hands them the right to change the TLS certificate or the listener port, because both live in the same object.
Gateway API separates this into three resources, each owned by a different role:
GatewayClass is a cluster-scoped resource defined by the infrastructure provider, naming which controller implements this class. Gateway is owned by the platform team and defines listener ports, protocols, and TLS certificates. HTTPRoute is written by the application team in its own namespace, defines which path routes to which service, and attaches to a Gateway via parentRefs.
This split maps directly onto RBAC. You grant application teams write access only to HTTPRoute; the Gateway resource stays with the platform team. A developer can add a new path for their own service but cannot touch the shared certificate or the listener port.
What the three resources actually look like
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: envoy-gateway
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main-gateway
namespace: platform
spec:
gatewayClassName: envoy-gateway
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: wildcard-cert
allowedRoutes:
namespaces:
from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: checkout
namespace: checkout
spec:
parentRefs:
- name: main-gateway
namespace: platform
hostnames:
- "checkout.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: checkout-service
port: 80
GatewayClass is created once, usually shipped with the controller's own installation. Gateway is defined by the platform, typically once per environment or team rather than once per application. HTTPRoute lives in each application's own namespace and reports through its status.conditions whether it actually attached to the target Gateway; don't assume a route is live without checking for Accepted: True in kubectl get httproute checkout -o yaml.
Crossing the namespace boundary on purpose
In the example above, allowedRoutes.namespaces.from: Same means the Gateway in the platform namespace only accepts routes from its own namespace. For an HTTPRoute in a different namespace to attach, the field must be set to All or Selector. Cross-namespace attachment is closed by default, and that's a deliberate design choice: a resource in one namespace reaching into another namespace's service is dangerous in a multi-tenant cluster.
The same logic applies in the other direction. When an HTTPRoute wants to reference a Service in a different namespace via backendRefs, the reference is rejected unless a ReferenceGrant in the target namespace explicitly allows it:
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-route-access
namespace: checkout
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: platform
to:
- group: ""
kind: Service
The important detail is who grants the permission: it's always the target namespace, not the source. The owner of a service decides who may reach it, not the party that wants to reach it. Ingress had no such boundary; cross-namespace backends wired through annotations worked silently, and figuring out who connected to what required a careful audit.
Traffic splitting is a single field
Where Ingress needed a controller-specific annotation or a separate CRD for canary or blue-green rollouts, HTTPRoute just needs a weight on each entry in backendRefs:
rules:
- backendRefs:
- name: checkout-v1
port: 80
weight: 90
- name: checkout-v2
port: 80
weight: 10
This works identically regardless of which implementation you run, because it's part of the spec itself. What changes is only your rollout speed: update weight by hand, or automate the ramp with a tool like Flagger or Argo Rollouts, both of which target the same field.
Migrating without a big-bang cutover
Gateway API doesn't retire Ingress; the two run side by side in the same cluster. The practical path is to point new services directly at HTTPRoute and leave existing Ingress objects alone. The kubernetes-sigs/ingress2gateway project scans your cluster's Ingress objects and produces draft Gateway and HTTPRoute equivalents, but those drafts are not final: wherever it can't find an equivalent for a controller-specific annotation, it leaves the field blank for you to fill in by hand.
Don't convert an Ingress and delete it in the same step. Keep both running in parallel for a while, validate with real traffic, then remove the old one. If the same host is defined through both Ingress and Gateway API at once, which one takes priority depends on the controller, and that behavior is usually undocumented.
Pitfalls
Gateway API is a specification, not a single implementation; Envoy Gateway, Cilium, Istio, Traefik, and NGINX Gateway Fabric all implement it at different levels of maturity. The standard-channel resources, GatewayClass, Gateway, HTTPRoute, and ReferenceGrant, are generally considered stable; TCPRoute, UDPRoute, and TLSRoute remain in the experimental channel and aren't supported by every controller. Reading the spec is not enough before using a feature; check your chosen controller's support matrix first.
The second pitfall is never reading status. Applying an HTTPRoute doesn't fail loudly; if it can't attach to its Gateway, you only find out through the Accepted and ResolvedRefs conditions under status.parents[].conditions. A setup that doesn't watch these conditions can believe traffic is routed when it never was.
The third pitfall is forgetting ReferenceGrant. A cross-namespace backend reference gets rejected silently, the error usually shows up in status conditions rather than as an error on the HTTPRoute itself, and at first glance it looks like 'service not found' when the service is right there and only the permission is missing.
When Ingress is still enough
For a small setup running a handful of services in one namespace, with no split between platform and application teams and no need for traffic splitting, moving to Gateway API adds a new CRD set, a learning curve, and a new controller decision to make. Ingress's four fields are still enough there. The migration pays off when at least two of these three are true: multiple teams define routes on the same cluster, you regularly do canary or weighted rollouts, or you want to manage non-HTTP protocols like TCP or gRPC under the same model. If none of that applies, there's no need to rush; the spec keeps maturing and the migration cost keeps getting cheaper every month.