Skip to main content
BlogDevOps

ingress-nginx Retirement: Contour or Gateway API Migration

What the March 2026 ingress-nginx retirement means, how Contour and Gateway API compare, the five quirks that cause 404s, and what to do on Monday.

Matthews Wong

Software Engineer

October 202612 min read
ingress-nginx Retirement: Contour or Gateway API Migration

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-nginx

I 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.

FactorPath A: Contour on the Ingress APIPath B: Gateway API
Migration effortLow to medium: translate annotationsHigh: rewrite routing manifests
Operational modelSingle owner: Ops manages monolithic definitionsRole-based: Ops manages the Gateway, developers manage HTTPRoutes
Future-proofingLow: the Ingress API is feature-frozenHigh: active upstream development
CapabilitiesHeavily reliant on proprietary annotationsAdvanced features built into the core spec
What stops workingEvery nginx.ingress.kubernetes.io annotationThe 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: contour

The 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: 8000

Gateway 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.

Behaviouringress-nginxGateway API implementationsFix
Regex pathsPrefix and case-insensitiveIstio, Envoy Gateway and Kgateway do a full case-sensitive matchWiden the pattern or use the (?i) flag
use-regex scopeApplies to all paths of the host across all its Ingresses, even Exact onesExact and Prefix are never silently turned into regexFix the typo, or use RegularExpression with (?i)
rewrite-targetSilently implies use-regex for the whole hostThe URLRewrite filter does not change match typesRegex matches such as (?i)/IP.* or corrected Exact paths
Trailing slash301 redirect from /my-path to /my-path/Conformant implementations configure no redirects silentlyAn explicit RequestRedirect filter
URL normalisationNormalises before matching, so //uuid reaches /uuidIstio, Envoy Gateway and Kgateway normalise dot segments by defaultRead 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=...,DenyServiceExternalIPs

The 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.

  1. Run the pod selector check in every cluster, including staging and the cluster nobody owns.
  2. Count nginx.ingress.kubernetes.io annotations by key, and list every host that uses use-regex or rewrite-target.
  3. Check Services for externalIPs, because that clock is separate.
  4. 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.
  5. Stand up the new controller beside the old one with its own address, and move non-critical workloads first, as the CNCF guide advises.
  6. 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"
done

One 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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Is ingress-nginx still safe to run after March 2026?

Existing deployments keep working and the Helm charts and images stay available, but there are no further releases, bugfixes or security updates. The Kubernetes Steering Committee said staying on it leaves you and your users vulnerable to attack. Plan a migration rather than waiting for a vulnerability to force one.

Was the Kubernetes Ingress API retired along with ingress-nginx?

No. Only the community-maintained ingress-nginx controller was retired. The CNCF guide states that the Ingress API remains supported and widely used, though it also describes the API as feature-frozen. That is why you can keep Ingress and change the controller, or move to Gateway API.

Should I migrate from ingress-nginx to Contour or to Gateway API?

Contour is the lower-effort path because your Ingress objects stay, but every nginx.ingress.kubernetes.io annotation must be translated, and the CNCF guide calls it a stopgap. Gateway API takes more work now and is where upstream development is happening. If you cannot staff a route rewrite soon, use Contour with a dated Gateway API plan.

How do I check whether my cluster runs ingress-nginx?

With cluster administrator permissions, run kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginx. The Kubernetes notice says this works in most cases. Check every cluster, including staging and ones nobody owns, because the controller keeps running silently.

Why do Gateway API migrations return 404 where ingress-nginx returned 200?

ingress-nginx treats regex paths as case-insensitive prefixes, applies use-regex to every path on a host, lets rewrite-target imply regex, and redirects missing trailing slashes. Conformant Gateway API implementations do none of this silently, so a literal translation can 404. Test status codes per path before moving DNS.