25. Traffic in: Gateway API, TLS, and cert-manager
25.1 Ingress → Gateway API
The Ingress API is frozen. Its common controller, ingress-nginx, was
retired by Kubernetes SIG Network in March 2026 (no further releases or
security fixes). Annotations-for-everything also made Ingress unportable.
Gateway API is the successor: typed, role-oriented, and supported by
Envoy Gateway, Istio, Cilium, NGINX Gateway Fabric, Traefik, Kong, and the
cloud LBs (GKE, AWS LB Controller, Azure).
GatewayClass (infra provider: "use Envoy Gateway") owned by: platform team
└─ Gateway (listeners: :443 for *.orbit.example, TLS cert) owned by: platform team
└─ HTTPRoute (orbit.example/api/orders -> svc orders:80) owned by: app team, in its namespace
⚠️ Caveat (verify): confirm the ingress-nginx retirement status and dates on the Kubernetes blog (kubernetes.io/blog) before citing them. The direction (move to Gateway API) is well established; the exact dates are what to check.
25.2 Install Envoy Gateway and cert-manager
helm upgrade --install eg oci://docker.io/envoyproxy/gateway-helm \
-n envoy-gateway-system --create-namespace --wait
helm repo add jetstack https://charts.jetstack.io
helm upgrade --install cert-manager jetstack/cert-manager -n cert-manager --create-namespace \
--set crds.enabled=true --set config.enableGatewayAPI=true --wait
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata: { name: eg }
spec: { controllerName: gateway.envoyproxy.io/gatewayclass-controller }
---
# Lab: a self-signed CA. Production: ACME/Let's Encrypt (below) or your corporate CA.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: selfsigned }
spec: { selfSigned: {} }
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public
namespace: envoy-gateway-system
annotations: { cert-manager.io/cluster-issuer: selfsigned } # cert-manager creates the Secret
spec:
gatewayClassName: eg
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "orbit.localtest.me" # *.localtest.me resolves to 127.0.0.1
tls: { mode: Terminate, certificateRefs: [{ name: orbit-tls }] }
allowedRoutes: { namespaces: { from: Selector, selector: { matchLabels: { gateway-access: "true" } } } }
- name: http
protocol: HTTP
port: 80
allowedRoutes: { namespaces: { from: Same } }
---
# HTTP -> HTTPS redirect
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: https-redirect, namespace: envoy-gateway-system }
spec:
parentRefs: [{ name: public, sectionName: http }]
rules: [{ filters: [{ type: RequestRedirect, requestRedirect: { scheme: https, statusCode: 301 } }] }]
Production issuer:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: letsencrypt }
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: platform@acme.example
privateKeySecretRef: { name: letsencrypt-account }
solvers:
- http01: { gatewayHTTPRoute: { parentRefs: [{ name: public, namespace: envoy-gateway-system, kind: Gateway }] } }
⚠️ Caveat (verify): pin
--versionfor both charts. Theconfig.enableGatewayAPIvalue and the Gateway-annotation certificate flow depend on the cert-manager version. Check its Gateway API docs page; on older releases it was a feature-gate flag. On kind, the Gateway gets an address only whilecloud-provider-kindis running.
25.3 Routes owned by the app team
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: orbit-api, namespace: orbit } # namespace labeled gateway-access=true
spec:
parentRefs: [{ name: public, namespace: envoy-gateway-system, sectionName: https }]
hostnames: ["orbit.localtest.me"]
rules:
- matches: [{ path: { type: PathPrefix, value: /api/products } }]
filters:
- type: URLRewrite
urlRewrite: { path: { type: ReplacePrefixMatch, replacePrefixMatch: /products } }
backendRefs: [{ name: catalog, port: 80 }]
- matches: [{ path: { type: PathPrefix, value: /api/orders }, method: POST }]
filters:
- type: URLRewrite
urlRewrite: { path: { type: ReplacePrefixMatch, replacePrefixMatch: /orders } }
timeouts: { request: 5s }
backendRefs: [{ name: orders, port: 80 }]
kubectl label namespace orbit gateway-access=true
kubectl get gateway -n envoy-gateway-system public # PROGRAMMED=True, ADDRESS from cloud-provider-kind
GW=$(kubectl get gateway -n envoy-gateway-system public -o jsonpath='{.status.addresses[0].value}')
curl -sk --resolve orbit.localtest.me:443:$GW https://orbit.localtest.me/api/products | jq
Traffic splitting (the foundation for canaries, §37) is just weights:
backendRefs:
- { name: orders, port: 80, weight: 90 }
- { name: orders-canary, port: 80, weight: 10 }
25.4 Edge resilience policies (Envoy Gateway)
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata: { name: orders-resilience, namespace: orbit }
spec:
targetRefs: [{ group: gateway.networking.k8s.io, kind: HTTPRoute, name: orbit-api }]
retry:
numRetries: 2
retryOn: { triggers: [connect-failure, retriable-status-codes], httpStatusCodes: [503] }
perRetry: { backOff: { baseInterval: 100ms, maxInterval: 1s } }
circuitBreaker: { maxPendingRequests: 1024, maxParallelRequests: 1024 }
rateLimit:
type: Local
local: { rules: [{ limit: { requests: 200, unit: Second } }] }
Retrying POST /orders at the edge is safe only because the API
requires an Idempotency-Key. That's the payoff of designing for it in
§11.
⚠️ Caveat (verify): Envoy Gateway's
BackendTrafficPolicyisv1alpha1, and its field names (retry, circuitBreaker, rateLimit) have moved between releases. Validate against the installed CRDs:kubectl apply --dry-run=server -f policy.yaml. Local rate limiting may need extra configuration depending on the version.