Key Takeaway
The Kubernetes community retired the ingress-nginx controller in March 2026: no more releases, bugfixes or security fixes, though running deployments keep working. The Ingress API itself stays. You can swap to another Ingress controller such as Contour, which means rewriting nginx annotations, or move to Gateway API, which means rewriting routes and re-testing every quirk.
The check takes one command, and it is the reason this post exists. Run kubectl get pods with the selector app.kubernetes.io/name=ingress-nginx across all namespaces, with cluster administrator permissions. If it returns a pod, you are running software the Kubernetes project stopped maintaining in March 2026. Nothing will have broken, and that is the trap: the Steering Committee warned that existing deployments keep working, so you may not find out until you are compromised.
# Needs cluster administrator permissions. Any output means you run ingress-nginx.
kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginxI re-read the primary sources on 17 October 2026: the Kubernetes retirement notice and the Steering Committee statement, a Microsoft engineer's post on five ingress-nginx behaviours, the Service externalIPs deprecation, the CNCF migration guide from July, the ingress-nginx README and Contour's documentation. I have not run this migration on a production cluster for this post, so the behaviours below are cited, not measured, and I say where the sources stop.
What was retired, and what was not
SIG Network and the Security Response Committee announced the retirement on 11 November 2025. Best-effort maintenance ran until March 2026. Afterwards there are no further releases, no bugfixes and no updates for security vulnerabilities found later. The GitHub repositories become read-only, Helm charts and container images stay available, and existing deployments are not broken.
What was not retired is the Ingress API. The CNCF guide says it remains supported and widely used; only the community-maintained ingress-nginx controller reached end of life. That distinction creates the two migration paths: keep Ingress and change the controller, or leave Ingress for Gateway API. Do not confuse it with NGINX Ingress from F5, which the Microsoft post calls a separate, unrelated controller that merely also uses NGINX as its data plane.
The scale explains the tone. The Steering Committee statement cites internal Datadog research that about half of cloud native environments rely on ingress-nginx, says none of the alternatives is a direct drop-in replacement, and says the change cannot be ignored, brushed off, or left until the last minute.
Why it ended, and what it means for your threat model
The notice is blunt about the cause. Options that were once considered helpful, such as adding arbitrary NGINX configuration directives through snippet annotations, are now considered serious security flaws, and for years the project had only one or two people developing it in their own time. Its planned replacement, InGate, never progressed far enough to be mature and will be retired as well.
The README on the main branch carries a second warning that predates the retirement: do not use it in multi-tenant production installations, because the project assumes anyone who can create Ingress objects is a cluster administrator. If your platform lets application teams create Ingresses, that assumption was already false before March.
There is a quieter signal in the same README. When I read it on 17 October 2026, the supported versions table listed v1.15.1 first, tested against Kubernetes 1.31 to 1.35, and no row covered Kubernetes 1.36 or later. On a newer cluster you are running a controller nobody will test against your version.
The two paths side by side
The CNCF guide frames the choice as a lateral move to an Envoy-based Ingress controller, with Contour as its example, versus a move to Gateway API. I kept its four factors and added a fifth row on what actually stops working.
| Factor | Path A: Contour on the Ingress API | Path B: Gateway API |
|---|---|---|
| Migration effort | Low to medium: translate annotations | High: rewrite routing manifests |
| Operational model | Single owner: Ops manages monolithic definitions | Role-based: Ops manages the Gateway, developers manage HTTPRoutes |
| Future-proofing | Low: the Ingress API is feature-frozen | High: active upstream development |
| Capabilities | Heavily reliant on proprietary annotations | Advanced features built into the core spec |
| What stops working | Every nginx.ingress.kubernetes.io annotation | The Ingress objects, plus any behaviour that only existed as an ingress-nginx quirk |
Two cautions. The guide's author works for a consultancy that sells migration services, and the guide gives no duration estimates or CVE identifiers, so treat its effort ratings as opinion. Second, it calls the Contour path a stopgap that buys planning time. My reading: because the Ingress API is feature-frozen, choosing A and later wanting Gateway API means migrating twice, so A only pays if the planning time is real. The fifth row is mine, drawn from the guide's annotation warning and the Microsoft post covered below.
Path A: change the controller, keep the Ingress objects
Path A keeps your Ingress resources and changes who serves them. Contour filters Ingresses by class: with the --ingress-class-name flag it serves only Ingresses whose class matches, and the flag accepts a comma-separated list. Its documentation says this is useful while migrating from another controller. Two controllers can therefore run side by side, each behind its own load balancer, and you move one Ingress at a time by changing its class.
# Contour runs beside ingress-nginx and serves only the class it is told to.
# The flag takes a comma-separated list: --ingress-class-name=contour,contour-canary
contour serve --ingress-class-name=contour
# Before: served by ingress-nginx. Both annotations stop working on Contour.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: "/uuid"
spec:
ingressClassName: nginx
rules:
- host: shop.example.com
http:
paths:
- path: "/IP"
pathType: ImplementationSpecific
backend:
service:
name: shop
port:
number: 8000
# After: the same object, moved by changing the class and dropping the
# nginx annotations. Re-express the rewrite with Contour's HTTPProxy.
metadata:
name: shop
annotations:
projectcontour.io/response-timeout: "30s" # a Contour annotation, not an nginx one
spec:
ingressClassName: contourThe cost is the annotations. The CNCF guide says every nginx.ingress.kubernetes.io annotation stops working and must be translated by hand to Contour's equivalents or rebuilt with Contour's CRDs. Contour's annotation reference lists only a short set for Ingress objects, among them ingress.kubernetes.io/force-ssl-redirect, kubernetes.io/ingress.allow-http, projectcontour.io/response-timeout and the retry settings num-retries, per-try-timeout and retry-on. It adds that features historically set by annotation are first-class in its HTTPProxy API, which it calls the more robust interface.
Path matching also changes meaning. Contour documents that ImplementationSpecific keeps its older behaviour: a path containing any of the characters ^+*[]% is a regex and anything else is a string prefix. Exact and Prefix follow the spec, and Prefix matches on segments, so /foo/bar matches /foo/bar/baz but not /foo/barbaz. Compare that with the five ingress-nginx behaviours below before assuming your paths carry over.
Set ingressClassName on every Ingress you migrate. Without the flag, Contour accepts Ingresses that name the class contour or name no class at all, and an Ingress with no class annotation is served by all controllers, so an unclassed Ingress can end up served twice.
Path B: Gateway API, where the roles are split
Gateway API splits what Ingress keeps in one object. Operators own the Gateway, with its listeners and address. Developers own HTTPRoutes that attach to it through parentRefs. The CNCF guide credits the model with standardising traffic splitting, advanced header matching and secure cross-namespace routing, which are the things teams reached for annotations to do on ingress-nginx.
# Owned by the platform team. The listener is mandatory in a real Gateway;
# Kubernetes' externalIPs example omits it to stay short.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: infra
spec:
gatewayClassName: <your-gateway-class> # set by the implementation you install
addresses:
- type: IPAddress
value: "192.0.2.4" # documentation address; use yours
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: All # who may attach HTTPRoutes
---
# Owned by the application team, in their own namespace.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop
namespace: shop
spec:
hostnames:
- shop.example.com
parentRefs:
- name: shared-gateway
namespace: infra
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: shop
port: 8000Gateway API 1.5, released on 27 February 2026 according to the Kubernetes blog, graduated ListenerSet, which lets application developers manage TLS certificates, and the HTTPRoute CORS filter. Check which of those your chosen implementation supports before you plan around them; the posts I read do not compare implementations feature by feature.
Because Gateways are designed for cluster administrators, RBAC can restrict who attaches an address, which is why the Kubernetes externalIPs post offers a Gateway as one replacement for that field. For translating existing manifests, the Microsoft post says SIG Network has been adding the most common ingress-nginx annotations, and some of the unexpected behaviours, to Ingress2Gateway, and the CNCF guide recommends it for automating translation. Treat its output as a draft to review, not a finished route.
Where migrations break: five behaviours nobody wrote down
The most useful source is Steven Jin's post on the Kubernetes blog. Its recurring risk pattern is that a seemingly correct translation can still cause an outage because it ignores an ingress-nginx quirk. Every example ends in a 404 where ingress-nginx answered 200 or 301. I have not reproduced them; the table restates his findings.
| Behaviour | ingress-nginx | Gateway API implementations | Fix |
|---|---|---|---|
| Regex paths | Prefix and case-insensitive | Istio, Envoy Gateway and Kgateway do a full case-sensitive match | Widen the pattern or use the (?i) flag |
| use-regex scope | Applies to all paths of the host across all its Ingresses, even Exact ones | Exact and Prefix are never silently turned into regex | Fix the typo, or use RegularExpression with (?i) |
| rewrite-target | Silently implies use-regex for the whole host | The URLRewrite filter does not change match types | Regex matches such as (?i)/IP.* or corrected Exact paths |
| Trailing slash | 301 redirect from /my-path to /my-path/ | Conformant implementations configure no redirects silently | An explicit RequestRedirect filter |
| URL normalisation | Normalises before matching, so //uuid reaches /uuid | Istio, Envoy Gateway and Kgateway normalise dot segments by default | Read each implementation's normalisation docs |
The second and third rows are the nastiest because the quirk is not in the Ingress you are reading. One Ingress with use-regex or rewrite-target turns every path on that host into a case-insensitive prefix regex, including paths declared in other Ingresses. A typo such as /Header instead of /headers worked for years and returns 404 after a faithful translation.
# Wrong: a faithful copy of the Ingress. ingress-nginx matched /ip here because
# rewrite-target silently made "/IP" a case-insensitive prefix regex.
# An Envoy-based gateway matches Exact literally, so GET /ip is a 404.
- matches:
- path:
type: Exact
value: "/IP"
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplaceFullPath
replaceFullPath: /uuid
# Right (to keep the old behaviour): say the regex out loud.
- matches:
- path:
type: RegularExpression
value: "(?i)/IP.*"
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplaceFullPath
replaceFullPath: /uuid
# Trailing slash: ingress-nginx answered GET /my-path with a 301 to /my-path/.
# Gateway API will not do that unless you ask. The Kubernetes post's snippet
# leaves out "type: RequestRedirect"; the API needs it alongside the block.
- matches:
- path:
type: Exact
value: "/my-path"
filters:
- type: RequestRedirect
requestRedirect:
statusCode: 301
path:
type: ReplaceFullPath
replaceFullPath: /my-path/Three habits follow, and they are mine rather than the post's: replay real access-log paths against the new gateway before cutover, diff status codes per path rather than per host, and treat any host that carries use-regex or rewrite-target on any of its Ingresses as the highest-risk host in the cluster.
A conformant Gateway API implementation adds no redirects and no regex behaviour you did not write. A migration that is correct on paper returns 404 for traffic that used to return 200 or 301, so test status codes, not just that the route applied.
The second clock: Service externalIPs
If you run bare metal and exposed ingress-nginx through Service externalIPs, a second deadline applies. Kubernetes formally deprecated .spec.externalIPs in v1.36. The API assumes every user in the cluster is fully trusted, which enables the exploits described in CVE-2020-8554, and the project has recommended disabling the field since 1.21 using the DenyServiceExternalIPs admission controller.
The rough timeline in the post: Kubernetes 1.36 deprecates the field and emits warnings. At the earliest in v1.40, about a year later, kube-proxy support is disabled but with a way to opt back in. At the earliest in v1.43, about another year after that, support is disabled completely with no opt-in. Only the Service field is affected; the Node ExternalIP address type and the EXTERNAL-IP column in kubectl output are not.
# Which Services still set spec.externalIPs? Run in every cluster.
kubectl get services --all-namespaces -o json \
| jq -r '.items[] | select(.spec.externalIPs != null)
| [.metadata.namespace, .metadata.name, (.spec.externalIPs | join(","))] | @tsv'
# To block new uses, enable the DenyServiceExternalIPs admission plugin on
# kube-apiserver (existing Services are not rewritten):
# --enable-admission-plugins=...,DenyServiceExternalIPsThe post lists three replacements. The easiest and, in its words, worst is a type LoadBalancer Service whose IP you patch into status by hand. Better is a load balancer controller such as MetalLB, where the administrator defines the address pools and the controller stops two Services claiming one IP. The third is a Gateway with an address in spec.addresses, which RBAC can restrict to privileged users. If your migration lands on Gateway API anyway, that third option removes this deprecation at the same time.
What to do on Monday
None of this needs a decision on Contour versus Gateway API first. The first three steps cost an afternoon and tell you how big the job is.
- Run the pod selector check in every cluster, including staging and the cluster nobody owns.
- Count nginx.ingress.kubernetes.io annotations by key, and list every host that uses use-regex or rewrite-target.
- Check Services for externalIPs, because that clock is separate.
- Choose the path per cluster. If you cannot staff a route rewrite this quarter, take Path A as a dated stopgap with a Gateway API plan attached; if you are already modernising, go straight to B.
- Stand up the new controller beside the old one with its own address, and move non-critical workloads first, as the CNCF guide advises.
- Before changing DNS, replay real paths against the new address and diff status codes. Remove ingress-nginx last.
# Step 2: which nginx annotations do you actually use, and on which Ingress?
kubectl get ingress --all-namespaces -o json \
| jq -r '.items[] | . as $i | ($i.metadata.annotations // {}) | keys[]
| select(startswith("nginx.ingress.kubernetes.io/"))
| [$i.metadata.namespace, $i.metadata.name, .] | @tsv' \
| sort > nginx-annotations.tsv
cut -f3 nginx-annotations.tsv | sort | uniq -c | sort -rn # by key
grep -E 'use-regex|rewrite-target' nginx-annotations.tsv # highest-risk hosts
# Step 6: send the same request to old and new address, compare status codes.
# --resolve pins the host to an IP without touching DNS.
for p in /uuid /UUID /my-path //uuid /ip; do
old=$(curl -s -o /dev/null -w '%{http_code}' --resolve shop.example.com:80:OLD_IP "http://shop.example.com$p")
new=$(curl -s -o /dev/null -w '%{http_code}' --resolve shop.example.com:80:NEW_IP "http://shop.example.com$p")
echo "$p old=$old new=$new"
doneOne thing I left out on purpose: some retirement summaries give a specific day in March. The pages I read, the Kubernetes notice, the statement, the README and the CNCF guide, say only March 2026, so I have not stated a day.
Treat the retirement as a security deadline that has already passed, not a project with slack. Find every ingress-nginx, inventory its annotations, and choose the path by how many behaviours you must preserve. Contour buys time, Gateway API is the destination, and either way the outages will come from quirks nobody documented, so test status codes per path before you move DNS.
Sources
- CNCF: Navigating the ingress-NGINX retirement (9 July 2026)
- Kubernetes blog: Ingress NGINX Retirement: What You Need to Know
- Kubernetes blog: Statement from the Steering and Security Response Committees
- Kubernetes blog: Before You Migrate, Five Surprising Ingress-NGINX Behaviors
- Kubernetes blog: v1.36 deprecation and removal of Service ExternalIPs
- GitHub: kubernetes/ingress-nginx README
- Contour docs: Ingress v1 support
- Contour docs: annotations reference

