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
instavotenamespace, withvote,resultandredisdeployments running. helmversion 3 installed.- Node port
30200free 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.Clusterlets 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
- group:
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
ingressClassNamedid. - 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
Sameand only routes ingateway-infrawould 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 theinstavotenamespace, alongside the app - attaches to the Gateway
instavote-gwin namespacegateway-infra - three rules
- path prefix
/votegoes to thevoteservice on port 80 - path prefix
/resultgoes to theresultservice on port 80 - path prefix
/goes tovote, as the catch all
- path prefix
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-infraand 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.namenesting 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
- Gateway API - Introduction
- Gateway API - HTTPRoute Reference
- Migrating from Ingress to Gateway API
- Envoy Gateway Documentation
- Gateway API Conformance Reports
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
defaultnamespace, next to the app it belongs to - The Gateway in
gateway-inframust not be edited /opshas 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.