Migrating from ingress-nginx to the Kubernetes Gateway API: A Hands-On Cutover Guide
The ingress-nginx project was archived at the start of 2026. No more releases, no more bug fixes, and β the part that should worry you β no more security patches. If you run Kubernetes in production, there’s a decent chance this affects you directly: recent surveys put ingress-nginx usage around half of all clusters. “Do nothing” is now a slow-motion incident.
The community has settled on the Gateway API as the long-term replacement for Ingress. It’s not a new controller; it’s a new set of Kubernetes resources (GatewayClass, Gateway, HTTPRoute) that several controllers implement β Envoy Gateway, Cilium, Istio, Kong, and others. This guide walks a real migration: install a Gateway controller alongside your existing ingress-nginx, convert the Ingress objects, translate the annotations that don’t convert automatically, and cut traffic over host by host without a big-bang maintenance window.
Prerequisites
- A Kubernetes cluster on 1.29+ with
kubectlaccess and cluster-admin for the install steps - Helm 3.14+
- An existing ingress-nginx deployment with real
Ingressobjects to migrate - cert-manager already running if you terminate TLS in-cluster (this guide assumes you do)
ingress2gatewayv0.4.0+ installed locally (go install github.com/kubernetes-sigs/ingress2gateway@latest)- Familiarity with how your current Ingress annotations behave (rewrites, redirects, timeouts, auth)
You should be comfortable reading and diffing YAML manifests and rolling DNS or load-balancer changes.
Architecture and Key Concepts
Ingress packed everything β listeners, hostnames, paths, TLS, and vendor-specific behavior β into one object plus a soup of annotations. The Gateway API splits that surface into three resources owned by three different roles.
flowchart TD
subgraph Infra["Infrastructure team"]
GC[GatewayClass<br/>controllerName: envoy / cilium]
end
subgraph Cluster["Cluster operators"]
GW[Gateway<br/>listeners :80 / :443<br/>TLS cert refs]
end
subgraph Dev["Application teams"]
R1[HTTPRoute<br/>shop.example.com]
R2[HTTPRoute<br/>api.example.com]
end
GC --> GW
GW --> R1
GW --> R2
R1 --> S1[(Service: storefront)]
R2 --> S2[(Service: api)]
R2 -. weighted 90/10 .-> S3[(Service: api-canary)]
GatewayClassnames the controller (spec.controllerName). You install one per controller and rarely touch it again.Gatewaydefines listeners: ports, protocols, hostnames, and TLS certificate references. Cluster operators own it.allowedRoutesdecides which namespaces may attach routes.HTTPRouteholds the routing rules:hostnames,matches(path, headers, method, query),filters(header mutation, redirects, rewrites, mirroring), andbackendRefswith optional weights for traffic splitting. App teams own these, in their own namespaces.
The practical wins: annotations become typed, validated fields; canary routing is native instead of a second Ingress with canary-weight; and a developer can’t accidentally change the TLS config or another team’s hostname because those live in objects they don’t control.
Choosing a controller
| Controller | Data plane | Best when |
|---|---|---|
| Envoy Gateway | Envoy (userspace) | You want the reference implementation, high Gateway API conformance, and portability across clouds |
| Cilium | eBPF for L4, Envoy for L7 | Cilium is already your CNI; you want in-kernel L4 load balancing near line rate |
| Istio | Envoy | You already run Istio and want the mesh and edge to share config |
This guide uses Envoy Gateway for the examples because it’s self-contained and cloud-neutral. Every manifest shown is standard Gateway API v1 β only the GatewayClass controllerName and the install differ for Cilium or Istio.
Step-by-Step Implementation
Installing a Gateway controller alongside ingress-nginx
The two can coexist. ingress-nginx watches Ingress objects; the Gateway controller watches Gateway/HTTPRoute. Install the Gateway API CRDs and Envoy Gateway:
| |
Create the GatewayClass and a shared Gateway. The Gateway gets its own external IP / load balancer, separate from the one ingress-nginx already has β that separation is what makes the cutover safe:
| |
| |
π‘ Keep TLS certificates in the
Gateway, not in every route. One wildcardcertificateRefon the listener covers every*.example.comHTTPRoute. If you need per-host certs, add more listeners with specifichostnamevalues.
Bulk-converting Ingress objects with ingress2gateway
Don’t hand-write dozens of routes. ingress2gateway reads your live Ingress objects (and ingress-nginx annotations it understands) and emits Gateway API YAML:
| |
For a typical Ingress like this:
| |
β¦the tool produces the HTTPRoute skeleton. You still review it β ingress2gateway converts what maps cleanly and leaves comments where it can’t:
| |
Translating annotations that don’t auto-convert
Most ingress-nginx behavior maps to a Gateway API filter or field. The ones you’ll hit most:
| ingress-nginx annotation | Gateway API equivalent |
|---|---|
rewrite-target | URLRewrite filter β path.type: ReplacePrefixMatch |
ssl-redirect / force-ssl-redirect | RequestRedirect filter with scheme: https, statusCode: 301 on an HTTP-listener route |
permanent-redirect | RequestRedirect filter (hostname, path, statusCode: 301) |
proxy-read-timeout / proxy-send-timeout | rules[].timeouts.backendRequest |
configuration-snippet adding headers | RequestHeaderModifier / ResponseHeaderModifier filters |
canary + canary-weight | multiple backendRefs with weight |
enable-cors + cors-* | ResponseHeaderModifier (static) or the controller’s CORS policy CRD |
auth-url / auth-signin (external auth) | controller-specific: Envoy Gateway SecurityPolicy, Cilium CiliumEnvoyConfig |
The HTTPβHTTPS redirect is worth showing because it’s on almost every Ingress. Attach a redirect-only route to the HTTP listener:
| |
Anything annotation-driven that has no field yet β mTLS to the backend, complex Lua, rate limiting β is where you reach for the controller’s own policy CRDs (BackendTrafficPolicy, SecurityPolicy, ClientTrafficPolicy in Envoy Gateway). Inventory those before you start; they’re the long tail of the migration.
TLS and cert-manager
cert-manager understands the Gateway API. Annotate the Gateway instead of an Ingress and it manages the listener certificate:
| |
Enable the cert-manager Gateway API integration with --set config.enableGatewayAPI=true on its Helm chart.
Production Configuration
Canary and traffic splitting
This is native. No second object, no annotation β just weights on backendRefs:
| |
Weights are relative, not percentages β 9 and 1 behave the same as 90 and 10. Progressive delivery tools (Argo Rollouts, Flagger) drive these numbers automatically and both support the Gateway API as a first-class provider.
Cross-namespace routes with ReferenceGrant
By default an HTTPRoute can only reference Services in its own namespace, and a Gateway only accepts routes from namespaces its allowedRoutes permits. To route to a Service in another namespace, the owning namespace must publish a ReferenceGrant:
| |
This is a feature, not friction: cross-namespace access is now explicit and auditable instead of implicit.
The side-by-side cutover
Run both stacks and move traffic per hostname:
- Deploy the
Gatewaywith its own load balancer IP. Nothing points at it yet. - Create
HTTPRoutes for one hostname. Verify with a host header against the Gateway IP directly:1 2curl -H "Host: shop.example.com" -kso /dev/null -w "%{http_code}\n" \ https://<gateway-ip>/healthz - Shift DNS for
shop.example.comfrom the ingress-nginx LB to the Gateway LB. Lower the TTL to 60s a day before so rollback is fast. - Watch both sets of access logs. ingress-nginx traffic for that host drains to zero as caches expire.
- Repeat per hostname. When the last one is moved and ingress-nginx logs are quiet for a full day, delete the
Ingressobjects, then uninstall ingress-nginx.
Rollback at any step is a DNS change back. No route is ever served by both stacks at once, so there’s no double-routing risk.
Common Mistakes and Troubleshooting
HTTPRoute shows Accepted: False with reason NotAllowedByListeners. The Gateway listener’s allowedRoutes.namespaces doesn’t include the route’s namespace, or the route’s hostnames don’t intersect the listener hostname. Check kubectl describe httproute β the status conditions tell you exactly which.
404 from Envoy but the route looks right. parentRefs is missing the namespace (it defaults to the route’s own namespace, not the Gateway’s), or sectionName points at a listener that doesn’t exist.
rewrite-target regex captures don’t translate. Gateway API rewrites are prefix-based, not regex. path: /app(/|$)(.*) β rewrite /$2 becomes PathPrefix: /app + ReplacePrefixMatch: /. Genuinely regex-dependent rewrites need RegularExpression path match (implementation-specific) or a small change to the app’s routing.
TLS handshake fails after cutover. The certificateRef Secret is in the Gateway’s namespace but the Gateway is elsewhere, or cert-manager hasn’t issued yet. kubectl get certificate -A and check the Gateway status Programmed condition.
External auth / OAuth2 proxy stopped working. auth-url has no Gateway API core equivalent. You need the controller’s SecurityPolicy (Envoy Gateway) or an equivalent, wired to the same OIDC provider. Don’t cut this hostname over until that’s tested.
Client IP is now the load balancer’s. Set externalTrafficPolicy: Local on the Gateway’s Service, or configure the controller’s ClientTrafficPolicy to trust X-Forwarded-For from your LB.
Performance and Scalability
- Cilium’s eBPF data plane handles L4 in the kernel and only punts L7 (HTTP header edits, gRPC) to Envoy. If you’re already on Cilium as your CNI, this is measurably lower latency and CPU than a userspace-only proxy at high connection counts.
- Envoy Gateway provisions one Envoy deployment per
Gateway(or perGatewayClass, configurable). Size it with anEnvoyProxyresource and an HPA; the default two replicas is a starting point, not a production number. - Fewer reloads. ingress-nginx reloaded and re-forked NGINX on many Ingress changes. Envoy applies route changes via xDS with no connection drops, so high-churn environments stop paying the reload tax.
- One Gateway, many routes. Don’t create a
Gatewayper team β that’s a load balancer per team. Share aGateway, isolate with namespaces andallowedRoutes. - Keep route objects small. A few hundred
HTTPRoutes is fine; a single 5,000-rule route is not. Split by hostname or path group.
Conclusion and Next Steps
ingress-nginx being archived forces a decision, but the Gateway API is a genuine upgrade rather than a lateral move: typed configuration instead of annotation strings, native canary, real role separation, and a spec that multiple controllers implement so you’re not locked to one project’s lifecycle again.
The migration itself is mechanical if you stage it: install a controller beside ingress-nginx, convert with ingress2gateway, translate the annotation long tail into filters and policy CRDs, then cut over one hostname at a time behind DNS.
Next steps:
- Run
ingress2gateway printagainst every namespace now, just to see the size of your annotation long tail. - Pick the controller that matches your stack (Cilium if it’s your CNI, Envoy Gateway otherwise) and stand up a non-prod
Gateway. - Wire Argo Rollouts or Flagger to the Gateway API so canaries are automated from day one.
- Add
BackendTrafficPolicyfor retries, timeouts, and circuit breaking that used to live in annotations.