By the end of this you will have the same production traffic that Ingress-NGINX served now flowing through a conformant Gateway API data plane on Envoy Gateway, with every annotation that couldn't translate surfaced and dealt with on purpose instead of lost in a log line. That last part is the whole point: the migration breaks on the annotations nobody checked, not on the YAML you can ask an AI to write.
On November 11, 2025, Kubernetes SIG Network announced that Ingress-NGINX retires in March 2026. No more releases, no bugfixes, and the line that gets a CISO's attention: no security patches. The repos go read-only. Your deployments keep running, but every future CVE in that controller is permanently your problem. This guide moves a real workload off it using ingress2gateway v1.0.0 (released March 20, 2026) to translate manifests, then cuts traffic over without a hard outage.
This is written for platform engineers already running Ingress-NGINX in production. It assumes you know what an Ingress is and skips the conceptual tour.
Prerequisites
- A Kubernetes cluster on v1.26+ (the Gateway API v1.5 CRDs require it), with
kubectland cluster-admin. - Helm 3.x, plus Go 1.22+ or Homebrew to install
ingress2gateway. - An existing Ingress-NGINX deployment with at least one
Ingressobject you can read. - A cloud LoadBalancer provisioner (managed clusters have this) or MetalLB on bare metal.
- Edit access to the DNS records for the hostnames you serve. This is a DNS cutover, so without it you can't finish.
Step-by-step
1. Inventory what Ingress-NGINX is actually doing
kubectl get ingress -A -o yaml | grep -E "nginx.ingress.kubernetes.io" | sort -u
This lists every NGINX annotation in use across the cluster. Do it first, because the outcome of the whole migration is decided here. proxy-body-size, use-regex, CORS, and path rewrites have Gateway API equivalents. configuration-snippet, auth-url external auth, and server-snippet do not map to any standard field. If your output is full of the second group, you have design work ahead, not just a translation.
2. Install the Gateway API CRDs (Standard channel)
kubectl apply --server-side -f \
https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml
--server-side is mandatory. These CRDs exceed the client-side annotation size limit, and a plain kubectl apply errors out without it. The Standard channel ships stable GatewayClass, Gateway, and HTTPRoute under gateway.networking.k8s.io/v1. Skip the Experimental channel unless you specifically need TCPRoute or TLSRoute.
3. Install a conformant controller (Envoy Gateway)
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.6.3 -n envoy-gateway-system --create-namespace
kubectl wait --timeout=5m -n envoy-gateway-system \
deployment/envoy-gateway --for=condition=Available
Envoy Gateway is the highest-conformance implementation in 2026, which is why I default to it for a clean migration. If you already run a mesh, Istio or Cilium Gateway API support lets you avoid standing up a separate data plane. Pick Kong if you depend on its plugin ecosystem. The mechanics below don't change; only the controllerName does.
4. Create a GatewayClass and Gateway
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: eg
namespace: default
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
kubectl apply -f gateway.yaml
Here is the split that trips people coming from Ingress: a Gateway owns the listener and the LoadBalancer (the infrastructure), while HTTPRoute objects own the routing rules (the app). One platform-owned Gateway, many team-owned routes. Watch the controller name. It's gateway.envoyproxy.io/gatewayclass-controller. Several older blog snippets show .../controller, which is wrong, and a GatewayClass with the wrong name will sit there and never reach Accepted.
5. Translate your Ingress with ingress2gateway 1.0
go install github.com/kubernetes-sigs/ingress2gateway@v1.0.0
# or: brew install ingress2gateway
ingress2gateway print --providers=ingress-nginx -A > converted.yaml
ingress2gateway reads live Ingress objects (or a file via --input-file=manifest.yaml) and prints equivalent Gateway API YAML to stdout. It is a translator, not a controller: it applies nothing to your cluster. The 1.0 release added support for 30+ Ingress-NGINX annotations, including CORS, backend TLS, regex matching, path rewrite, and proxy-body-size.
Read the stderr warnings. Anything the tool can't translate gets dropped and reported on stderr, and if you redirected only stdout to a file (as above), it's easy to scroll right past the line telling you your external-auth annotation just evaporated. In practice this is the step that bites people: they diff the pretty YAML, see clean HTTPRoutes, and never notice the three annotations that didn't make the cut. Run it once without the > redirect and actually read what it complains about.
6. Reconcile the generated HTTPRoutes
Open converted.yaml. The tool emits its own Gateway. Delete it and point your routes at the Gateway from step 4 via parentRefs. A typical route after editing:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: app
namespace: default
spec:
parentRefs:
- name: eg
hostnames: ["app.example.com"]
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: app-svc
port: 8080
kubectl apply -f converted.yaml
If a backend Service lives in a different namespace from the route, the route is silently rejected until you add a ReferenceGrant in the backend's namespace allowing the reference. This is the single most-missed step in the whole migration, and it fails quietly: the HTTPRoute exists, looks fine, and just doesn't route.
7. Cut traffic over with a weighted canary
Both controllers run in parallel now, each with its own external IP. Shift gradually by weighting backends inside one HTTPRoute, then flip DNS:
rules:
- backendRefs:
- name: app-svc
port: 8080
weight: 90
- name: app-svc-canary
port: 8080
weight: 10
Validate at 10%, ramp the weight, then repoint the hostname's DNS record from the Ingress-NGINX LoadBalancer IP to the Gateway's IP. Keep Ingress-NGINX running until DNS TTLs expire and traffic fully drains off the old IP.
Verify it works
Confirm the Gateway got an address and is programmed, then hit it directly so DNS isn't in the picture:
kubectl get gateway eg -o jsonpath='{.status.addresses[0].value}{"\n"}'
kubectl get httproute app -o jsonpath='{.status.parents[0].conditions}'
GW_IP=$(kubectl get gateway eg -o jsonpath='{.status.addresses[0].value}')
curl -H "Host: app.example.com" http://$GW_IP/api/health
Success looks like this: a real IP or hostname in the Gateway address, an HTTPRoute carrying both type: Accepted, status: "True" and type: ResolvedRefs, status: "True", and your app's normal response from the curl. If you see ResolvedRefs: False, it's almost always a missing ReferenceGrant or a wrong Service name or port, in that order of likelihood.
Common pitfalls
-
configuration-snippetandserver-snippetdon't migrate. There is no standard Gateway API field for raw NGINX config. You re-express the intent as a controller-specific policy (Envoy Gateway'sBackendTrafficPolicyorSecurityPolicy) or you drop it.ingress2gatewaywarns and skips. -
External auth (
auth-url,auth-signin) has no standard equivalent. It maps to implementation-specific CRDs, for example Envoy Gateway'sSecurityPolicy. Don't assume your auth gate survived the translation. Test it explicitly with a request that should be rejected, and confirm it actually gets rejected. -
Regex paths aren't portable. Ingress-NGINX
use-regexbecomes HTTPRoute path typeRegularExpression, which is implementation-specific and not guaranteed across controllers. PreferPathPrefixorExactwherever the routing allows it. -
TLS moves from annotations to listeners. cert-manager issues certs for Gateways only with its Gateway API support enabled (
--feature-gates=ExperimentalGatewayAPI=true) and the cert-manager annotation placed on theGateway, not on the route. Miss this and HTTPS quietly never provisions while everything else looks healthy. -
It is not an in-place swap. Two controllers means two LoadBalancers and two IPs. The cutover is a DNS change with a TTL drain, so plan a maintenance window rather than expecting a single
kubectl applyto flip everything.
Wrap-up
You now have a conformant Gateway API data plane serving the traffic Ingress-NGINX used to, with every unsupported annotation surfaced and consciously handled instead of silently lost. Once DNS has fully drained and you've watched the new path for a few days, uninstall Ingress-NGINX with helm uninstall so the retiring, unpatched controller stops being an attack surface in your cluster.
Then formalize the boundary the Gateway API hands you for free: keep one platform-owned Gateway and give teams namespaced HTTPRoute and ReferenceGrant access through RBAC. That clean role split between infrastructure and application routing is something Ingress never allowed, and it's reason enough to be glad about the move even before the retirement deadline forces it.
Sources
- https://kubernetes.io/blog/2025/11/11/ingress-nginx-retirement/
- https://kubernetes.io/blog/2026/03/20/ingress2gateway-1-0-release/
- https://gateway-api.sigs.k8s.io/guides/getting-started/migrating-from-ingress-nginx/
- https://gateway.envoyproxy.io/latest/tasks/quickstart/
- https://github.com/kubernetes-sigs/ingress2gateway
Top comments (0)