Sign in
← Kubernetes: networking
8 min · medium · Lesson 8 of 8

Gateway API: HTTPRoute

Why routing got split across GatewayClass, Gateway, and HTTPRoute, and how that differs from Ingress.

Why the cluster entrance got split into GatewayClass, Gateway and HTTPRoute, how to describe a route, and how this differs from Ingress.

What's wrong with Ingress

Ingress has been around for ten years and isn't going anywhere. It's still GA, it's still used everywhere, and nothing in the cluster forbids it. But it has three pain points that pile up as a team grows.

First: one object describes everything at once. Hosts, paths, TLS, sometimes even backend settings through annotations. Whoever writes the Ingress has to know about the infrastructure (ports, certificates) and about the routing of their own Service.

Second: extensions through annotations. nginx.ingress.kubernetes.io/..., traefik.ingress.kubernetes.io/.... Switch from one controller to another, and half your YAML turns into garbage. That's exactly what happened: ingress-nginx, whose annotations spread across every guide, reached end of maintenance in March 2026, and now those lines need rewriting for a different implementation (more in the lesson on Ingress).

Third: HTTP and HTTPS only. TCP, UDP, gRPC and mTLS get wrapped through the same annotations or a vendor-specific CRD.

Gateway API fixes this differently: instead of one object, it introduces three, each with its own owner.

Three roles, three objects

HTTPRoute references a plain Service of type ClusterIP, exactly the way Ingress did. What's new here is only the split of the entrance itself.

  GatewayClass: traefik           <- the cluster admin picked the implementation
       |
       v
  Gateway: public                 <- infrastructure, namespace gateway-system
       |  listens on :80, *.example.com
       v
  HTTPRoute: helloroute           <- application team, in its own namespace
       |  hostnames: [helloapp.example.com]
       |  backendRefs: [helloapp:8080]
       v
  Service helloapp -> Pod

GatewayClass defines which controller will handle traffic (Traefik, Cilium, Istio, NGINX Gateway Fabric and others). The cluster admin creates it once. A developer only needs it to make sure the right controller is actually installed:

kubectl get gatewayclass
NAME      CONTROLLER                      ACCEPTED   AGE
traefik   traefik.io/gateway-controller   True       42d

Gateway describes the actual entry point: what port it listens on, which hosts, with what TLS. The infrastructure team creates it, usually in a separate namespace like gateway-system. Many teams can share one Gateway.

HTTPRoute holds the application's rules: which hosts and paths to catch, where to send them. A developer creates it in their own namespace. The route attaches to a Gateway through parentRefs.

The result is a split of responsibility. Infrastructure owns the entrance and decides who gets access to it. Application teams describe only their own routes and never touch the shared object.

A minimal HTTPRoute

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: helloroute
  namespace: hello
spec:
  parentRefs:
    - name: public
      namespace: gateway-system
  hostnames:
    - helloapp.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: helloapp
          port: 8080

Key fields:

A rule with no matches catches every request: the API fills in PathPrefix / on its own. You can see this in kubectl get httproute ... -o yaml after apply.

Who grants access: allowedRoutes and ReferenceGrant

A route from namespace hello attaches to a Gateway in gateway-system. So that not every team can attach its own rules to someone else's entrance, permission comes from the side being referenced.

For linking a route to a Gateway, that's the allowedRoutes field on the Gateway's listener:

spec:
  gatewayClassName: traefik
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: Selector
          selector:
            matchLabels:
              shared-gateway: "true"

from takes three values: Same (only routes from the Gateway's own namespace), All (from any namespace), and Selector (from namespaces with the right labels). If allowedRoutes isn't set, Same applies. So a route from a different namespace won't attach to the Gateway by default, even if parentRefs is written correctly.

ReferenceGrant is needed in a different case: when a route sends traffic to a Service in another namespace (backendRefs[].namespace), or a Gateway pulls a TLS certificate from a Secret in another namespace. A ReferenceGrant is created in the namespace of the side being referenced, and lists who's allowed to reference it.

Weights and headers

In Gateway API, traffic splitting is built right into the spec:

backendRefs:
  - name: helloapp-v1
    port: 8080
    weight: 90
  - name: helloapp-v2
    port: 8080
    weight: 10

weight isn't a percentage. A backend's share equals its weight divided by the sum of weights in that rule: 90 and 10 give the same result as 9 and 1. With no weight set, it defaults to 1, so two backends with no weight split traffic evenly. weight: 0 means nothing goes to that backend.

Besides the path, a rule can also check headers, the method and query parameters:

rules:
  - matches:
      - headers:
          - name: env
            value: canary
    backendRefs:
      - name: helloapp-v2
        port: 8080

The default comparison type for a header is Exact. Conditions inside one matches element combine with AND, and different elements of the matches list combine with OR.

If a request fits several rules, the controller picks the most specific match: first an Exact path, then the longest PathPrefix, then a rule with a method condition, then the one with more header conditions. The order of rules in the YAML doesn't affect the choice.

With Ingress, this was done through annotations or two separate Ingress objects, which often led to races.

Filters: header edits, redirect, URL rewrite, mirror

rules[].filters holds request modifiers. The most common ones:

  • RequestHeaderModifier: add or remove headers on the request.
  • ResponseHeaderModifier: the same for the response.
  • URLRewrite: change the host or path before it reaches the backend. Handy when the outside path is /api/users but the Service expects /users.
  • RequestRedirect: return a 301 or 302 without reaching the backend.
  • RequestMirror: send a copy of the request to another backend for shadow testing.

An example of URLRewrite:

rules:
  - matches:
      - path: { type: PathPrefix, value: /api }
    filters:
      - type: URLRewrite
        urlRewrite:
          path:
            type: ReplacePrefixMatch
            replacePrefixMatch: /
    backendRefs:
      - name: api-service
        port: 80

Comparison with Ingress

What it doesIngressHTTPRoute
HTTP routingyesyes
TCP/UDP/TLS/gRPCthrough vendor annotations/CRDseparate types: TCPRoute, UDPRoute, TLSRoute, GRPCRoute
Hostrules[].hostspec.hostnames[]
Path matchpathType: Prefix/Exactpath.type: PathPrefix/Exact/RegularExpression
Header conditionsthrough annotationsmatches[].headers in the spec
Multiple weighted backendsthrough annotationsbackendRefs[].weight in the spec
Cross-namespace backendnobackendRefs.namespace + ReferenceGrant
Header edits/redirectthrough annotationsfilters in the spec
Standard across vendorsminimalconformance tests
StatusGA, API frozenGA since Gateway API v1.0, actively growing

Ingress isn't going away. If one Ingress covers one Service and a single team owns it, there's no need to move. Gateway API earns its keep once more than two teams are involved, or non-HTTP protocols show up.

Where the ecosystem is heading

Gateway API has already become the direction the ecosystem is moving in. The push came from the end of ingress-nginx: the controller running half the clusters out there reached end of maintenance in March 2026, and teams had to pick a replacement. Nearly every live implementation is betting on Gateway API, and there's a tool for the move, ingress2gateway, which generates HTTPRoute objects from existing Ingress ones.

Ingress still needs to be understood either way: it's GA, not deprecated, and its manifests will keep showing up for years. But new designs target HTTPRoute. The foundation stays the same, Service and NetworkPolicy, and Gateway API sits one layer above, handling only inbound traffic.

How Gateway API gets into a cluster

A plain cluster has no Gateway API resources:

kubectl get httproute
error: the server doesn't have a resource type "httproute"

kubectl apply of a manifest with an HTTPRoute in such a cluster fails with no matches for kind "HTTPRoute" in version "gateway.networking.k8s.io/v1" and a hint to ensure CRDs are installed first.

CRDs are installed from the project's release. The Standard channel holds the stable resources, Experimental adds the experimental ones (covered in the lesson on local cluster add-ons):

kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.3/standard-install.yaml
customresourcedefinition.apiextensions.k8s.io/backendtlspolicies.gateway.networking.k8s.io created
customresourcedefinition.apiextensions.k8s.io/gatewayclasses.gateway.networking.k8s.io created
customresourcedefinition.apiextensions.k8s.io/gateways.gateway.networking.k8s.io created
customresourcedefinition.apiextensions.k8s.io/grpcroutes.gateway.networking.k8s.io created
customresourcedefinition.apiextensions.k8s.io/httproutes.gateway.networking.k8s.io created
...

CRDs only describe the objects. Traffic starts flowing once a controller shows up in the cluster that has claimed a GatewayClass as its own. Some controllers install the CRDs themselves: for example, the Envoy Gateway Helm chart applies the Gateway API CRDs by default. Then a separate apply isn't needed.

Status: what the controller writes

The API only checks the shape of the object: allowed values, required fields, a path starting with /. Whether the Gateway from parentRefs exists, whether it accepts the route, and whether a Service with that port exists, is decided by the controller. It writes the answer to status.parents[].conditions on the route:

On a Gateway, the controller sets Accepted and Programmed. While there's no controller, the Gateway sits at Unknown:

kubectl get gateway -A
NAMESPACE        NAME     CLASS     ADDRESS   PROGRAMMED   AGE
gateway-system   public   traefik             Unknown      1m

kubectl get gateway public -n gateway-system -o yaml then shows reason: Pending and message: Waiting for controller. An HTTPRoute, meanwhile, has no status block at all.

What commonly breaks

Commands for exploring

kubectl get gatewayclass
kubectl get gateway -A
kubectl get httproute -A
NAMESPACE   NAME         HOSTNAMES                  AGE
hello       helloroute   ["helloapp.example.com"]   2m
kubectl describe httproute helloroute -n hello

In the describe output, look at the Status, Parents, Conditions block: that's where Accepted and ResolvedRefs show up with a reason. Reference for any field is available right in the cluster:

kubectl explain httproute.spec.rules.backendRefs.weight

What you'll need for the tasks

Homework

Practice the theory on your own cluster: 2 steps with a check. Set up the cluster with the Kubernetes locally track, the tasks live in the k8s-practice repository.

./run.sh start k8s-gateway-basics     # set up the starting state
./run.sh verify k8s-gateway-basics    # check the current step

In short

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 3 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson k8s-network-08-gateway-api