If you have been working with Kubernetes for a while, you are probably familiar with Ingress, it used to expose HTTP services outside the cluster. But there is a newer, more powerful way to handle traffic routing in Kubernetes: the Gateway API.
In this tutorial, you will learn what the Gateway API is, how it compares to the traditional Ingress, and how to set it up in a real cluster using Envoy Gateway as the implementation. By the end, you will understand when and why to choose Gateway API over Ingress, and you will have hands-on experience creating GatewayClass, Gateway, and HTTPRoute resources.
Before diving into Gateway API, let’s understand why it was created in the first place.
Kubernetes Ingress is a resource that defines HTTP and HTTPS routing rules to expose services outside the cluster. It works by having an Ingress Controller (like NGINX, Traefik, or HAProxy) watch for Ingress resources and configure itself accordingly.
Here is a typical Ingress definition:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: app.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
This looks straightforward. So why was a replacement needed?
While Ingress gets the job done for simple cases, it has well-known pain points that become more apparent as your cluster grows:
Controller-specific annotations: Advanced features like timeouts, rate limiting, header manipulation, and traffic weighting are all done through annotations. The problem is that these annotations are not standardized. The annotation nginx.ingress.kubernetes.io/proxy-read-timeout only works with NGINX Ingress Controller. The moment you switch to Traefik or another controller, you need to rewrite all your annotations. This kills portability.
No real multi-tenant support: Ingress does not cleanly separate the concerns between an infrastructure team (who owns the load balancer) and application teams (who define routing rules). Both end up modifying the same resource type, which leads to permission and ownership conflicts in larger organizations.
Limited protocol support: Ingress only handles HTTP and HTTPS natively. For TCP, UDP, or gRPC routing, you need controller-specific workarounds or separate CRDs that are not part of the Kubernetes standard.
No native traffic splitting: Canary deployments and A/B testing require controller-specific annotations rather than being first-class features of the API spec. This means the behavior differs across controllers and is not portable.
These limitations motivated the Kubernetes SIG-Network group to design a better API from scratch.
The Gateway API is a collection of Kubernetes custom resources designed to replace Ingress with a more expressive, extensible, and role-oriented approach. It became Generally Available (GA) in Kubernetes 1.28.
The Gateway API introduces three core resource types, each owned by a different persona in your organization.
GatewayClass is the entry point. Think of it like StorageClass for persistent volumes, it defines the type of gateway infrastructure available in the cluster. A GatewayClass is created by an infrastructure provider or platform admin, and it references the controller (the software) that will implement the behavior.
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: envoy-gateway
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
A Gateway represents an actual instance of a load balancer or proxy. It declares what ports and protocols are exposed and which GatewayClass (implementation) to use. This resource is typically owned by the platform or infrastructure team.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: my-gateway
namespace: demo
spec:
gatewayClassName: envoy-gateway
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: Same
The allowedRoutes field is important, it controls which namespaces are allowed to attach routes to this Gateway. This is access control that Ingress simply does not have.
HTTPRoute defines the actual routing logic, which hosts, paths, and headers map to which backend services. This resource is owned by application developers. They do not need access to the Gateway or the load balancer configuration.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-route
namespace: demo
spec:
parentRefs:
- name: my-gateway
namespace: demo
hostnames:
- "app.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: api-service
port: 80
Beyond HTTPRoute, the Gateway API also defines TCPRoute, TLSRoute, GRPCRoute, and UDPRoute for non-HTTP traffic. This is a major expansion over Ingress.
The most important architectural difference is how Gateway API separates responsibilities:
| Role | Resource | Responsibility |
|---|---|---|
| Infrastructure Provider | GatewayClass | Defines the type of gateway |
| Platform Admin | Gateway | Creates and manages load balancer instances |
| Application Developer | HTTPRoute | Defines routing rules for their service |
In practice, this means a developer can deploy their service and create an HTTPRoute without ever touching load balancer configuration. The platform team sets up the Gateway once, and developers self-service from there.
Before starting this tutorial, you will need:
kubectl configured to connect to your clusterhelm v3 installed on your local machineVerify your cluster is accessible and check its version:
kubectl cluster-info
kubectl version --short
We will use Envoy Gateway as the Gateway API implementation. Envoy Gateway is the CNCF-backed reference implementation built on the Envoy Proxy, the same proxy used by Istio and many service meshes.
Install Envoy Gateway using Helm. This single command installs the controller and all required Gateway API CRDs:
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.1.0 \
-n envoy-gateway-system \
--create-namespace
Wait for the controller to become ready:
kubectl wait --timeout=120s \
-n envoy-gateway-system \
deployment/envoy-gateway \
--for=condition=Available
Verify the controller pod is running:
kubectl get pods -n envoy-gateway-system
Expected output:
NAME READY STATUS RESTARTS AGE
envoy-gateway-7d9d5f45b4-xkpzv 1/1 Running 0 60s
Envoy Gateway automatically creates a GatewayClass upon installation. Confirm it has been accepted by the controller:
kubectl get gatewayclass
Expected output:
NAME CONTROLLER ACCEPTED AGE
envoy-gateway gateway.envoyproxy.io/gatewayclass-controller True 2m
The ACCEPTED: True status means the controller recognized the class and is ready to serve Gateways that reference it. If you see False, the controller pod may still be starting up. Wait a moment and try again.
Create a dedicated namespace and deploy two simple HTTP services. We will use them to demonstrate path-based routing:
kubectl create namespace demo
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: service-a
namespace: demo
spec:
replicas: 1
selector:
matchLabels:
app: service-a
template:
metadata:
labels:
app: service-a
spec:
containers:
- name: backend
image: hashicorp/http-echo:0.2.3
args:
- "-text=Hello from Service A"
ports:
- containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
name: service-a
namespace: demo
spec:
selector:
app: service-a
ports:
- port: 80
targetPort: 5678
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: service-b
namespace: demo
spec:
replicas: 1
selector:
matchLabels:
app: service-b
template:
metadata:
labels:
app: service-b
spec:
containers:
- name: backend
image: hashicorp/http-echo:0.2.3
args:
- "-text=Hello from Service B"
ports:
- containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
name: service-b
namespace: demo
spec:
selector:
app: service-b
ports:
- port: 80
targetPort: 5678
EOF
Confirm both pods are running:
kubectl get pods -n demo
Now create the Gateway resource. This tells Envoy Gateway to provision a load balancer that listens on port 80:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: my-gateway
namespace: demo
spec:
gatewayClassName: envoy-gateway
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: Same
EOF
Check the Gateway status, it may take 30 to 60 seconds to get an external IP:
kubectl get gateway -n demo
Expected output:
NAME CLASS ADDRESS PROGRAMMED AGE
my-gateway envoy-gateway 10.96.200.15 True 45s
The PROGRAMMED: True status means Envoy Gateway has successfully configured the Envoy proxy instances backing this Gateway. Note the ADDRESS field is the IP address you will use to send test traffic.
Create an HTTPRoute that sends /service-a requests to service-a and /service-b requests to service-b:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-route
namespace: demo
spec:
parentRefs:
- name: my-gateway
namespace: demo
hostnames:
- "app.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /service-a
backendRefs:
- name: service-a
port: 80
- matches:
- path:
type: PathPrefix
value: /service-b
backendRefs:
- name: service-b
port: 80
EOF
The parentRefs field is how an HTTPRoute attaches itself to a Gateway. Unlike Ingress, which implicitly connects to whatever controller is installed, this explicit reference makes it clear which Gateway handles this route.
Verify the route was created:
kubectl get httproute -n demo
Get the Gateway’s external IP address:
GATEWAY_IP=$(kubectl get gateway my-gateway -n demo \
-o jsonpath='{.status.addresses[0].value}')
echo "Gateway IP: $GATEWAY_IP"
Send test requests using curl with the Host header set to match the hostname defined in your HTTPRoute:
# Should print: Hello from Service A
curl -H "Host: app.example.com" http://$GATEWAY_IP/service-a
# Should print: Hello from Service B
curl -H "Host: app.example.com" http://$GATEWAY_IP/service-b
Both requests go to the same IP and port but are routed to different backend services based purely on the URL path.
One of the most compelling advantages of Gateway API is native traffic weighting, built directly into the spec with no annotations required. This is ideal for canary deployments where you want to gradually shift traffic to a new version.
The following example sends 80% of requests to service-a and 20% to service-b:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: canary-route
namespace: demo
spec:
parentRefs:
- name: my-gateway
namespace: demo
hostnames:
- "canary.example.com"
rules:
- backendRefs:
- name: service-a
port: 80
weight: 80
- name: service-b
port: 80
weight: 20
EOF
Test the weighted routing by running multiple requests:
for i in $(seq 1 10); do
curl -s -H "Host: canary.example.com" http://$GATEWAY_IP/
done
You will see roughly 8 responses from service-a for every 2 from service-b. With traditional Ingress, achieving this requires controller-specific annotations and the behavior is not guaranteed to be consistent across controllers.
Here is a direct comparison of how both approaches handle common traffic management tasks:
| Feature | Ingress | Gateway API |
|---|---|---|
| Path-based routing | Yes | Yes |
| Host-based routing | Yes | Yes |
| HTTPS/TLS termination | Yes | Yes |
| TCP/UDP routing | Controller-specific | Native (TCPRoute, UDPRoute) |
| gRPC routing | Controller-specific | Native (GRPCRoute) |
| Traffic weighting | Annotations only | Native in spec |
| Header manipulation | Annotations only | Native in spec |
| Request redirects | Annotations only | Native in spec |
| Role-based access | None | GatewayClass / Gateway / Route |
| Cross-namespace routes | Not supported | Yes (with ReferenceGrant) |
| Portability across controllers | Low | High |
| GA status in Kubernetes | Yes | Yes (v1.28+) |
Check whether the HTTPRoute successfully attached to the Gateway:
kubectl describe httproute my-route -n demo
Look for the Status.Parents section in the output. If Conditions shows Accepted: False, the most common causes are:
parentRefs is wrongallowedRoutes.namespaces setting excludes the HTTPRoute’s namespacekubectl describe gateway my-gateway -n demo
If no address appears after 2 minutes, check whether the Envoy Gateway controller is healthy:
kubectl get pods -n envoy-gateway-system
kubectl logs -n envoy-gateway-system deployment/envoy-gateway --tail=50
The Gateway API evaluates route rules in order. More specific matches (like Exact) take priority over broader ones (like PathPrefix). If a request is hitting the wrong backend, review whether a broader rule earlier in the list is capturing it first.
Also note that PathPrefix: /service-a will also match /service-a-extra. If you want strict matching, use Exact instead:
- path:
type: Exact
value: /service-a
This usually means the controller that owns the GatewayClass is not running. Verify the Envoy Gateway deployment is healthy:
kubectl rollout status deployment/envoy-gateway -n envoy-gateway-system
Separate namespaces by role: Deploy Gateways in a dedicated namespace like gateway-system that only the platform team has write access to. Application teams should only have access to create HTTPRoute resources in their own namespaces. This enforces the role-based design the Gateway API was built for.
Be explicit with path match types: Always specify PathPrefix or Exact in your route rules rather than relying on defaults. Ambiguous path matches are a common source of routing bugs.
Use allowedRoutes to restrict access: Set allowedRoutes.namespaces.from: Same or use a label Selector instead of All. Allowing any namespace to bind routes to your Gateway can open unintended access paths.
Terminate TLS at the Gateway: Configure HTTPS listeners in your Gateway using a Kubernetes Secret containing your certificate. Do not pass unencrypted traffic into your cluster from the internet.
listeners:
- name: https
port: 443
protocol: HTTPS
tls:
mode: Terminate
certificateRefs:
- name: my-tls-secret
namespace: demo
Use ReferenceGrant for cross-namespace access: If you need an HTTPRoute in namespace app to reference a backend Service in namespace shared-services, you must create a ReferenceGrant in the target namespace. This is the Gateway API’s security boundary.
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-from-app
namespace: shared-services
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: app
to:
- group: ""
kind: Service
Set resource requests and limits on Gateway-managed proxies: When Envoy Gateway provisions Envoy proxy pods to serve a Gateway, those are real workloads consuming cluster resources. Use EnvoyProxy custom resources to configure resource limits and prevent runaway usage.
The Kubernetes Gateway API is a meaningful upgrade over the traditional Ingress resource. It introduces a clean, role-based separation of concerns across GatewayClass, Gateway, and HTTPRoute, supports multiple protocols natively, and bakes common traffic management features(like weighted routing and header manipulation) directly into the API spec rather than burying them in controller-specific annotations.
In this tutorial you accomplished the following:
GatewayClass was accepted by the controllerGateway that exposes port 80 with namespace-scoped access controlHTTPRoute rules for path-based routingWhat to explore next:
The Kubernetes ecosystem is actively moving toward Gateway API as the standard for all traffic management. If you are starting a new cluster today, it is worth learning Gateway API now rather than migrating later when your Ingress annotations have accumulated into an unmaintainable pile.