If you’ve worked with Kubernetes Ingress resources for any length of time, you’ve hit the walls. You want to split traffic between two backend versions — no standard way to do it. You want to route based on a request header — vendor annotation required. You want your platform team to control which TLS certificates are allowed while letting application teams manage their own routes — impossible without hacking the RBAC model. You end up with dozens of nginx.ingress.kubernetes.io/ annotations that tie you to a specific implementation and break the moment you switch controllers.
The Kubernetes Gateway API was designed to fix all of this. It graduated to GA (v1) in late 2023 and is now the recommended path for new Kubernetes networking. It’s expressive enough to replace Ingress, service meshes, and many custom networking controllers — all with portable, implementation-agnostic resources.
What’s Wrong with Ingress
The Kubernetes Ingress API was designed for a narrow use case: HTTP/HTTPS routing from outside the cluster to services inside it. It handles host-based and path-based routing and nothing else. Every real-world requirement beyond that gets addressed through implementation-specific annotations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
# This works only with nginx-ingress — tie yourself to one implementation
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "20"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "10"
nginx.ingress.kubernetes.io/rate-limit: "100"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/use-regex: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts: [app.example.com]
secretName: app-tls
rules:
- host: app.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-service
port:
number: 8080
|
There’s no RBAC separation between infrastructure owners and application teams, no standard traffic-splitting mechanism, no gRPC or TCP routing, and no way to express cross-cutting policies. The annotation soup is unportable and undiscoverable.
The Gateway API Model
Gateway API introduces four main resources with a deliberate role-based separation:
GatewayClass ← infrastructure provider (cluster admin)
↓
Gateway ← cluster operator / network admin
↓
HTTPRoute ← application developer
TCPRoute ← application developer
GRPCRoute ← application developer
This role separation is built into the API design, not bolted on with RBAC hacks.
GatewayClass — defined by the infrastructure team or installed by the controller. Describes what implementation handles Gateways (Envoy, Traefik, nginx, Cilium, etc.).
Gateway — requests a load balancer / listener. Defines ports, protocols, and TLS. Owned by the network/platform team. Can restrict which namespaces and Routes are allowed to attach.
HTTPRoute — defines routing rules for HTTP traffic. Owned by application teams. Attaches to a Gateway in another namespace if allowed.
GRPCRoute, TCPRoute, TLSRoute, UDPRoute — same model for other protocols.
Installing Gateway API CRDs
Gateway API ships as CRDs separate from Kubernetes itself. Install the standard channel (stable features):
1
|
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml
|
For experimental features (TCPRoute, UDPRoute, BackendLBPolicy):
1
|
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/experimental-install.yaml
|
Then install a Gateway controller. We’ll use Envoy Gateway (the reference implementation backed by the Envoy proxy project):
1
2
3
4
5
6
7
8
9
|
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.2.0 \
--namespace envoy-gateway-system \
--create-namespace
kubectl wait --timeout=5m \
-n envoy-gateway-system \
deployment/envoy-gateway \
--for=condition=Available
|
Other well-supported implementations: Traefik v3, Cilium, nginx Gateway Fabric, Istio, Linkerd.
Basic HTTP Routing
A minimal setup that replicates what Ingress does:
1
2
3
4
5
6
7
|
# 1. GatewayClass — installed by the controller, you usually don't write this
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
# 2. Gateway — owned by network/platform team
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: production
namespace: infra
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
# Allow HTTPRoutes from any namespace to attach
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: wildcard-tls
namespace: infra
allowedRoutes:
namespaces:
from: All
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
# 3. HTTPRoute — owned by the application team, lives in the app namespace
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app
namespace: production
spec:
# Attach to the Gateway in the infra namespace
parentRefs:
- name: production
namespace: infra
sectionName: https # Attach to the HTTPS listener specifically
hostnames:
- "app.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: api-service
port: 8080
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: frontend-service
port: 3000
|
This is already cleaner than Ingress: the platform team controls the Gateway (what ports, what TLS certs, which namespaces can attach), and the application team controls the HTTPRoute (their own routing rules) without needing any special permissions on the Gateway itself.
Traffic Splitting and Canary Releases
This is where Gateway API shines. Traffic splitting is a first-class concept, not an annotation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-canary
namespace: production
spec:
parentRefs:
- name: production
namespace: infra
hostnames:
- "api.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
# 90% traffic to stable, 10% to canary
- name: api-stable
port: 8080
weight: 90
- name: api-canary
port: 8080
weight: 10
|
Update the weights progressively as confidence in the canary grows:
1
2
3
4
5
6
7
8
9
10
11
|
# Shift to 50/50
kubectl patch httproute api-canary -n production --type='json' -p='[
{"op": "replace", "path": "/spec/rules/0/backendRefs/0/weight", "value": 50},
{"op": "replace", "path": "/spec/rules/0/backendRefs/1/weight", "value": 50}
]'
# Full cutover
kubectl patch httproute api-canary -n production --type='json' -p='[
{"op": "replace", "path": "/spec/rules/0/backendRefs/0/weight", "value": 100},
{"op": "replace", "path": "/spec/rules/0/backendRefs/1/weight", "value": 0}
]'
|
Route traffic based on headers without any annotations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-header-routing
namespace: production
spec:
parentRefs:
- name: production
namespace: infra
hostnames:
- "api.example.com"
rules:
# Beta users get routed to the new backend
- matches:
- headers:
- name: X-User-Tier
value: beta
backendRefs:
- name: api-v2
port: 8080
# Internal traffic (from CI, test tools) goes to the canary
- matches:
- headers:
- name: X-Internal-Request
value: "true"
backendRefs:
- name: api-canary
port: 8080
# Everyone else hits stable
- backendRefs:
- name: api-stable
port: 8080
|
You can also match on query parameters, HTTP method, or combinations:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
rules:
# Only match POST requests to /checkout with a specific query param
- matches:
- path:
type: Exact
value: /checkout
method: POST
queryParams:
- name: debug
value: "true"
backendRefs:
- name: checkout-debug
port: 8080
|
Request and Response Modification
Gateway API includes built-in filters for common HTTP manipulation tasks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-with-filters
namespace: production
spec:
parentRefs:
- name: production
namespace: infra
rules:
- matches:
- path:
type: PathPrefix
value: /api
filters:
# Add, set, or remove request headers before forwarding
- type: RequestHeaderModifier
requestHeaderModifier:
add:
- name: X-Forwarded-By
value: gateway
- name: X-Request-Start
value: "%START_TIME%"
set:
- name: X-Service-Version
value: "v2"
remove:
- X-Internal-Debug
# Rewrite the URL path before forwarding
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /v2
backendRefs:
- name: api-service
port: 8080
|
1
2
3
4
5
6
7
8
9
10
11
|
filters:
- type: ResponseHeaderModifier
responseHeaderModifier:
add:
- name: X-Content-Type-Options
value: nosniff
- name: X-Frame-Options
value: DENY
set:
- name: Cache-Control
value: "no-store, max-age=0"
|
Request Mirroring
Send a copy of traffic to a second backend for testing or debugging without affecting the primary response:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
rules:
- matches:
- path:
type: PathPrefix
value: /api
filters:
# Mirror 10% of traffic to the shadow service
- type: RequestMirror
requestMirror:
backendRef:
name: api-shadow
port: 8080
percent: 10
backendRefs:
- name: api-service
port: 8080
|
HTTP to HTTPS Redirect
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-redirect
namespace: infra
spec:
parentRefs:
- name: production
namespace: infra
sectionName: http # Attach to HTTP listener only
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
|
gRPC Routing
GRPCRoute is a first-class resource for routing gRPC traffic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
name: payment-grpc
namespace: production
spec:
parentRefs:
- name: production
namespace: infra
hostnames:
- "grpc.example.com"
rules:
# Route to v2 for the new ChargeV2 method
- matches:
- method:
service: payments.PaymentService
method: ChargeV2
backendRefs:
- name: payments-v2
port: 9090
# Everything else hits v1
- matches:
- method:
service: payments.PaymentService
backendRefs:
- name: payments-v1
port: 9090
|
TLS Configuration
Per-Route TLS with cert-manager
cert-manager supports Gateway API natively:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
# Gateway with automated certificate management
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: production
namespace: infra
annotations:
# cert-manager will create and manage the certificate
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: eg
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: wildcard-tls # cert-manager creates this
|
TLS Passthrough
For services that terminate TLS themselves (like databases or services requiring mTLS end-to-end):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TLSRoute
metadata:
name: postgres-tls
namespace: production
spec:
parentRefs:
- name: production
namespace: infra
sectionName: tls-passthrough
hostnames:
- "db.internal.example.com"
rules:
- backendRefs:
- name: postgres
port: 5432
|
1
2
3
4
5
6
7
|
# Gateway listener for TLS passthrough
listeners:
- name: tls-passthrough
protocol: TLS
port: 5432
tls:
mode: Passthrough # Forward encrypted traffic without terminating
|
Cross-Namespace Routing with ReferenceGrants
By default, an HTTPRoute can only reference backends (Services) in its own namespace. To reference a Service in another namespace, the target namespace must grant permission with a ReferenceGrant:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# In the 'shared-services' namespace — allows routes from 'production' to reference services here
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-production-routes
namespace: shared-services
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: production
to:
- group: ""
kind: Service
|
This makes cross-namespace service sharing explicit and auditable rather than implicit.
Policy Attachment
Policy attachment is how Gateway API handles cross-cutting concerns like timeouts, retries, rate limiting, and authentication — without annotations. Policies attach to Gateway, HTTPRoute, or Service resources and apply to matching traffic.
Timeout and Retry Policy (Envoy Gateway)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: api-timeouts
namespace: production
spec:
targetRef:
group: gateway.networking.k8s.io
kind: HTTPRoute
name: api-route
timeout:
request: 30s
retry:
numRetries: 3
perRetry:
timeout: 5s
retryOn:
httpStatusCodes: [503, 504]
triggers: [connect-failure, retriable-4xx]
|
Rate Limiting Policy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: api-rate-limit
namespace: production
spec:
targetRef:
group: gateway.networking.k8s.io
kind: HTTPRoute
name: api-route
rateLimit:
type: Global
global:
rules:
# 1000 requests per minute per distinct client IP
- clientSelectors:
- headers:
- name: x-forwarded-for
type: Distinct
limit:
requests: 1000
unit: Minute
# 50 requests per minute for unauthenticated requests
- clientSelectors:
- headers:
- name: Authorization
type: Absent
limit:
requests: 50
unit: Minute
|
Authentication Policy (JWT)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: jwt-auth
namespace: production
spec:
targetRef:
group: gateway.networking.k8s.io
kind: HTTPRoute
name: api-route
jwt:
providers:
- name: keycloak
issuer: https://auth.example.com/realms/myrealm
audiences:
- my-api
remoteJWKS:
uri: https://auth.example.com/realms/myrealm/protocol/openid-connect/certs
|
Migrating from Ingress
Migration can be gradual — both Ingress and Gateway API can coexist in the same cluster. Here’s a step-by-step approach:
Step 1: Install Gateway API CRDs and your chosen controller alongside the existing Ingress controller.
Step 2: Create a Gateway resource that matches your existing Ingress controller’s listener configuration.
Step 3: For each Ingress, create an equivalent HTTPRoute. Use this mapping:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
# Old Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts: [app.example.com]
secretName: app-tls
rules:
- host: app.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-service
port:
number: 8080
---
# Equivalent HTTPRoute
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app
namespace: production
spec:
parentRefs:
- name: production
namespace: infra
hostnames:
- "app.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /api
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: api-service
port: 8080
|
Step 4: Test the new routes with a curl against the new Gateway’s address.
Step 5: Update DNS to point to the new Gateway. Delete the old Ingress.
Step 6: After all Ingresses are migrated, remove the old Ingress controller.
Checking Route Status
Gateway API gives detailed status on whether routes are accepted:
1
2
3
4
5
|
# Check Gateway status
kubectl describe gateway production -n infra
# Check HTTPRoute status — shows whether it's attached and any errors
kubectl describe httproute my-app -n production
|
Successful attachment looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
Status:
Parents:
- ParentRef:
Group: gateway.networking.k8s.io
Kind: Gateway
Name: production
Namespace: infra
SectionName: https
Conditions:
- Type: Accepted
Status: "True"
Reason: Accepted
- Type: ResolvedRefs
Status: "True"
Reason: ResolvedRefs
|
If a route is rejected (wrong namespace, wrong listener, invalid backend), the status shows exactly why:
1
2
3
4
5
|
Conditions:
- Type: ResolvedRefs
Status: "False"
Reason: RefNotPermitted
Message: "Cross-namespace reference to Service 'other-ns/api-service' not allowed without ReferenceGrant"
|
Choosing a Gateway Controller
| Controller |
Best For |
Notable Features |
| Envoy Gateway |
General purpose, greenfield |
Official reference impl, rich policy API |
| Traefik v3 |
Homelab, Docker + K8s |
Easiest setup, great UI |
| Cilium |
eBPF networking |
No sidecar, kernel-native, combined CNI+Gateway |
| nginx Gateway Fabric |
Migrating from nginx-ingress |
Familiar, good performance |
| Istio |
Service mesh + ingress |
mTLS, traffic management, observability |
| Linkerd |
Lightweight service mesh |
Low overhead, automatic mTLS |
For a homelab or small cluster: Traefik v3 has the easiest setup and a built-in dashboard. For production on Kubernetes with an existing Cilium CNI: the Cilium gateway controller eliminates a separate proxy entirely.
Traefik v3 with Gateway API
1
2
3
4
5
6
7
8
9
10
11
12
|
# values.yaml for traefik helm chart
providers:
kubernetesGateway:
enabled: true # Enable Gateway API support
ingressRoute:
dashboard:
enabled: true
experimental:
kubernetesGateway:
enabled: true
|
1
2
3
4
|
helm upgrade --install traefik traefik/traefik \
--namespace traefik \
--create-namespace \
-f values.yaml
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
# GatewayClass is created automatically by Traefik
# Just create your Gateway:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: traefik
namespace: traefik
spec:
gatewayClassName: traefik
listeners:
- name: web
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: All
- name: websecure
port: 443
protocol: HTTPS
tls:
certificateRefs:
- kind: Secret
name: wildcard-tls
allowedRoutes:
namespaces:
from: All
|
Real-World Pattern: Multi-Team Gateway
Here’s a production-grade multi-team setup where the platform team owns the Gateway and application teams manage their own routes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
# Platform team: gateway.yaml (namespace: infra)
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: production
namespace: infra
spec:
gatewayClassName: eg
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: wildcard-tls
allowedRoutes:
namespaces:
# Only allow routes from namespaces with this label
from: Selector
selector:
matchLabels:
gateway-access: allowed
|
1
2
3
4
|
# Platform team: label production namespaces
kubectl label namespace payments gateway-access=allowed
kubectl label namespace checkout gateway-access=allowed
kubectl label namespace auth gateway-access=allowed
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
# Payments team: their HTTPRoute (namespace: payments)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: payments-api
namespace: payments
spec:
parentRefs:
- name: production
namespace: infra
hostnames:
- "payments.example.com"
rules:
- backendRefs:
- name: payments-api
port: 8080
|
Each team has full control over their routing rules. The platform team controls which teams can attach to the Gateway. No one needs to touch the Gateway resource to update routing — RBAC naturally enforces the separation.
Conclusion
The Kubernetes Gateway API is not just “Ingress with more features.” It’s a fundamental redesign that treats network routing as a role-based concern, separates infrastructure from application configuration, and makes complex routing patterns (traffic splitting, header matching, mirroring) first-class API concepts rather than implementation-specific annotations.
The migration path is gentle — Gateway API coexists with Ingress, and the tooling (cert-manager, external-dns, ArgoCD) already supports it. The annotation soup that accumulated on your Ingress resources can be replaced with readable, portable YAML that works across any compliant controller.
If you’re starting a new cluster today, skip the Ingress API entirely and start with Gateway. If you have an existing cluster, migrate one application at a time — the investment pays off every time you need traffic splitting, per-route timeouts, or cross-team route ownership without a kubectl escalation.
Comments