Application Routing with Gateway API and Envoy

In the previous lab you exposed applications with an Ingress controller. Ingress does two things well, host routing and path routing, and everything beyond that became a vendor annotation. This time you are going to route the same instavote application with the Gateway API, using Envoy Gateway as the controller, and you will do things which the Ingress spec simply can not express - matching on a request header, and splitting traffic by weight.

What will you learn

  • Setting up Envoy Gateway as a Gateway API controller
  • Creating the three Gateway API objects - GatewayClass, Gateway and HTTPRoute
  • Path based routing, and rewriting the path before it reaches the app
  • Reading route status to troubleshoot, instead of guessing
  • Matching requests on a header
  • Releasing a canary by splitting traffic 90/10

Pre Requisites

  • A 3 node KIND cluster, created as described in Lab K101 - Install Kubernetes with KIND.
  • The instavote application deployed in the instavote namespace, with vote, result and redis deployments running.
  • helm version 3 installed.
  • Node port 30200 free on the host, and mapped by your KIND config.

Validate before you begin,

kubectl get nodes
kubectl get deploy -n instavote
helm version --short

Three objects, three owners

With Ingress, one YAML file held the platform team's TLS configuration and the app team's routing rules together. Gateway API cuts the same job along the line where teams actually divide.


   infra provider        cluster operator          app developer
        |                      |                        |
        v                      v                        v
  +-------------+        +-----------+          +-------------+
  | GatewayClass| <----- |  Gateway  | <------- |  HTTPRoute  |
  +-------------+        +-----------+          +-------------+
   which                  a real listener        hosts, paths,
   implementation         port, protocol, TLS    headers, backends
   installed once         this gets an IP        lives with the app

where,

  • GatewayClass names the controller which will do the work. You install it once for the whole cluster.
  • Gateway is a real listener - a port, a protocol, and the rules for who is allowed to attach to it. This is the object which gets an address.
  • HTTPRoute carries the routing rules. It lives in the application's own namespace, and attaches itself to a Gateway.

Many HTTPRoutes may attach to one Gateway, even from different namespaces. The Gateway decides which namespaces are allowed in, so nothing sneaks on to the listener.

PART I - Routing Traffic with Gateway API

Set up Envoy Gateway

Envoy Gateway is the Gateway API controller you are going to use. Envoy is the proxy which moves the traffic, Envoy Gateway is the controller which configures it from Kubernetes objects.

To install it along with the Gateway API CRDs,

helm install eg oci://docker.io/envoyproxy/gateway-helm \
  --version v1.9.2 \
  -n envoy-gateway-system --create-namespace

[ Expected output ]

**************************************************************************
*** PLEASE BE PATIENT: Envoy Gateway may take a few minutes to install ***
**************************************************************************

Envoy Gateway is an open source project for managing Envoy Proxy as a standalone or Kubernetes-based application gateway.

Thank you for installing Envoy Gateway! 🎉

Your release is named: eg. 🎉

Your release is in namespace: envoy-gateway-system. 🎉

The chart brings the Gateway API custom resource definitions with it. Check what you now have,

kubectl get crd | grep gateway.networking

[ Expected output ]

backendtlspolicies.gateway.networking.k8s.io          2026-09-29T03:43:15Z
gatewayclasses.gateway.networking.k8s.io              2026-09-29T03:43:15Z
gateways.gateway.networking.k8s.io                    2026-09-29T03:43:15Z
grpcroutes.gateway.networking.k8s.io                  2026-09-29T03:43:15Z
httproutes.gateway.networking.k8s.io                  2026-09-29T03:43:20Z
listenersets.gateway.networking.k8s.io                2026-09-29T03:43:15Z
referencegrants.gateway.networking.k8s.io             2026-09-29T03:43:15Z
tcproutes.gateway.networking.k8s.io                   2026-09-29T03:43:15Z
tlsroutes.gateway.networking.k8s.io                   2026-09-29T03:43:15Z
udproutes.gateway.networking.k8s.io                   2026-09-29T03:43:15Z

Notice that grpcroutes, tcproutes and udproutes are there as well. Ingress only ever spoke HTTP.

Wait for the controller to come up,

kubectl get pods -n envoy-gateway-system

[ Expected output ]

NAME                             READY   STATUS      RESTARTS   AGE
eg-gateway-helm-certgen-4v26j    0/1     Completed   0          69s
envoy-gateway-8656bcc8b4-lg8qn   1/1     Running     0          26s

Now check whether a GatewayClass exists,

kubectl get gatewayclass
No resources found

Observe - the controller is running, but nothing yet tells the cluster to use it. That is the GatewayClass, and you write it yourself.

Tell Envoy where to listen

Before the GatewayClass, one piece of housekeeping. By default Envoy Gateway asks for a LoadBalancer Service, and on KIND there is no cloud load balancer to give it an address. You are going to pin it to a NodePort instead.

file: envoy-nodeport.yaml

---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
  name: envoy-nodeport
  namespace: envoy-gateway-system
spec:
  provider:
    type: Kubernetes
    kubernetes:
      envoyService:
        type: NodePort
        externalTrafficPolicy: Cluster
        patch:
          type: StrategicMerge
          value:
            spec:
              ports:
                - name: http-80
                  port: 80
                  nodePort: 30200

where,

  • type: NodePort replaces the default LoadBalancer service, which would sit in <pending> forever on KIND.
  • externalTrafficPolicy: Cluster matters more than it looks. The default is Local, which serves the node port only on the node where the Envoy pod happens to be running. In KIND only the control plane node publishes node ports to your host, and the Envoy pod could land on any worker. Cluster lets any node forward the request.
  • nodePort: 30200 pins the port, so the URL in this lab stays the same for everyone.

Apply it,

kubectl apply -f envoy-nodeport.yaml

Create the GatewayClass

A GatewayClass has very little in it. It names a controller, and optionally points at a configuration object for that controller. Start from this skeleton,

---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: xxx
spec:
  controllerName: xxx
  parametersRef:
    group: xxx
    kind: xxx
    name: xxx
    namespace: xxx

Problem Statement

  • name the class eg
  • controllerName
    • gateway.envoyproxy.io/gatewayclass-controller
  • parametersRef, pointing at the EnvoyProxy config you just created
    • group: gateway.envoyproxy.io
    • kind: EnvoyProxy
    • name: envoy-nodeport
    • namespace: envoy-gateway-system

Which gives you,

file: gateway-class.yaml

---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: eg
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
  parametersRef:
    group: gateway.envoyproxy.io
    kind: EnvoyProxy
    name: envoy-nodeport
    namespace: envoy-gateway-system

Apply and validate,

kubectl apply -f gateway-class.yaml
kubectl get gatewayclass

[ Expected output ]

NAME   CONTROLLER                                      ACCEPTED   AGE
eg     gateway.envoyproxy.io/gatewayclass-controller   True       3s

ACCEPTED True means a controller has claimed this class. If it stays empty, no controller recognised the controllerName you typed.

Create the Gateway

The Gateway is the cluster operator's object. To make that separation real, you are going to put it in its own namespace, away from the application.

kubectl create namespace gateway-infra

file: instavote-gateway.yaml

---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: instavote-gw
  namespace: gateway-infra
spec:
  gatewayClassName: eg
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All

where,

  • gatewayClassName picks the implementation, exactly like ingressClassName did.
  • listeners is the real front door - one HTTP listener on port 80.
  • allowedRoutes.namespaces.from: All is the permission. Routes from any namespace may attach to this listener. Set it to Same and only routes in gateway-infra would be allowed in.

Apply it,

kubectl apply -f instavote-gateway.yaml

Give it half a minute, then look at what appeared,

kubectl get gateway -n gateway-infra
kubectl get pods,svc -n envoy-gateway-system

[ Expected output ]

NAME           CLASS   ADDRESS      PROGRAMMED   AGE
instavote-gw   eg      172.19.0.2   True         3m14s

NAME                                                             READY   STATUS    RESTARTS   AGE
pod/envoy-gateway-8656bcc8b4-lg8qn                               1/1     Running   1          4m7s
pod/envoy-gateway-infra-instavote-gw-8ddfa1b8-797b8bbccc-ndngt   2/2     Running   0          3m13s

NAME                                                TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
service/envoy-gateway                               ClusterIP   10.96.157.96   <none>        18000/TCP...   83s
service/envoy-gateway-infra-instavote-gw-8ddfa1b8   NodePort    10.96.71.228   <none>        80:30200/TCP   27s

Observe - you never created that deployment or that service. Writing the Gateway object caused the controller to launch a dedicated Envoy proxy for it, and to publish it on node port 30200. PROGRAMMED True is the controller saying the data plane is up and carrying your configuration.

Now try connecting to it,

curl -m 5 http://localhost:30200/
curl: (28) Operation timed out after 5006 milliseconds with 0 bytes received

A Gateway with no routes attached is a door to nowhere. Envoy has a listener object but nothing to route to, so it never answers. Hold on to this - it is the first thing to check when a fresh Gateway appears dead.

Add the missing Service

Before routing, the result app needs a Service. Check what exists,

kubectl get svc -n instavote
NAME     TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
vote     NodePort    10.96.207.93   <none>        80:30000/TCP   131m

Only vote is there, and it is a NodePort left over from an earlier lab. Once the Gateway is routing, per application node ports stop being necessary - one entry point serves all of them.

file: result-svc.yaml

---
apiVersion: v1
kind: Service
metadata:
  name: result
  namespace: instavote
spec:
  type: ClusterIP
  selector:
    app: result
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
kubectl apply -f result-svc.yaml
kubectl get endpointslices -n instavote

[ Expected output ]

NAME           ADDRESSTYPE   PORTS   ENDPOINTS                 AGE
result-dv57l   IPv4          80      10.244.2.41               1s
vote-hz9d5     IPv4          80      10.244.0.21,10.244.1.77   132m

Make sure both slices list at least one address. An empty EndpointSlice means the selector matched nothing, and no amount of routing will fix that.

Write the HTTPRoute

This lab uses no hostnames and no DNS. An HTTPRoute with no hostnames field matches any Host header, so every URL here is plain http://localhost:30200/<path>. In a real cluster you would add hostnames: [vote.example.com] and point a DNS record at the Gateway address - the field goes in the same place, nothing else changes.

Start from the skeleton,

---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: xxx
  namespace: xxx
spec:
  parentRefs:
    - name: xxx
      namespace: xxx
  rules:
    - matches:
        - path:
            type: xxx
            value: xxx
      backendRefs:
        - name: xxx
          port: xxx

Problem Statement

  • route named instavote, in the instavote namespace, alongside the app
  • attaches to the Gateway instavote-gw in namespace gateway-infra
  • three rules
    • path prefix /vote goes to the vote service on port 80
    • path prefix /result goes to the result service on port 80
    • path prefix / goes to vote, as the catch all

file: instavote-route.yaml

---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: instavote
  namespace: instavote
spec:
  parentRefs:
    - name: instavote-gw
      namespace: gateway-infra
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /vote
      backendRefs:
        - name: vote
          port: 80
    - matches:
        - path:
            type: PathPrefix
            value: /result
      backendRefs:
        - name: result
          port: 80
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: vote
          port: 80

where,

  • parentRefs is the attach. The route reaches across into gateway-infra and asks to join that listener. The app team never edits the Gateway itself.
  • matches can carry path, headers, query parameters and method. You start with path.
  • backendRefs points straight at a Service. There is no backend.service.name nesting like Ingress had.

Apply it,

kubectl apply -f instavote-route.yaml

And test all three paths,

for p in / /vote /result; do printf "%-8s -> " "$p"; curl -s -o /dev/null -w "%{http_code}\n" http://localhost:30200$p; done

[ Expected output ]

/        -> 200
/vote    -> 404
/result  -> 404

Observe what happens. The catch all works, and both prefixes return 404. Why would routing succeed and the page still be missing?

The classic path routing trap

Look at who sent the 404,

curl -s -i http://localhost:30200/vote | head -8

[ Expected output ]

HTTP/1.1 404 Not Found
server: gunicorn/19.10.0
date: Tue, 29 Sep 2026 03:53:43 GMT
content-type: text/html; charset=utf-8
content-length: 232

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>404 Not Found</title>

server: gunicorn - that 404 came from the vote application itself, not from Envoy. And /result returns a 404 with x-powered-by: Express, which is the result app. So the routing is correct. The request arrived.

Bingo. The gateway matched /vote and then forwarded /vote unchanged, and the vote app only ever serves /. It was never told it lives under a prefix.

The fix is a URLRewrite filter, which strips the matched prefix before the request leaves Envoy. Add it to both prefix rules,

file: instavote-route.yaml

[...]
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /vote
      filters:
        - type: URLRewrite
          urlRewrite:
            path:
              type: ReplacePrefixMatch
              replacePrefixMatch: /
      backendRefs:
        - name: vote
          port: 80
    - matches:
        - path:
            type: PathPrefix
            value: /result
      filters:
        - type: URLRewrite
          urlRewrite:
            path:
              type: ReplacePrefixMatch
              replacePrefixMatch: /
      backendRefs:
        - name: result
          port: 80
[...]

Apply and test again,

kubectl apply -f instavote-route.yaml
for p in / /vote /result; do printf "%-8s -> " "$p"; curl -s -o /dev/null -w "%{http_code}\n" http://localhost:30200$p; done

[ Expected output ]

/        -> 200
/vote    -> 200
/result  -> 200

Open http://localhost:30200/vote and http://localhost:30200/result in your browser. Two applications, one entry point, one port.

filters is the part worth pausing on. With nginx Ingress the same rewrite was an annotation, spelled differently on every controller and portable to none of them. Here it is a typed field in the API, validated by the API server, and it reads the same on any conformant implementation.

[TODO: Additional Labs]

Summary

You now have the whole north-south path in your hands. A GatewayClass naming the controller, a Gateway owned by the platform side holding the listener, and an HTTPRoute owned by the app team doing the routing - path matching, path rewriting, header matching and weighted release, all as typed fields rather than annotations. You also know where to look when a request does not arrive, which is the route's own status before anything else.

What you have not touched is the traffic between the pods. vote still calls redis, worker still calls db, and none of that goes anywhere near the Gateway. In the next lab you are going to look at that side of the network, where policy and identity live.

Reading List

Search Keywords

  • kubernetes gateway api
  • envoy gateway
  • gatewayclass gateway httproute
  • httproute path rewrite
  • gateway api header matching
  • gateway api traffic splitting
  • gateway api vs ingress
  • backendnotfound resolvedrefs

Mini Project : Publish the Visualiser through the Gateway

The kube-ops-view visualiser runs in the default namespace with an Ingress object and no controller behind it, so that Ingress does nothing today. Replace it.

Requirements,

  • The visualiser must be reachable at http://localhost:30200/ops
  • The route lives in the default namespace, next to the app it belongs to
  • The Gateway in gateway-infra must not be edited
  • /ops has to reach the application's /
  • The old Ingress object is deleted when you are done

Validation is simple - the visualiser loads in your browser on the same port as the vote and result apps, and kubectl get httproute -A shows two routes attached to one Gateway.