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 kubectl and cluster-admin.
  • Helm 3.x, plus Go 1.22+ or Homebrew to install ingress2gateway.
  • An existing Ingress-NGINX deployment with at least one Ingress object 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-snippet and server-snippet don'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's BackendTrafficPolicy or SecurityPolicy) or you drop it. ingress2gateway warns and skips.
  • External auth (auth-url, auth-signin) has no standard equivalent. It maps to implementation-specific CRDs, for example Envoy Gateway's SecurityPolicy. 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-regex becomes HTTPRoute path type RegularExpression, which is implementation-specific and not guaranteed across controllers. Prefer PathPrefix or Exact wherever 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 the Gateway, 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 apply to 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.