The Kubernetes project retired Ingress-NGINX in March 2026. No further releases, no bug fixes, no security patches, which turns every remaining deployment into an internet-facing component with a permanent unpatched-CVE clock on it. AWS repeats the warning in its own EKS version notes and adds the sentence that should shape your plan: none of the alternatives is a drop-in replacement.
Most teams read that as a syntax problem: convert the Ingress objects into Gateway and HTTPRoute objects and ship it. Syntax is the easy half, and the tooling is good. The hard half is that Ingress-NGINX applies defaults nobody wrote in your manifests, your traffic has depended on them for years, and a correct conversion drops every one.
First, the naming trap
Ingress-NGINX and NGINX Ingress are different controllers. Ingress-NGINX is the community project that retired in March 2026. NGINX Ingress is F5’s product and is unaffected. Both use NGINX as the data plane and are otherwise unrelated. Check which one you run before planning anything: half the migration guides online answer the other question.
# which controller is actually serving your traffic
kubectl get ingressclass -o wide
kubectl get pods -A -l app.kubernetes.io/name=ingress-nginx
Start with the mechanical conversion
SIG Network maintains ingress2gateway, which translates Ingress resources and provider-specific annotations into Gateway API resources. It reached 1.0, covers the most common Ingress-NGINX annotations, and flags alternatives where there is no equivalent. Run it first so the rest of the work is the interesting part, not a week of retyping YAML.
ingress2gateway print --namespace production --providers ingress-nginx > gateway.yaml
Treat the output as a first draft. The review pass below is what keeps you online.
The five behaviours that do not survive translation
SIG Network documented these in the Kubernetes blog’s pre-migration write-up. They share one failure mode: a conversion that reads correctly still causes an outage, because the old system was doing something the manifest never said.
1. Regex matches are prefix-based and case-insensitive
With nginx.ingress.kubernetes.io/use-regex: "true", a pattern of /[A-Z]{3} does not match three uppercase letters. It matches any path beginning with any three letters, so /uuid routes to that backend. Envoy-based implementations, Istio and Envoy Gateway and Kgateway among them, do a full case-sensitive match instead. Convert the pattern literally and every request that relied on the loose behaviour starts returning 404.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: regex-match-route
spec:
hostnames:
- regex-match.example.com
parentRefs:
- name: your-gateway
rules:
- matches:
- path:
type: RegularExpression
# (?i) restores case-insensitivity, .* restores the prefix behaviour
value: "(?i)/[a-z]{3}.*"
backendRefs:
- name: httpbin
port: 8000
2. use-regex contaminates every route on the host
This is the one that catches experienced teams. The annotation is not scoped to the Ingress carrying it. If any Ingress sets use-regex for a host, every path on that host is treated as a regular expression, across every Ingress. An Exact match of /Header on a completely separate object will happily serve /headers.
Gateway API does not do this. Exact means exact. An audit that looks only at the annotated Ingress misses the routes that were silently borrowing its semantics, and those are the ones that break. Find the affected hosts first, then enumerate everything on them:
# 1. every host where regex semantics are switched on, by any Ingress
kubectl get ingress -A -o json | jq -r '
.items[]
| select((.metadata.annotations // {}) | keys[] | test("use-regex|rewrite-target"))
| .spec.rules[].host' | sort -u
# 2. every route on one of those hosts, annotated or not. The unannotated
# ones inherited the behaviour and are the ones that break on cutover.
kubectl get ingress -A -o json | jq -r --arg host "$HOST" '
.items[]
| .metadata.namespace as $ns | .metadata.name as $name
| .spec.rules[] | select(.host == $host)
| .http.paths[] | "\($ns)/\($name)\t\(.pathType)\t\(.path)"'
The annotation you need to audit is not on the route that breaks.
3. rewrite-target silently implies use-regex
Setting nginx.ingress.kubernetes.io/rewrite-target turns on regex interpretation for the whole host, with every side effect above, even when use-regex appears nowhere in the cluster. Teams that use rewrites and never touched use-regex are still exposed. Gateway API expresses the rewrite through an explicit filter, which is better engineering and a behaviour change:
rules:
- matches:
- path:
type: RegularExpression
value: "(?i)/IP.*"
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplaceFullPath
replaceFullPath: /uuid
backendRefs:
- name: httpbin
port: 8000
The URLRewrite filter leaves your other matches alone. That is the improvement, and it means every route coasting on the implied regex now needs handling of its own.
4. A missing trailing slash gets a 301
Given an Exact path of /my-path/, Ingress-NGINX answers /my-path with a 301 to the slashed form rather than a 404. The same holds for Prefix, though not for regex. Conformant Gateway API implementations add no redirect you did not ask for, so anything depending on that 301 (a client library, a bookmark, a downstream service) breaks at cutover. Ask for it:
rules:
- matches:
- path:
type: Exact
value: "/my-path"
filters:
requestRedirect:
statusCode: 301
path:
type: ReplaceFullPath
replaceFullPath: /my-path/
- matches:
- path:
type: Exact
value: "/my-path/"
backendRefs:
- name: your-backend
port: 8000
5. URLs are normalized before matching
Ingress-NGINX canonicalizes a path per RFC 3986 before testing it against rules, so /ip/abc/../../uuid and ////uuid both reach the backend registered at /uuid. Most implementations normalize dot segments by default, but the exact behaviour varies. If a backend has been relying on the gateway to clean paths, confirm the semantics of the one you are adopting.

The mapping, in one table
| Ingress-NGINX behaviour | Implicit or explicit | Gateway API equivalent |
|---|---|---|
| Regex prefix, case-insensitive | Implicit | type: RegularExpression with (?i) and a trailing .* |
use-regex applied host-wide | Implicit | No equivalent. Convert each route by hand. |
rewrite-target implying regex | Implicit | URLRewrite filter, matches stay as written |
| Trailing-slash 301 | Implicit | requestRedirect filter with statusCode: 301 |
| Path normalization | Implicit | Implementation-specific. Verify before relying on it. |
| TLS termination | Explicit | Gateway listener, or ListenerSet in Gateway API 1.5 |
On EKS specifically
The AWS Load Balancer Controller reached general availability for Gateway API in v3.0.0, covering both L4 and L7 routing, so ALBs and NLBs are provisioned and managed through Gateway resources instead of Ingress annotations. For an EKS team that removes the main reason to wait: the supported path now exists on the platform you already run.
It is also a good moment to move ownership: a Gateway belongs to the platform team, an HTTPRoute to the service team, and the API enforces that split instead of review comments on a shared file.
What we do on a cutover
We ran an end-to-end platform migration for a SaaS marketing platform coming off Heroku onto AWS: EKS, ALB, CloudFront, RDS, a second regional tenant for latency, all of it in our Terraform modules with Terragrunt on top. Total downtime came in at roughly two hours. The database was never the frightening part of that plan. The routing layer was.
The practice that follows is simple to state and unpopular to schedule: verify behaviour, not configuration. Stand the new stack up in parallel, replay real request paths against both, and compare the responses. A diff of two YAML files tells you nothing about any of that.
# extract the paths that actually get traffic, then replay them against both stacks
kubectl get ingress -A -o jsonpath='{range .items[*]}{.spec.rules[*].host}{"\t"}{.spec.rules[*].http.paths[*].path}{"\n"}{end}'
# compare status and redirect target, old versus new
for p in $(cat paths.txt); do
old=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -H "Host: $HOST" "http://$OLD_IP$p")
new=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -H "Host: $HOST" "http://$NEW_IP$p")
[ "$old" = "$new" ] || echo "DIFF $p | old: $old | new: $new"
done
Every line that script prints is an outage you did not have.
Honest trade-offs
Gateway API is the right destination, and it is not free.
- Regex semantics are implementation-specific. A pattern verified on one controller is not portable to another without retesting.
- The heavier
auth-*annotations have no one-to-one translation. External auth generally moves to an implementation-specific policy CRD, so you are back to controller-specific config in a better-shaped API. - You will run two data planes through the migration. That is the safe way, and it costs real nodes, certificates and DNS complexity.
- cert-manager and external-dns both work with Gateway API, but annotations and ownership move with the resources, and that step gets underestimated.
ListenerSet, which lets application teams manage their own TLS certificates against a shared Gateway, arrived in Gateway API 1.5 in February 2026. Right pattern, new code, weigh it accordingly.- Almost all of the risk above comes from annotations. If your Ingress objects are plain host and path rules, this is a small job.
The timeline is not a trade-off. Ingress-NGINX takes no more security patches, so the next serious CVE in it never gets a fix. That is a different conversation with your auditor than a planned migration.
Facing this on your stack? Naviteq’s senior platform team does this for SaaS, FinTech, and Enterprise teams across the US, EU, and Israel. Let’s talk.