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.

The Problem with Kubernetes Ingress

Before diving into Gateway API, let’s understand why it was created in the first place.

What Is Ingress?

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?

Limitations of Ingress

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.

Introducing the Kubernetes Gateway API

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

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

Gateway

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 (and Other Route Types)

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 Role-Based Design

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.

Prerequisites

Before starting this tutorial, you will need:

Verify your cluster is accessible and check its version:

kubectl cluster-info
kubectl version --short

Step-by-Step Tutorial: Setting Up Gateway API with Envoy Gateway

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.

Step 1: Install Envoy Gateway

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

Step 2: Verify the GatewayClass

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.

Step 3: Deploy Sample Applications

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

Step 4: Create a Gateway

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.

Step 5: Create an HTTPRoute for Path-Based Routing

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

Step 6: Test the Routing

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.

Step 7: Native Traffic Splitting (A Feature Ingress Cannot Do)

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.

Gateway API vs Ingress: Feature Comparison

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+)

Common Mistakes and Troubleshooting

HTTPRoute is created but traffic is not routing

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:

Gateway is stuck without an IP address

kubectl 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

Requests are matching the wrong route rule

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

GatewayClass shows ACCEPTED: False

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

Best Practices

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.

Conclusion

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:

What 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.