| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | kubectl 1.36.3Gateway API CRDs ( gateway.networking.k8s.io/v1)Envoy Gateway v1.5.0 (implementation example) |
| Applies to | Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Raspberry Pi OS, elementary OS, Zorin OS, Parrot OS, MX Linux, RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora Kubernetes cluster |
| Cert prep | CKA |
| Lab environment | Multi-node kubeadm cluster with containerd — install Kubernetes with kubeadm. This walkthrough uses Envoy Gateway with a NodePort Envoy Service because the lab has no cloud LoadBalancer. |
| Privilege | Normal user for kubectl when kubeconfig is available |
| Scope | Gateway API CRDs and controller prerequisites, GatewayClass, Gateway listeners, HTTPRoute parentRefs and backendRefs, path and hostname matching, weighted backends, HTTPS termination with a TLS Secret, and a brief ReferenceGrant example. Does not cover installing every Gateway controller, experimental route types, service mesh mode, advanced filters, auth extensions, provider LoadBalancer annotations, full Ingress migration, or controller development. |
| Related guides | Kubernetes Services Kubernetes networking model CRDs and custom resources |
Gateway API is the role-oriented way to publish HTTP services outside a cluster: a GatewayClass from your infrastructure, a Gateway that owns listeners, and HTTPRoutes that attach routes to those listeners. This walkthrough verifies an installed implementation, creates one Gateway, then attaches path, hostname, weighted, and TLS routes against two echo backends.
Gateway API is not built into every cluster
Gateway API resources ship as Custom Resource Definitions under gateway.networking.k8s.io. A fresh kubeadm cluster does not include them by default, and even with CRDs present you still need a controller (implementation) that:
- Watches GatewayClass, Gateway, and Route objects
- Publishes one or more GatewayClass names you can reference
- Programs a data plane (proxy or load balancer) and updates Gateway status
Without that controller, kubectl apply of a Gateway may leave the object stuck without Accepted / Programmed, and no address appears for clients.
Role separation in the API matches how teams usually work:
| Role | Owns |
|---|---|
| Infrastructure provider | Controller install and GatewayClass |
| Cluster operator | Gateway listeners, allowed namespaces, certificates |
| Application developer | HTTPRoute rules and backendRefs to Services |
Keep controller install outside this tutorial. Once a GatewayClass shows Accepted=True, the rest of the article is portable YAML.
Understand the resource model
Traffic flows through these objects:
GatewayClass
↓ implemented by controller
Gateway
↓ owns listeners
HTTPRoute
↓ attaches through parentRefs
Service
↓
Backend Pods- GatewayClass — cluster-scoped template that names the controller (
spec.controllerName). - Gateway — namespaced entry point: which class, which ports and protocols, which hostnames, which namespaces may attach Routes.
- HTTPRoute — application routing:
parentRefsto a Gateway, optionalhostnames, match rules, andbackendRefsto Services. - Service — stable selector for backend Pods; Gateway API does not replace Services.
Verify Gateway API and its controller
Confirm the standard CRDs exist before you write YAML:
kubectl get crd | grep gateway.networking.k8s.iobackendtlspolicies.gateway.networking.k8s.io 2026-07-27T14:17:42Z
gatewayclasses.gateway.networking.k8s.io 2026-07-27T14:17:42Z
gateways.gateway.networking.k8s.io 2026-07-27T14:17:42Z
grpcroutes.gateway.networking.k8s.io 2026-07-27T14:17:42Z
httproutes.gateway.networking.k8s.io 2026-07-27T14:17:42Z
referencegrants.gateway.networking.k8s.io 2026-07-27T14:17:42Z
tcproutes.gateway.networking.k8s.io 2026-07-27T14:17:42Z
tlsroutes.gateway.networking.k8s.io 2026-07-27T14:17:42Z
udproutes.gateway.networking.k8s.io 2026-07-27T14:17:42Zhttproutes, gateways, gatewayclasses, and referencegrants are the ones this article uses. Extra route kinds can sit unused.
List GatewayClasses and check that one is Accepted:
kubectl get gatewayclassNAME CONTROLLER ACCEPTED AGE
eg gateway.envoyproxy.io/gatewayclass-controller True 5m40sCONTROLLER is the implementation identity. ACCEPTED=True means that controller validated the class.
Inspect status and the controller name in more detail:
kubectl describe gatewayclass eg | sed -n '/Status:/,$p'Status:
Conditions:
Last Transition Time: 2026-07-27T14:20:02Z
Message: Valid GatewayClass
Observed Generation: 1
Reason: Accepted
Status: True
Type: Accepted
Events: <none>Confirm controller Pods are running. On this lab they live in envoy-gateway-system:
kubectl get pods -n envoy-gateway-systemNAME READY STATUS RESTARTS AGE
envoy-gateway-5cd69fdc84-hnhmb 1/1 Running 0 7m15s
envoy-gateway-demo-demo-gateway-449da5df-754495df67-24gq6 2/2 Running 0 5m29sThe first Pod is the control plane. Additional Pods appear after you create Gateways — one data-plane replica set per Gateway on this implementation. Small labs can run out of memory if you create several Gateways at once.
eg at an EnvoyProxy that sets envoyService.type: NodePort so clients can reach the Gateway without a cloud LoadBalancer. Cloud clusters often get a LoadBalancer Service and an EXTERNAL-IP instead. Use whichever address your Gateway status reports.
Prepare backend Services
Create a namespace and two echo Deployments so path and host demos return different Pod names. The echo image prints JSON including namespace and pod, which makes routing easy to verify.
kubectl create namespace gateway-demonamespace/gateway-demo createdApply the web backend:
kubectl -n gateway-demo apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: echo
image: gcr.io/k8s-staging-gateway-api/echo-basic:v20231214-v1.0.0-140-gf544a46e
ports:
- containerPort: 3000
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
ports:
- port: 80
targetPort: 3000
EOFdeployment.apps/web created
service/web createdApply the api backend the same way, with labels and names set to api:
kubectl -n gateway-demo apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: echo
image: gcr.io/k8s-staging-gateway-api/echo-basic:v20231214-v1.0.0-140-gf544a46e
ports:
- containerPort: 3000
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
---
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector:
app: api
ports:
- port: 80
targetPort: 3000
EOFdeployment.apps/api created
service/api createdWait until both Deployments are ready, then confirm ClusterIP reachability from inside the cluster:
kubectl -n gateway-demo get deploy,svcNAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/api 1/1 1 1 8m30s
deployment.apps/web 1/1 1 1 8m30s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/api ClusterIP 10.97.193.10 <none> 80/TCP 8m30s
service/web ClusterIP 10.103.12.174 <none> 80/TCP 8m30sBoth Services are Available. Spot-check with a Job that curls each ClusterIP:
kubectl -n gateway-demo create job web-check --image=curlimages/curl:8.5.0 -- curl -sS http://web/job.batch/web-check createdRead the Job logs after a few seconds:
kubectl -n gateway-demo logs job/web-check{
"path": "/",
"host": "web",
"method": "GET",
"proto": "HTTP/1.1",
"headers": {
"Accept": [
"*/*"
],
"User-Agent": [
"curl/8.5.0"
]
},
"namespace": "gateway-demo",
"ingress": "",
"service": "",
"pod": "web-8cddb5978-zj7np"
}The pod field starts with web-, so the Service selector is healthy. Repeat with api-check against http://api/ if you want the same proof for the second backend. Do not dig into Service troubleshooting here — fix selector and port problems before attaching Routes.
Create a Gateway
Create a Gateway that uses class eg, listens for HTTP on port 80, and only allows Routes from the same namespace:
kubectl -n gateway-demo apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: demo-gateway
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
EOFgateway.gateway.networking.k8s.io/demo-gateway createdWatch until PROGRAMMED is True:
kubectl -n gateway-demo get gateway demo-gatewayNAME CLASS ADDRESS PROGRAMMED AGE
demo-gateway eg 192.168.56.109 True 7m46sADDRESS shows node IPs on this NodePort lab. Cloud LoadBalancer implementations usually show a single external address instead.
Read the conditions that matter for day-two checks:
kubectl -n gateway-demo get gateway demo-gateway -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason} {"\n"}{end}'Accepted=True Accepted
Programmed=True ProgrammedAccepted means the controller took the Gateway. Programmed=True means the configuration was successfully sent to the data plane and should become ready soon. Check the Gateway ADDRESS field separately for a usable address. Also check listener status with kubectl describe gateway when Routes fail to attach — look for listener Accepted, Programmed, and ResolvedRefs.
Find the NodePort clients should use on this lab:
kubectl -n envoy-gateway-system get svc -l gateway.envoyproxy.io/owning-gateway-name=demo-gatewayNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
envoy-gateway-demo-demo-gateway-449da5df NodePort 10.103.124.64 <none> 80:31074/TCP 7m16sHere external HTTP is http://<node-ip>:31074. Your NodePort number will differ.
Create a basic HTTPRoute
Attach a Route that sends all matching traffic to web through parentRefs:
kubectl -n gateway-demo apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web-basic
spec:
parentRefs:
- name: demo-gateway
rules:
- backendRefs:
- name: web
port: 80
EOFhttproute.gateway.networking.k8s.io/web-basic createdConfirm parent conditions:
kubectl -n gateway-demo describe httproute web-basic | sed -n '/Status:/,$p'Status:
Parents:
Conditions:
Last Transition Time: 2026-07-27T14:30:12Z
Message: Route is accepted
Observed Generation: 1
Reason: Accepted
Status: True
Type: Accepted
Last Transition Time: 2026-07-27T14:30:12Z
Message: Resolved all the Object references for the Route
Observed Generation: 1
Reason: ResolvedRefs
Status: True
Type: ResolvedRefs
Controller Name: gateway.envoyproxy.io/gatewayclass-controller
Parent Ref:
Group: gateway.networking.k8s.io
Kind: Gateway
Name: demo-gateway
Events: <none>Both Accepted and ResolvedRefs should be True. Then curl the Gateway address (replace the NodePort if yours differs):
Manifest and API checks with curl are covered in the curl command guide.
curl -sS http://192.168.56.109:31074/ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["namespace"], d["pod"])'gateway-demo web-8cddb5978-zj7npTraffic reached the web Pod through the Gateway. Delete this catch-all before the path demo so rules do not compete:
kubectl -n gateway-demo delete httproute web-basichttproute.gateway.networking.k8s.io "web-basic" deleted from gateway-demo namespaceRoute by path
Create one HTTPRoute with two rules. /api is listed first for readability, but its position does not make it win. Gateway API selects the longest matching PathPrefix, so /api takes precedence over / regardless of rule order.
kubectl -n gateway-demo apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: site-paths
spec:
parentRefs:
- name: demo-gateway
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: api
port: 80
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web
port: 80
EOFhttproute.gateway.networking.k8s.io/site-paths createdCommon path match types:
| Type | Behavior |
|---|---|
PathPrefix |
Matches the path and everything under it |
Exact |
Matches only that exact path |
RegularExpression |
Implementation-dependent regex matching |
This first example stays on PathPrefix and does not use rewrite filters.
Curl /api and read which Pod answered:
curl -sS http://192.168.56.109:31074/api | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["path"], d["namespace"], d["pod"])'/api gateway-demo api-d85c97648-whc5tThe api Pod handled /api. Curl / next:
curl -sS http://192.168.56.109:31074/ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["path"], d["namespace"], d["pod"])'/ gateway-demo web-8cddb5978-zj7npThe web Pod handled /. If a real app returns 404 on a prefixed path, the Route still worked — the backend rejected the path. Echo backends avoid that confusion for demos.
Route by hostname
Hostname selection is the intersection of three things:
- The Gateway listener hostname (if set)
- The HTTPRoute
hostnameslist - The HTTP
Hostheader (or TLS SNI for HTTPS)
When the listener omits hostname, any Route hostname can attach. DNS (or /etc/hosts / curl -H 'Host: ...') must still send the name you intend.
Create two Routes on the same Gateway:
kubectl -n gateway-demo apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: www-host
spec:
parentRefs:
- name: demo-gateway
hostnames:
- www.lab.example
rules:
- backendRefs:
- name: web
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-host
spec:
parentRefs:
- name: demo-gateway
hostnames:
- api.lab.example
rules:
- backendRefs:
- name: api
port: 80
EOFhttproute.gateway.networking.k8s.io/www-host created
httproute.gateway.networking.k8s.io/api-host createdSend the Host header without changing DNS:
curl -sS -H 'Host: www.lab.example' http://192.168.56.109:31074/ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["host"], d["pod"])'www.lab.example web-8cddb5978-zj7npcurl -sS -H 'Host: api.lab.example' http://192.168.56.109:31074/ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["host"], d["pod"])'api.lab.example api-d85c97648-whc5tSame Gateway address, different backends based on Host. If a Route hostname never intersects the listener hostname, the Route stays unattached even when YAML looks valid.
Split traffic between backends
Weighted backendRefs are a built-in Gateway API capability for canary-style splits. Create a Route that prefers web at weight 70 and api at weight 30:
kubectl -n gateway-demo apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: split-traffic
spec:
parentRefs:
- name: demo-gateway
hostnames:
- split.lab.example
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web
port: 80
weight: 70
- name: api
port: 80
weight: 30
EOFhttproute.gateway.networking.k8s.io/split-traffic createdSample ten requests and print the Pod name only:
for i in 1 2 3 4 5 6 7 8 9 10; do curl -sS -H 'Host: split.lab.example' http://192.168.56.109:31074/ | python3 -c 'import sys,json; print(json.load(sys.stdin)["pod"])'; doneapi-d85c97648-whc5t
web-8cddb5978-zj7np
web-8cddb5978-zj7np
api-d85c97648-whc5t
web-8cddb5978-zj7np
api-d85c97648-whc5t
web-8cddb5978-zj7np
web-8cddb5978-zj7np
api-d85c97648-whc5t
web-8cddb5978-zj7npBoth backends appear. Do not expect exactly seven web hits in ten curls — short samples are noisy, and implementations may deviate slightly from the configured ratio. Treat weights as relative preference, not a promise of exact percentages.
Configure HTTPS
TLS termination lives on the Gateway listener. The certificate Secret must be in a namespace the Gateway is allowed to read — typically the Gateway namespace for same-namespace refs.
Create a lab TLS Secret (self-signed is fine for demos; production uses a real issuer):
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
-keyout /tmp/tls.key -out /tmp/tls.crt \
-subj "/CN=secure.lab.example" \
-addext "subjectAltName=DNS:secure.lab.example"The command writes /tmp/tls.key and /tmp/tls.crt with no stdout on success.
kubectl -n gateway-demo create secret tls secure-lab-tls --cert=/tmp/tls.crt --key=/tmp/tls.keysecret/secure-lab-tls createdCreate an HTTPS Gateway that terminates TLS with that Secret:
kubectl -n gateway-demo apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: secure-gateway
spec:
gatewayClassName: eg
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: secure.lab.example
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: secure-lab-tls
allowedRoutes:
namespaces:
from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: secure-web
spec:
parentRefs:
- name: secure-gateway
hostnames:
- secure.lab.example
rules:
- backendRefs:
- name: web
port: 80
EOFgateway.gateway.networking.k8s.io/secure-gateway created
httproute.gateway.networking.k8s.io/secure-web createdWait until Programmed is True. On small labs, free memory first if a previous Gateway left the proxy Pod Pending (NoResources):
kubectl -n gateway-demo get gateway secure-gatewayNAME CLASS ADDRESS PROGRAMMED AGE
secure-gateway eg 192.168.56.108 True 3m28sFind the HTTPS NodePort and curl with --resolve so TLS hostname matching works without DNS:
kubectl -n envoy-gateway-system get svc -l gateway.envoyproxy.io/owning-gateway-name=secure-gatewayNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
envoy-gateway-demo-secure-gateway-af8bf1ed NodePort 10.98.26.20 <none> 443:32385/TCP 4m31scurl -skS --resolve secure.lab.example:32385:192.168.56.109 https://secure.lab.example:32385/ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["host"], d["namespace"], d["pod"])'secure.lab.example:32385 gateway-demo web-8cddb5978-zj7npTLS terminated at the Gateway; the backend still spoke plain HTTP on port 80. For more on creating TLS Secrets, see Kubernetes Secrets.
Cross-namespace route attachment
Two separate controls matter:
- Route attachment — Gateway
allowedRoutes.namespacesdecides which namespaces may attach HTTPRoutes to a listener (Same,All, or a selector). - Backend references — an HTTPRoute that points at a Service in another namespace needs a
ReferenceGrantin the backend namespace. Cross-namespace Service access is not automatic.
Optional lab: create gateway-apps with a remote-web Service, then an HTTPRoute in gateway-demo that references it.
Without a ReferenceGrant, ResolvedRefs fails:
Message: Failed to process route rule 0 backendRef 0: Backend ref to Service gateway-apps/remote-web not permitted by any ReferenceGrant.
Reason: RefNotPermitted
Status: False
Type: ResolvedRefsGrant the reference from the backend namespace:
kubectl -n gateway-apps apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gateway-demo
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: gateway-demo
to:
- group: ""
kind: Service
EOFreferencegrant.gateway.networking.k8s.io/allow-gateway-demo createdAfter the grant, ResolvedRefs becomes True and curls with Host: cross.lab.example reach the remote namespace Pod (namespace=gateway-apps in the echo JSON). Keep this as an optional pattern — most app Routes stay in the same namespace as their Services.
Gateway API vs Ingress
| Gateway API | Ingress |
|---|---|
| Multiple role-oriented resources | One main Ingress resource |
| Extensible route types (HTTPRoute and others) | HTTP/HTTPS-focused |
| Explicit status and attachment conditions | Controller-dependent extensions |
| Rich listener and delegation model | Simpler application routing model |
Ingress remains common and exam-relevant. For host, path, and TLS examples on Ingress, use Expose services with Ingress. Gateway API is the newer portable model when your cluster has a conforming implementation.
Common Gateway API problems
| Symptom | Likely cause | Fix |
|---|---|---|
No gateway.networking.k8s.io CRDs |
CRDs not installed | Install Gateway API CRDs from the release your controller supports |
| No GatewayClass, or never Accepted | No compatible controller, or wrong controllerName | Install/configure an implementation; match gatewayClassName to an Accepted class |
| Gateway Accepted but not Programmed | Data plane pending, no address, or NoResources |
Check controller logs and proxy Pods; free capacity; wait for LoadBalancer/NodePort |
| HTTPRoute not Accepted | Wrong parentRef, listener mismatch, or namespace not allowed | Align parentRefs, listener protocol/port/hostname, and allowedRoutes |
| Hostnames never match | Route hostname does not intersect listener hostname | Align listener hostname, Route hostnames, and client Host/SNI |
| ResolvedRefs false on same-namespace Service | Missing Service or wrong port | Fix backendRefs name and port; confirm Endpoints exist |
| Cross-namespace backend RefNotPermitted | No ReferenceGrant | Create ReferenceGrant in the Service namespace |
| Gateway address empty | Provider did not allocate LB/NodePort | Check implementation Service; configure LB or NodePort as required |
| 404 from backend on a matching path | App rejects the path; Route may still be correct | Confirm with an echo backend or app-aware rewrites (out of scope here) |
What's Next
- kubectl port-forward with Pods, Deployments and Services
- Fix FailedCreatePodSandBox and Kubernetes CNI Errors
- Monitor Kubernetes Pods and Nodes with kubectl top
References
- Gateway API concept docs (kubernetes.io)
- Gateway API project site
- API overview — GatewayClass, Gateway, HTTPRoute
- ReferenceGrant
- Envoy Gateway documentation
- kubernetes-sigs/gateway-api
Summary
You verified Gateway API CRDs and an Accepted GatewayClass, then published traffic through a Gateway and HTTPRoutes instead of a single Ingress object. The useful status checks are the same every time: Gateway Accepted and Programmed, HTTPRoute Accepted and ResolvedRefs, then curl through the address your implementation assigned.
Path and hostname rules are portable across conforming controllers; weighted backends are a short canary tool, not a promise of exact percentages from a few curls. HTTPS terminates on the Gateway listener with a Secret reference, and cross-namespace Service backends need an explicit ReferenceGrant. When a Route looks correct but never attaches, compare listener hostname, allowed namespaces, and parentRefs before blaming the backend.
Next, keep practicing on the same kubeadm lab: tighten listener hostnames for production-like DNS, or compare the same host and path rules on Ingress using the Ingress guide linked above. Clean up demo namespaces when you are done so leftover Gateways do not consume proxy capacity on small clusters.

