Gateway API: what I use instead of Ingress
Ingress controllers got most of us started, and Ingress is not deprecated. But on a multi-tenant platform it fails in a specific, predictable way: one object ends up owning TLS, routing and rate limits, which means three teams edit the same YAML and none of them owns it.
The failure mode
Ingress is fine when one team owns the cluster. The problems start when platform, application and security teams share one.
- One object, three owners. TLS config, path routing and rate limits all live on the same resource. Every change is a negotiation conducted through
kubectl edit. - Route delegation is convention, not API.
ingressClassNameand controller annotations get you partway. There is no first-class boundary that says platform owns the listener, app teams own their routes. - Cross-namespace routing needs shared secrets. Most implementations either do not support it or require copying the TLS Secret into each namespace — which multiplies the number of places a wildcard certificate exists.
On a multi-tenant PaaS with dozens of services across several tenants and ten clusters, that last one is disqualifying. A wildcard cert copied per namespace is a wildcard cert you can no longer rotate in one place.
What Gateway API changes
Three resources, split along the lines the org is already split:
| Resource | Owner | Says |
|---|---|---|
GatewayClass | Platform | "This is the controller we run." |
Gateway | Platform | "These listeners, these ports, this TLS." |
HTTPRoute | App team | "Traffic for /api goes to our Service." |
An app team creates an HTTPRoute in its own namespace and attaches it to a Gateway it does not own. The platform team never hands over the certificate or the public IP.
The platform side
The Gateway is where the delegation policy lives, and allowedRoutes is the field that makes it enforceable rather than advisory:
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: nginx
spec:
controllerName: gateway.nginxinc.io/nginx-gateway-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public
namespace: platform
spec:
gatewayClassName: nginx
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.example.com"
tls:
certificateRefs:
- name: wildcard-cert # stays in the platform namespace
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"
from: Selector is the part worth stopping on. The alternatives are Same (only the Gateway's own namespace) and All — and All on a public listener means any namespace in the cluster can publish a route on your production hostname. Label the namespaces you intend to allow and let the selector enforce it.
The tenant side
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: app-route
namespace: payments
spec:
parentRefs:
- name: public
namespace: platform
hostnames:
- payments.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: payments-service
port: 8080
The parentRefs namespace reference is the whole mechanism: the route attaches upward to a Gateway in another namespace, and the Gateway's allowedRoutes decides whether that attachment is permitted.
ReferenceGrant, and why cross-namespace backends fail closed
A route in one namespace cannot reach a Service in another by default. That is deliberate — otherwise any namespace could route traffic to any Service in the cluster.
The target namespace has to consent, explicitly:
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-payments-routes
namespace: billing # lives with the TARGET, not the route
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: payments
to:
- group: ""
kind: Service
name: billing-service
Two things catch people here. The grant lives in the target namespace, because consent belongs to the party being referenced. And without it the route does not error loudly — it reports a ResolvedRefs: False condition and quietly serves nothing:
kubectl get httproute app-route -n payments -o jsonpath='{.status.parents[*].conditions}' | jq
Check that status field first on any route that attaches cleanly but returns 404.
What is genuinely better
- Portable filters. Header manipulation, redirects and URL rewrites are API fields, not controller-specific annotations. Migrating between implementations stops being a rewrite.
- Traffic splitting is native. Weighted
backendRefsgive you canary releases without a service mesh. - Policy attachment hangs timeouts, retries and rate limits off routes through a standard extension point.
When I still use Ingress
Internal clusters where one team owns everything. Ingress is simpler, the tooling is mature, and the ownership problem Gateway API solves does not exist there.
I do not migrate working clusters on principle either — the cost is real and the payoff is proportional to how many teams share the ingress path.
The signal to move is specific: more than one team edits the same Ingress object. If that is happening, the object has become a negotiation table, and Gateway API will pay for itself quickly. If it is not, stay where you are.
