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.
Read the status, do not guess
Every Gateway API object reports what the controller decided about it.
kubectl describe httproute instavote -n instavote
[ Expected output ]
Status:
Parents:
Conditions:
Message: Route is accepted
Reason: Accepted
Status: True
Type: Accepted
Message: Resolved all the Object references for the Route
Reason: ResolvedRefs
Status: True
Type: ResolvedRefs
Controller Name: gateway.envoyproxy.io/gatewayclass-controller
Parent Ref:
Kind: Gateway
Name: instavote-gw
Namespace: gateway-infra
Two separate questions are being answered here. Accepted says the Gateway let this route attach. ResolvedRefs says every backend the route points at actually exists.
Now break it on purpose. Change the result backend to a service name which does not exist,
sed 's/name: result$/name: results/' instavote-route.yaml | kubectl apply -f -
kubectl describe httproute instavote -n instavote
[ Expected output ]
Message: Failed to process route rule 1 backendRef 0: service instavote/results not found.
Reason: BackendNotFound
Status: False
Type: ResolvedRefs
And from outside,
curl -s -i http://localhost:30200/result | head -3
HTTP/1.1 500 Internal Server Error
date: Tue, 29 Sep 2026 03:54:33 GMT
content-length: 0
Observe - the route is still Accepted, because attaching to the Gateway worked fine. Only ResolvedRefs flipped. A 500 at the edge with a healthy looking route is almost always a name that does not resolve.
Put it back,
kubectl apply -f instavote-route.yaml
The routing checklist
When a request does not arrive, walk this from the top. Four of the five usual failures sit below where people start looking.
- Is the Pod
Ready? Not ready means not routed. - Does the EndpointSlice list any address at all?
- Does the Service
targetPortmatch the container port? - Does the route's path match what the client actually sent?
- Does the app expect
/when the route hands it/vote?
PART II - Splitting Traffic
Everything so far, Ingress could have done. This part is where the two APIs part company. You are going to release a second version of the vote app, send yourself to it with a header, and then let a small slice of real traffic reach it.
Deploy a second version
The running vote deployment uses schoolofdevops/vote:v2, which asks Emacs vs Vi. You will deploy v5 alongside it, which asks a different question, so you can tell the two apart from the page itself.
file: vote-beta-deploy.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: vote-beta
namespace: instavote
spec:
replicas: 2
selector:
matchLabels:
role: vote-beta
template:
metadata:
labels:
app: python
role: vote-beta
version: v5
spec:
containers:
- name: app
image: schoolofdevops/vote:v5
ports:
- containerPort: 80
protocol: TCP
file: vote-beta-svc.yaml
---
apiVersion: v1
kind: Service
metadata:
name: vote-beta
namespace: instavote
spec:
type: ClusterIP
selector:
role: vote-beta
ports:
- port: 80
targetPort: 80
protocol: TCP
kubectl apply -f vote-beta-deploy.yaml -f vote-beta-svc.yaml
kubectl rollout status deployment/vote-beta -n instavote
[ Expected output ]
deployment "vote-beta" successfully rolled out
Note the label role: vote-beta. The existing vote Service selects role: vote, so the new pods can not accidentally join it. Two deployments, two Services, one route about to decide between them.
Route on a request header
Add a rule which matches the same path, plus a header. Put it first, so it reads in the order it is evaluated.
file: instavote-route.yaml
[...]
rules:
- matches:
- path:
type: PathPrefix
value: /vote
headers:
- name: x-beta
value: "true"
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: vote-beta
port: 80
- matches:
- path:
type: PathPrefix
value: /vote
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: vote
port: 80
[...]
path and headers sit under the same match - both conditions must hold for the rule to fire.
Apply and compare two requests which differ only by one header,
kubectl apply -f instavote-route.yaml
curl -s http://localhost:30200/vote | grep -i "<title>"
curl -s -H "x-beta: true" http://localhost:30200/vote | grep -i "<title>"
[ Expected output ]
<title>Emacs vs Vi!</title>
<title>Amazon Echo vs Google Home!</title>
That is a beta channel, in six lines of YAML, with no annotation and no controller specific syntax. Your team ships the new version, sets the header in their browser with an extension, and everyone else keeps seeing the old one.
Release a canary by weight
Header matching is for people who opt in. A canary is for traffic which did not opt in to anything. Give the plain /vote rule two backends and weights.
file: instavote-route.yaml
[...]
- matches:
- path:
type: PathPrefix
value: /vote
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: vote
port: 80
weight: 90
- name: vote-beta
port: 80
weight: 10
[...]
Apply, then send fifty requests and count which version answered,
kubectl apply -f instavote-route.yaml
for i in $(seq 1 50); do
curl -s http://localhost:30200/vote | grep -om1 "Emacs vs Vi\|Amazon Echo vs Google Home"
done | sort | uniq -c
[ Expected output ]
6 Amazon Echo vs Google Home
44 Emacs vs Vi
Roughly one request in ten reached the new version. Weights are not percentages, they are shares - 90 and 10 behave the same as 9 and 1, and the same as 900 and 100.
Observe - change the weights to 50 and 50, re-apply, and run the loop again. How close to an even split do you get in fifty requests? Why is it never exactly half? Now roll the canary forward by moving the weights to 0 and 100, and notice that no deployment was touched and no pod restarted.
Exercise
Using what you just learned, extend the route,
- Add a
RequestHeaderModifierfilter to the/resultrule which adds a headerx-served-by: gatewayto every request going to the result app. Confirm it arrived by reading the result pod's logs. - Split
/voteevenly betweenvoteandvote-beta, then takevote-betadown to zero replicas without editing the route. What does the browser show, and what doeskubectl describe httproutesay? Which of the five checklist items would have caught it?
What Gateway API does not give you
- It stops at the edge. Traffic between your pods - vote to redis, worker to db - is untouched by any of this. Encrypting that is a service mesh, not a Gateway.
- It issues no certificates. The Gateway names a TLS Secret, something else has to create and renew it, usually cert-manager.
- It creates no DNS records. The Gateway gets an address, publishing that address as a name is still ExternalDNS or your DNS console.
- Conformance varies. Core fields such as path and header matching work everywhere, but extended fields may be unimplemented on another controller. Check the conformance report before assuming a field is portable.
- More Gateways is not better. One shared Gateway with many routes attached is the point of the design. A Gateway per application recreates the per service load balancer bill you were trying to escape.
Cleanup
Remove what this lab created,
kubectl delete -f instavote-route.yaml
kubectl delete -f vote-beta-deploy.yaml -f vote-beta-svc.yaml
kubectl delete -f instavote-gateway.yaml
kubectl delete namespace gateway-infra
kubectl delete -f gateway-class.yaml
kubectl delete -f envoy-nodeport.yaml
helm uninstall eg -n envoy-gateway-system
the result Service stays - the result app needs it regardless of how traffic reaches the cluster.
Uninstalling the chart leaves the Gateway API CRDs behind on purpose, so that other controllers using them keep working. Remove them only if you are sure nothing else needs them,
kubectl get crd | grep gateway.networking
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.