Kubernetes Ingress & Gateway API
For most of a decade, exposing an HTTP service from Kubernetes meant writing an Ingress object and then writing everything the Ingress specification does not cover into annotations. Rewrites, timeouts, canary weights, authentication, CORS — all of it lived in string-valued keys that one controller understood and no other did. The Gateway API replaces that arrangement with a typed, role-separated resource model, and in doing so changes who owns which part of the routing configuration. This page covers the resource model, how routes attach across namespace boundaries, what the config actually looks like on two implementations, and the operational differences that matter once it is carrying production traffic. It sits under API gateway fundamentals and architecture, which frames the control-plane and data-plane split that the Gateway API makes explicit in its resources.
Architectural baseline
Three things are worth having straight before reading further.
The Gateway API is a set of CRDs, not a controller. Installing the API definitions gives you the types; nothing happens until an implementation — Envoy Gateway, Istio, Kong Ingress Controller, Cilium, NGINX Gateway Fabric and others — watches them and programs a data plane. The relationship is the same one described in control plane versus data plane, with the Kubernetes API server acting as the configuration store.
Resources map to roles, deliberately. Ingress collapsed infrastructure concerns and application concerns into one object, which is why annotations grew: an application team needed a rewrite, and the only place to put it was the object the platform team also owned. The Gateway API splits that into three resources with three intended owners.
A route is a request to attach, not a fact. Attachment is bidirectional: the listener says who may attach, and the route says what it wants to attach to. Either side can refuse, and the result is reported in the route’s status rather than as a silent failure — which is the single largest operational improvement over annotation-driven Ingress, where a mistyped annotation key simply did nothing.
Versioning is two-dimensional. The CRDs carry their own version and the controller carries another, and the pair has to be compatible in both directions: a controller newer than the installed CRDs cannot see fields it expects, and CRDs newer than the controller accept configuration nothing will act on. Treat the two as one release artefact and upgrade them together, in that order, with the CRDs first.
The resource model
The three core resources form a chain from cluster infrastructure down to a single application’s routing rules. Each link is owned by a different team and secured by ordinary Kubernetes RBAC on the resource type.
There are sibling route types for other protocols — GRPCRoute, TLSRoute, TCPRoute, UDPRoute — attaching to the same Gateway listeners. GRPCRoute in particular is worth reaching for rather than expressing gRPC as HTTP paths, because it matches on service and method directly and interacts correctly with the framing concerns covered in protocol translation patterns.
A working configuration
A Gateway with a TLS listener that accepts routes from labelled namespaces, and an application route attaching to it. This is Gateway API v1 as supported by Envoy Gateway 1.2+ and Kong Ingress Controller 3.x.
# Platform team: one Gateway, two listeners
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: edge
namespace: gateway-infra
spec:
gatewayClassName: envoy-gateway # or: kong, istio, cilium
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.api.example.com" # constrains what routes may claim
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: api-example-com-tls
allowedRoutes:
namespaces:
from: Selector # not All — an explicit opt-in
selector:
matchLabels:
gateway-access: "edge"
- name: http
protocol: HTTP
port: 80
hostname: "*.api.example.com"
allowedRoutes:
namespaces:
from: Same # only the redirect route, next to the Gateway
# Application team: routing rules in their own namespace
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: orders
namespace: orders-prod # carries label gateway-access: edge
spec:
parentRefs:
- name: edge
namespace: gateway-infra
sectionName: https # attach to one listener, not all of them
hostnames:
- "orders.api.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /v2/orders
headers:
- name: x-api-version
value: "2"
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
set:
- name: x-forwarded-tier
value: standard
remove:
- x-internal-role # strip anything the caller could forge
backendRefs:
- name: orders-v2
port: 8080
weight: 90
- name: orders-v3-canary
port: 8080
weight: 10
timeouts:
request: 3s
backendRequest: 1s # per attempt, must be under request
Three things in that route were annotation-only under Ingress and are typed fields here: the header match, the header modifier filter, and the weighted backends. The weights in particular are the foundation of traffic splitting and progressive delivery, and being a first-class field means a controller can validate them rather than parsing a string at reconcile time.
The timeout pair follows the nesting rule described in circuit breaking and retry budgets: backendRequest bounds one attempt, request bounds the whole exchange including retries, and the former must fit inside the latter with room for backoff.
Crossing namespace boundaries safely
Two separate permissions govern traffic that crosses a namespace: attaching a route to a Gateway, and forwarding from a route to a Service that lives elsewhere. They are deliberately different mechanisms, because they protect against different mistakes.
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-orders-routes
namespace: payments # lives where the Service is
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: orders-prod # one namespace, not a wildcard
to:
- group: ""
kind: Service
name: ledger # one Service, not the whole namespace
The same grant mechanism governs a Gateway referencing a TLS Secret in another namespace, which is how a central certificate namespace can serve listeners without handing every platform engineer read access to every private key.
Comparative implementation
| Concern | Ingress + annotations | Gateway API |
|---|---|---|
| Header-based matching | controller-specific annotation, if supported at all | matches.headers, Core conformance |
| Path rewrite | annotation string, often with capture-group syntax | URLRewrite filter, typed fields |
| Traffic weighting | canary-weight annotation on a second Ingress |
backendRefs[].weight on one rule |
| Request mirroring | rarely available | RequestMirror filter, Extended |
| Cross-namespace backends | not expressible | backendRefs plus ReferenceGrant |
| Who owns TLS config | same object as the app’s paths | the Gateway, separate RBAC |
| Failure reporting | annotation silently ignored | status conditions on the route |
| Portability | none in practice | Core features are conformance-tested |
Operational gotchas
Status is the debugging surface, so read it first. Every route reports Accepted and ResolvedRefs conditions per parent, with a reason string. kubectl describe httproute orders answers “did this attach, and did its backends resolve?” in one command. Teams migrating from Ingress often keep debugging by tailing controller logs out of habit; the status block is faster and is the supported contract.
Listener hostnames intersect, they do not override. A listener with hostname: "*.api.example.com" and a route with hostnames: ["internal.corp.example"] produce no usable hostname at all, and the route reports zero accepted hostnames rather than serving the route’s value. This is the most common first-day surprise.
Two Gateways of different classes can bind the same hostname and both will happily program their data planes, at which point which one receives traffic depends on DNS and load balancer provisioning rather than on anything in the API. During a migration this is exactly what you want — but only if you know it is happening. Give each Gateway its own address and cut over deliberately, as described in migrating from Ingress-NGINX to Gateway API.
Policy attachment is where portability ends. Rate limiting, authentication and retry policy are attached through implementation-specific resources — BackendTrafficPolicy, KongPlugin, AuthorizationPolicy and so on. Those are the parts that will not survive a change of implementation, so keep them in separate files from the routes and know which set is which.
Route conflicts resolve by specificity, then by age. When two rules match, the more specific one wins; if they are equally specific, the older resource wins by creation timestamp. That last tiebreak is deterministic but invisible in a manifest diff, which makes it worth avoiding overlapping rules entirely rather than relying on it.
What the split costs you
Role separation is not free, and it is worth being honest about the two places it shows up before adopting it across a large cluster.
The first is that a single application change can now touch two objects owned by two teams. Exposing a new hostname needs a listener the platform team owns and a route the application team owns, and if those land in different review queues the lead time for “add an endpoint” doubles. The teams that make this work well treat listeners as coarse — one wildcard listener per environment, opened to a labelled set of namespaces — so that day-to-day work only ever touches routes. A deployment with one listener per application has recreated the Ingress coupling with more YAML.
The second is that troubleshooting now spans resources. Under Ingress, one kubectl describe showed the whole story. Now the question “why is this hostname returning 404?” can be answered by the Gateway’s listener status, the route’s attachment conditions, the route’s resolved backend references, or a missing ReferenceGrant two namespaces away. This is genuinely more information, but only if people know to look for it — write the four-command triage sequence into the runbook rather than assuming it is discoverable.
Against those costs sits the reason the model exists: the permission to change what an application routes and the permission to change what terminates TLS for a public hostname are now different permissions. Under annotation-driven Ingress they were the same permission, and the usual mitigation — a platform team that owns every Ingress object and applies changes on request — is exactly the bottleneck that role separation removes. Whether that trade is worth it depends mostly on how many teams share one Kubernetes cluster: at one team it is overhead, and at a dozen it is the difference between a self-service platform and a ticket queue.
Production configuration checklist
-
allowedRoutesusesSelectororSame, neverAll, on any listener carrying production traffic - Every cross-namespace
backendRefhas a matchingReferenceGrantnaming a specific kind and name - Listener hostnames and route hostnames intersect, verified by a non-empty accepted-hostname list in status
-
timeouts.backendRequestis strictly less thantimeouts.requeston every rule - Implementation-specific policy resources are kept in separate manifests from portable route definitions
- Route status conditions are exported as a metric or alert, so a rejected route pages someone
- The GatewayClass version and the installed CRD version are pinned together in the same release
- A second Gateway of the previous implementation exists during migration, with its own address
FAQ
Does the Gateway API replace Ingress?
It supersedes it for new work without deleting it. Ingress remains a stable API and controllers continue to support it, but no new capability is being added there — header matching, weighting, mirroring and typed filters exist only in the Gateway API. Running both on one cluster is supported and is the normal migration path: the two controllers own different listeners, and hostnames move across one at a time.
What is the difference between GatewayClass, Gateway and HTTPRoute?
GatewayClass names an implementation, much as a StorageClass names a provisioner, and is cluster-scoped. A Gateway is a concrete instance of that class with listeners, ports and TLS, owned by the platform team. An HTTPRoute attaches to a listener and describes matching and forwarding for one application, owned by that application’s team. Three resources, three owners, three RBAC boundaries — that separation is the point of the redesign.
How does a route in one namespace attach to a Gateway in another?
The listener declares which namespaces may attach through allowedRoutes, and the route names the Gateway in parentRefs. Both sides must agree. Forwarding to a Service in a third namespace additionally needs a ReferenceGrant in that namespace, so the receiving team consents to traffic being routed into their service.
Is a Gateway API configuration portable between implementations?
Core conformance features are portable in practice — path and header matching, weighted backends, the standard filters. Extended features are optional and may be rejected. Anything expressed through policy attachment is implementation-specific by construction. Read the published conformance report for the exact version you run rather than assuming parity; Gateway API conformance and portability covers how to read one.
Parent: API Gateway Fundamentals & Architecture
Related
- Migrating from Ingress-NGINX to Gateway API — a hostname-by-hostname cutover with both controllers live.
- HTTPRoute Path Rewrites and Header Matching — the filter and match semantics in detail, with the rewrite cases that surprise people.
- API Gateway vs Service Mesh — where a Gateway ends and east-west policy begins.
- High Availability Topologies — running the Gateway data plane so a node loss is not an outage.