Skip to main content

Gateway API: what I use instead of Ingress

· 4 min read
Ashik Mostofa Tonmoy
Senior DevOps Engineer & Platform Engineering Consultant

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. ingressClassName and 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:

ResourceOwnerSays
GatewayClassPlatform"This is the controller we run."
GatewayPlatform"These listeners, these ports, this TLS."
HTTPRouteApp 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 backendRefs give 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.

Resources​