Ingress-NGINX loses its security patches in March 2026, so the traffic it serves has to end up somewhere conformant. This walks that move end to end onto a 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 point: the migration breaks on the annotations nobody checked, not on the YAML you can ask an AI to write.
TL;DR: Stand up the Gateway API v1.5 CRDs and Envoy Gateway alongside your running Ingress-NGINX, translate your Ingress objects with ingress2gateway v1.0.0, then shift traffic with weighted backends and a DNS cutover. The translation step is where the risk lives: configuration-snippet, server-snippet, and auth-url external auth have no standard equivalent and disappear with only a stderr warning. Read that warning before you apply anything.
On November 11, 2025, Kubernetes SIG Network published its Ingress-NGINX retirement announcement: the controller 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.
Which annotations survive the move?
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.
The Gateway API project's migration guide from Ingress-NGINX is the reference to keep open while you read that list, because it is explicit about which behaviours have a standard home and which ones become implementation-specific policy.
How do you install the Gateway API CRDs?
2. Apply the 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.
Large CRDs and tightened API server schema validation are a recurring source of upgrade-day surprises, in the same family as the unrecognized format int32 failures in Kubernetes 1.34. Apply the CRDs in their own change, and confirm they are established before you install any controller on top of them.
Which Gateway API controller should you pick?
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
That chart and namespace come straight from Envoy Gateway's quickstart task. 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; on Cilium, budget the same care you would for the 1.19 upgrade traps, since you are changing the thing that carries north-south traffic. 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.
How do you translate Ingress objects into HTTPRoutes?
5. Run 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. As the kubernetes-sigs/ingress2gateway README makes clear, it is a translator, not a controller: it applies nothing to your cluster. The 1.0 release announcement lists 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.
How do you cut traffic over without an outage?
7. Weight the backends, then move DNS
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. It is the same instinct behind scoping workload credentials down to bound, namespaced tokens: give each team exactly the object it owns and nothing wider.
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. If you would rather have someone else run the annotation inventory and the cutover window on your cluster, get in touch.
FAQ
Does Ingress-NGINX stop working in March 2026?
No. Existing deployments keep serving traffic exactly as they do today. What stops is maintenance: no releases, no bugfixes, no security patches, and the repos go read-only. Every CVE filed against that controller afterwards is permanently yours to mitigate.
Can I run Ingress-NGINX and Gateway API at the same time?
Yes, and you should. They are separate controllers watching separate resource types, each with its own LoadBalancer and external IP. That parallel period is what makes a safe cutover possible: validate the Gateway path with a Host header against its IP, then repoint DNS once you trust it.
Why is my HTTPRoute Accepted but still not routing traffic?
Check the ResolvedRefs condition, not Accepted. If ResolvedRefs is False, the usual cause is a backend Service in another namespace with no ReferenceGrant there, followed by a wrong Service name or port. The route object looks healthy either way, which is what makes it expensive.
Does ingress2gateway change anything in my cluster?
No. It reads live Ingress objects or an input file and prints YAML to stdout. Nothing is applied until you run kubectl apply yourself, so it is safe to point at production and read the output first.
What happens to my configuration-snippet and server-snippet annotations?
They are dropped, with a warning on stderr. Gateway API has no standard field for raw NGINX config, so you either re-express the intent as a controller-specific policy or decide the behaviour was not worth keeping. Deciding by accident is the failure mode.

Comments
Be the first to comment.