Skip to main content

Your Ingress-Nginx Has an Expiry Date: What Moving to Gateway API Actually Takes

11 min readDora NodaDora Noda
Share
On this page

Your ingress controller has an expiry date, and it already passed the first one. In November 2025, Kubernetes SIG Network and the Security Response Committee announced the retirement of ingress-nginx; upstream maintenance ended in March 2026. No more releases, no more bugfixes, no more security patches — ever.

The only thing standing between a frozen ingress-nginx fleet and the unpatched future is a vendor support bridge. The longest one in the industry — Microsoft's critical-security-patch coverage for AKS application routing — ends in November 2026, roughly two months from now.

So here is the 60-second version of what moving to Gateway API actually takes. Every annotation-driven Ingress object you own becomes some combination of four resources: a cluster-wide GatewayClass and Gateway owned by the platform team, plus per-tenant HTTPRoute objects owned by whoever owns the app. TLS moves from tls: blocks and cert-manager annotations to listener configuration on the Gateway.

Anything clever you did with nginx snippets gets rewritten as a native route rule — or, in a few cases, rethought entirely. The migration itself runs in four phases (inventory, convert, dual-serve, cut over), none of which requires a flag day. That is the whole shape of the work. Everything below is the detail that makes it true.

What moves where: the before/after map

The single biggest mental shift: Ingress packed three jobs into one object — the load-balancer plumbing, the routing rules, and a pile of controller-specific annotations gluing them together. Gateway API splits those jobs across roles, which is exactly what a multi-tenant platform wants: the platform team owns the Gateway, tenants own their routes, and neither can break the other's half.

Ingress worldGateway API worldWho owns it
IngressClassGatewayClassPlatform team, once per controller
Controller Deployment + Service + --publish-service wiringGateway (the controller reconciles real infra from it)Platform team, one per environment
spec.rules host/path entriesHTTPRoute rules with parentRefs to the GatewayTenant / app team
nginx.ingress.kubernetes.io/rewrite-targetHTTPRoute urlRewrite filterTenant
nginx.ingress.kubernetes.io/canary-* + weightsHTTPRoute backendRefs with native weightTenant
nginx.ingress.kubernetes.io/auth-url (external auth)HTTPRoute extensionRef filter or a policy object on your chosen implementationPlatform team (implementation-specific)
nginx.ingress.kubernetes.io/configuration-snippetNothing automatic — rewrite as route rules or drop itWhoever wrote the snippet
spec.tls + cert-manager.io/cluster-issuer annotationGateway listeners TLS block referencing a Secret; cert-manager Certificate fills the same SecretPlatform team + cert-manager
nginx.ingress.kubernetes.io/backend-protocol: HTTPSBackendTLSPolicy on the backend ServicePlatform team
TCP passthrough (ssl-passthrough annotation)TLSRoute with SNI hostnamesTenant + platform team

Concretely, the tenant route your platform generates for every custom domain today probably looks like this:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tenant-acme
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  ingressClassName: nginx
  tls:
    - hosts: [app.acme.example]
      secretName: tenant-acme-tls
  rules:
    - host: app.acme.example
      http:
        paths:
          - path: /api(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: tenant-acme-api
                port: { number: 80 }

After the move, the platform's Gateway exists once, and the tenant's route becomes:

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: tenant-acme
spec:
  parentRefs:
    - name: platform-gateway
  hostnames: [app.acme.example]
  rules:
    - matches:
        - path: { type: PathPrefix, value: /api/ }
      filters:
        - type: URLRewrite
          urlRewrite: { path: { type: ReplacePrefixMatch, value: / } }
      backendRefs:
        - name: tenant-acme-api
          port: 80

Note what disappeared: the regex path with its ImplementationSpecific escape hatch, the rewrite annotation, the TLS block, the issuer annotation. The rewrite is now a typed filter any Gateway API controller understands, which means this route object is portable across implementations — the property Ingress never had and the reason every migration guide starts here.

The clock: three dates that made this urgent

DateWhat happenedWhat it means for your fleet
March 2025IngressNightmare: CVE-2025-1974 (CVSS 9.8) plus four related CVEs, found by Wiz ResearchUnauthenticated RCE through the admission controller; ~43% of cloud environments vulnerable, 6,500+ clusters with the webhook reachable from the internet
November 2025SIG Network + Security Response Committee announce ingress-nginx retirementBest-effort maintenance only, hard stop March 2026; the Kubernetes Steering Committee reiterated it in January 2026
March 2026Upstream maintenance ends; Helm chart frozen at its final releaseNo releases, bugfixes, or CVE fixes ever again; AKS's critical-patch bridge for managed NGINX runs out in November 2026

The March 2025 episode is worth thirty seconds because it explains why retirement, not just patching, was the answer. CVE-2025-1974 let anything on the pod network run code in the controller through its Validating Admission Controller — and ingress-nginx by default can read every Secret in the cluster, so RCE became cluster takeover in one step.

The deeper problem was architectural: a decade of features had accreted as annotations that inject raw NGINX configuration, so every new annotation was a potential config-injection primitive. The Ingress API itself is frozen — no new features, ever — which meant the bug class could not be fixed in place. Retirement was the remediation.

November 2026 matters because it is the last date with anyone else's name on it. After Microsoft's bridge ends, a CVE in your frozen controller gets fixed by exactly one party: you, by migrating under fire instead of on a schedule.

The migration in four phases (no flag day)

Every public migration guide — GKE's, AWS's for its load-balancer controller, VMware's self-described procrastinator's guide — converges on the same four phases. They converge because the phases are shaped by one fact: the old and new data planes can serve the same Services simultaneously.

Phase 1: inventory your annotations. List every Ingress object and every annotation key in use across all namespaces. Most fleets find that 80% of their Ingresses use the same five annotations (issuer, rewrite, CORS, proxy timeouts, backend protocol) and the remaining 20% contain the entire migration risk: configuration-snippet, server-snippet, auth-url/auth-signin, and canary pairs.

The inventory output is a spreadsheet with three columns — annotation, count, Gateway API equivalent — and the rows without an equivalent are your actual project plan.

Phase 2: convert mechanically, then review by hand. The community's ingress2gateway tool reached 1.0 in March 2026, timed for exactly this migration:

bash
go install github.com/kubernetes-sigs/ingress2gateway@latest
ingress2gateway print --input-file my-ingress.yaml --providers ingress-nginx

It emits Gateway and HTTPRoute manifests from your Ingress objects, translating the common nginx annotations. Treat its output as a first draft, not a migration: it converts what maps cleanly and silently drops what does not — server snippets, external-auth chains, and anything implementation-specific. Every dropped annotation needs a manual rewrite into a native route rule or a policy object on your chosen implementation, and Phase 1's spreadsheet tells you exactly how many such rewrites you owe.

Phase 3: dual-serve behind a controlled split. Install your chosen Gateway API controller alongside ingress-nginx, deploy the converted Gateway and HTTPRoutes pointing at the same backend Services, and shift traffic gradually — DNS weights per tenant hostname, or a header-based split for test traffic first. Both data planes serve the same pods; the blast radius of a bad route conversion is one weighted slice, not the fleet. Validate with production traffic shadows before moving real percentages.

Phase 4: cut over per tenant, then decommission. Move hostnames one by one, watching per-route status conditions (Accepted, ResolvedRefs, Programmed — Gateway API's standardized status surface is itself an upgrade over parsing controller logs). When the last hostname moves, disable ingress-nginx's admission webhook first — it was the IngressNightmare attack surface — then remove the controller. The order matters: webhook off, controller deleted, CRDs finally.

TLS end to end: listeners, passthrough, and backend TLS

TLS is where migrations stall, because Ingress let you sprinkle tls: blocks and issuer annotations per object while Gateway API centralizes them. There are three wirings to get right, and they cover every tenant shape a PaaS serves.

Termination at the listener replaces per-Ingress tls: blocks. The Gateway declares an HTTPS listener referencing a Secret; cert-manager keeps filling that Secret exactly as before — the Certificate resource targets the same Secret name, only the consumer changes from an Ingress annotation to a listener's certificateRefs:

yaml
listeners:
  - name: https
    port: 443
    protocol: HTTPS
    hostname: "*.example"
    tls:
      certificateRefs:
        - name: platform-wildcard-tls

Passthrough with TLSRoute covers tenants who bring their own certificates and terminate at their own pod — the old ssl-passthrough annotation crowd. A TLSRoute matches on SNI hostname and forwards TCP untouched, so the platform never sees plaintext and never manages those certs:

yaml
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TLSRoute
metadata:
  name: tenant-byo-cert
spec:
  parentRefs:
    - name: platform-gateway
  hostnames: [secure.tenant.example]
  rules:
    - backendRefs:
        - name: tenant-terminator
          port: 443

Re-encryption with BackendTLSPolicy replaces backend-protocol: HTTPS plus the proxy-ssl-* annotation family. It declares, as typed config with hostname verification, that Gateway-to-Service hops use TLS — the setting auditors always ask about and annotations always obscured:

yaml
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: BackendTLSPolicy
metadata:
  name: tenant-strict-backend
spec:
  targetRefs:
    - kind: Service
      name: tenant-acme-api
  validation:
    hostname: tenant-acme-api.tenant.svc.cluster.local

Note the API versions: TLSRoute and BackendTLSPolicy still ride the experimental channel in 2026 while Gateway/HTTPRoute are stable GA (Gateway API is at v1.5/v1.6 this year). That is fine for platform use — install the experimental CRD channel — but pin the channel version in Git and treat CRD upgrades as a deliberate step, not a Helm side effect.

Picking an implementation for a fleet you own

Gateway API is a standard, not software: more than 25 controllers implement it, with published conformance reports. For a self-managed fleet — no cloud vendor choosing for you — three criteria separate the field:

  1. Conformance score — how much of the Core (and Extended) spec the controller actually passes, so your portable HTTPRoutes stay portable.
  2. Operational weight on machines you run — extra control planes, CRD sprawl, and whether the data plane is something you already operate.
  3. TLS and cert-manager fit — how naturally listeners, rotation, and backend TLS map onto what you run today.

Against those criteria, the shortlist compresses fast:

  • Envoy Gateway — the default. The purpose-built answer: a clean Gateway API implementation on the Envoy data plane with strong conformance. Gardener's platform team selected it for its shoot clusters after benchmarking the field, which is the closest thing this space has to an independent bake-off.
  • Cilium — if you already run it as your CNI. Gateway API support arrives with the agent you already upgrade, the eBPF data plane removes a proxy tier, and kube-proxy replacement plus L4LB come along for free.
  • Traefik — the smallest step. If Traefik already terminates your traffic, it speaks both Ingress and Gateway API, so Phase 3's dual-serve can happen inside one controller.
  • Istio — only if you also want the mesh. The right call when you need mTLS identity and fine-grained L7 policy too. It is the heaviest option, and its main credential here is that AKS's own Gateway API app-routing tier is Istio-based.
  • NGINX Gateway Fabric — one clarification. It is F5/NGINX's official Gateway API product and shares nothing but a brand with the retired controller. Pick it if your team thinks in NGINX idioms and wants a supported path that preserves them — not because it preserves your annotations (it does not).

DigitalOcean now pre-installs managed Gateway API on its Kubernetes clusters at no extra cost, and GKE positions Gateway API as the recommended path for new clusters — the ecosystem direction is unambiguous even where no vendor forces your hand yet.

The honest case for staying (a little longer)

"Recommended" is not "the only supported path," and a frozen controller is not a broken one — existing deployments keep functioning and the artifacts stay published. Staying through some date short of forever is defensible if all four of these hold:

  • The admission webhook is unreachable from anything but the API server — or disabled entirely, which closes the IngressNightmare vector by construction.
  • You have pinned the final chart release and diffed every annotation against the known-dangerous set.
  • Nothing on your roadmap needs features Ingress will never get: header matching, traffic weighting, per-route policy.
  • And the load-bearing one: you have a calendar date, before November 2026, when Phase 1 starts — not a vague intention to migrate "when there's time."

The price after November is stark and should be stated plainly: zero patches from anyone, for anything, including the next 9.8. Your frozen controller keeps routing packets exactly as well as it does today while its vulnerability surface compounds silently. Every month past the deadline, the migration you deferred gets scheduled by someone else's disclosure instead of your runbook. If you stay, stay deliberately, briefly, and with the webhook off.

What the other side looks like

Six months after cutover, the operational texture changes in ways the mapping table understates. Tenants self-serve header matches and weighted canaries that used to require platform-team annotation archaeology. Route status conditions tell you which ref failed instead of a controller log telling you something failed.

And the platform team owns one Gateway per environment instead of auditing a thousand annotation blobs for the next injection primitive. The Ingress era's core flaw was never NGINX — it was expressing load-balancer policy as untyped strings stapled to routing objects. Gateway API ends that, and November 2026 ends the grace period for acting on it. Inventory your annotations this week; the spreadsheet is always smaller than you fear and the snippets always weirder than you hope.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex