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.

Three resources, three owners GatewayClass names an implementation and is cluster-scoped infrastructure owned by the platform operator. A Gateway is an instance of that class with listeners, ports and certificates, owned by the platform team. HTTPRoutes attach to a listener and are owned by application teams in their own namespaces. Each arrow is an attachment that both sides must permit. cluster operator GatewayClass names the implementation platform team Gateway listeners, ports, certificates application team HTTPRoute matching and forwarding Each arrow is an attachment both sides must permit: the listener declares allowedRoutes, the route declares parentRefs. Under Ingress all three concerns lived in one object, so any team that needed a rewrite needed write access to the object that also held the TLS certificate and the public hostname. A refused attachment appears in the route's status conditions — Accepted false, with a reason — rather than disappearing the way an unrecognised annotation did.

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.

Two permissions, two directions Attachment is negotiated between the Gateway listener, which names the namespaces it accepts, and the route, which names the Gateway it wants. Forwarding to a Service in a third namespace is granted by that namespace itself through a ReferenceGrant, so a team cannot route traffic into another team's service without that team agreeing. namespace: gateway-infra Gateway listener allowedRoutes: from: Selector gateway-access: edge permits requests namespace: orders-prod HTTPRoute parentRefs: edge backendRefs: payments/ledger namespace: payments ReferenceGrant from: HTTPRoute in orders-prod Without the grant the backend reference is rejected and the route reports ResolvedRefs false — traffic is not silently dropped into a namespace whose owners never agreed to receive it. This is the mechanism that makes a shared Gateway safe for many teams: every edge of the graph is consented to by the namespace on the receiving end, and consent is a resource you can review, diff and audit. Grants are namespaced and specific — never write one that accepts every kind from every namespace.
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.

Reading a route's status before reading any logs Four status reasons and what each one means. NotAllowedByListeners means the namespace selector does not match. NoMatchingListenerHostname means the hostnames do not intersect. RefNotPermitted means a ReferenceGrant is missing. BackendNotFound means the Service name or port is wrong. Each is reported on the route itself. status reason what it actually means NotAllowedByListeners the namespace label selector does not match yours NoMatchingListenerHostname listener and route hostnames do not intersect RefNotPermitted a ReferenceGrant is missing in the target namespace BackendNotFound the Service name or port does not exist All four are visible in kubectl describe — none of them require controller logs or a support ticket.

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

  • allowedRoutes uses Selector or Same, never All, on any listener carrying production traffic
  • Every cross-namespace backendRef has a matching ReferenceGrant naming a specific kind and name
  • Listener hostnames and route hostnames intersect, verified by a non-empty accepted-hostname list in status
  • timeouts.backendRequest is strictly less than timeouts.request on 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