Skip to main content

Ingress-NGINX Is Retiring: What Migrating a Self-Hosted PaaS's Custom-Domain Routing to Gateway API Actually Involves

8 min readDora NodaDora Noda
Share
On this page

Roughly half of all cloud-native environments route traffic through Ingress-NGINX, and for most of its life it's been kept alive by one or two maintainers working unpaid, after hours, until they burned out. On March 20, 2026, Kubernetes SIG-Network shipped the fix on the tooling side — Ingress2Gateway 1.0, with real support for translating Ingress-NGINX's annotations instead of the three it understood before. That's the good news. The bad news, announced back in November 2025 and reaffirmed by a joint Steering Committee and Security Response Committee statement on January 29, 2026, is that Ingress-NGINX itself retires this month. No new releases, no new CVE patches, nothing.

If your platform routes traffic for other people's custom domains — which is exactly what a self-hosted PaaS does — this isn't an abstract governance story. It's your control plane's front door reaching end of life. Here's what actually changes in the manifests, what a migration tool can and can't do for you automatically, and the order to do it in.

Why This Is Happening, in Three Sentences

Ingress-NGINX has been maintained by a shrinking volunteer team for years despite sitting in front of roughly half of all cloud-native traffic, according to Datadog's internal usage research. One of its maintainers put it bluntly: "not having more maintainers ended up burning me out and burning James out." When Kubernetes' Steering Committee looked at handing the project to new owners, they found the accumulated technical debt and design decisions — the kind that produced a string of ingress-nginx CVEs, including a path-based admission-controller bypass — made a handover impractical rather than just hard. The project winds down in March 2026; nobody is coming to save it.

The Architecture Change That Actually Matters for a Multi-Tenant Platform

Skip the philosophy for a second — here's the concrete shift. Say your PaaS terminates TLS for a tenant's custom domain, app.customer.com, routed to a service living in that tenant's namespace. Under Ingress-NGINX, a typical per-tenant object looks like this:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tenant-app
  namespace: tenant-4821
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
spec:
  tls:
    - hosts: ["app.customer.com"]
      secretName: tenant-4821-tls
  rules:
    - host: app.customer.com
      http:
        paths:
          - path: /(.*)
            pathType: Prefix
            backend:
              service: { name: tenant-app-svc, port: { number: 8080 } }

That reflector.v1.k8s.emberstack.com annotation is doing real work: because Ingress requires the TLS secret to live in the same namespace as the Ingress object, a multi-tenant platform issuing per-domain certificates has to clone the secret into every tenant namespace — via a reflector controller, a cert-manager ClusterIssuer per namespace, or a custom sync job. That cloning layer is infrastructure you built and now own forever.

Gateway API removes it structurally, not by convention. TLS termination is a property of the Gateway's listener, not the route:

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: platform-gateway
  namespace: platform-system
spec:
  gatewayClassName: envoy-gateway
  listeners:
    - name: tenant-4821-app
      hostname: app.customer.com
      port: 443
      protocol: HTTPS
      tls:
        certificateRefs:
          - name: tenant-4821-tls
      allowedRoutes:
        namespaces:
          from: Selector
          selector: { matchLabels: { tenant: "4821" } }
yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: tenant-app
  namespace: tenant-4821
  labels: { tenant: "4821" }
spec:
  parentRefs:
    - name: platform-gateway
      namespace: platform-system
  rules:
    - backendRefs:
        - name: tenant-app-svc
          port: 8080

The certificate lives once, in the platform-system namespace next to the Gateway your control plane already owns. The tenant's HTTPRoute — which is the thing your deploy pipeline actually templates per-tenant — never references a TLS secret at all. It attaches to the Gateway via parentRefs, and the Gateway explicitly opts in to which namespaces are allowed to attach via allowedRoutes. If the Gateway and the backend Service instead live in different namespaces than the route, you add one ReferenceGrant in the target namespace granting that specific cross-namespace reference — a two-sided, explicit permission model instead of "same namespace or bust."

For a platform issuing certificates per customer domain, this is the actual win: your secret-cloning controller — the reflector job, the custom sync cron, whatever you built to work around Ingress's same-namespace rule — goes away. It's not a smaller version of that problem. It's not your problem anymore.

Running Ingress2Gateway 1.0 on What You Actually Have

Before touching a manifest, point the tool at your live cluster or an exported YAML file and see what it produces:

bash
ingress2gateway print --providers ingress-nginx > gwapi.yaml

This is a dry run — it reads, prints, and warns; it does not apply anything. The 1.0 release (versus the original 2023 tool) is the difference between a toy and something you'd trust on a production annotation set: it went from understanding 3 Ingress-NGINX annotations to over 30, and — this is the part worth trusting — each supported annotation is backed by a controller-level integration test that runs both controllers in a live cluster and diffs actual runtime routing behavior, not just YAML shape. But "30+ annotations supported" isn't "your Ingress converts cleanly." Here's how the annotations a PaaS's routing layer actually leans on shake out:

Ingress-NGINX annotationIngress2Gateway 1.0Notes
rewrite-targetTranslatedMaps to HTTPRoute URLRewrite filter
cors-allow-*TranslatedMaps to HTTPRoute CORS filter (GA in newer Gateway API)
backend-protocol (TLS to origin)TranslatedMaps to BackendTLSPolicy
ssl-redirect / force-ssl-redirectTranslatedMaps to RequestRedirect filter
proxy-body-sizeNot translatedNo Gateway API core equivalent; only recoverable via an implementation-specific extension (--emitter envoy-gateway/kgateway/agentgateway), and only if your target controller supports it
proxy-read-timeout / proxy-send-timeoutPartialStandardized in newer Gateway API timeout fields, but controller support varies — verify against your specific GatewayClass
configuration-snippet / custom LuaNot translated, not translatableGateway API has no raw-config escape hatch by design; if your PaaS injects tenant-custom NGINX snippets today, this is the one that forces an actual architecture conversation, not a mechanical migration
CRDs (VirtualServer, etc., from other controllers)Not in scopeingress2gateway converts core Ingress objects only

Run the tool, then grep the output for its own warnings — it tells you exactly which annotations it couldn't map, which is your real punch list, not the 30-annotation headline number. For a PaaS specifically: if you're using proxy-body-size to cap tenant upload sizes (most platforms are), budget time to find your target Gateway controller's equivalent (Envoy Gateway and kgateway both expose this via BackendTrafficPolicy-style CRDs, just not through core Gateway API) — it's a real gap, not a rounding error.

Where the Certificates Actually Come From Now

If you're using cert-manager to issue per-tenant-domain certificates via ACME, the good news is you don't have to migrate TLS and routing in the same change. cert-manager 1.14+ accepts a Gateway API Gateway listener as a Certificate target the same way it's always accepted an Ingress:

yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: tenant-4821-tls
  namespace: platform-system
spec:
  secretName: tenant-4821-tls
  dnsNames: ["app.customer.com"]
  issuerRef: { name: letsencrypt-prod, kind: ClusterIssuer }

That's the same Certificate object as before — cert-manager's HTTP-01/DNS-01 solver logic doesn't care whether the eventual consumer is an Ingress or a Gateway listener. What changes is where it lands: one namespace, next to the Gateway, instead of replicated into every tenant namespace it currently lives in. This is worth sequencing deliberately: get cert-manager issuing against the Gateway and confirm certs are live before you cut routing traffic over, so a routing bug and a cert bug never show up in the same incident.

The Migration Order That Doesn't Break Production

  1. Install the Gateway API CRDs and pick a GatewayClass. Envoy Gateway and kgateway are the two with the most complete Ingress2Gateway emitter support today; check which one your target has controller-level test coverage for before committing.
  2. Dry-run ingress2gateway print against your real Ingress objects and read every warning line — that output is your actual scope, not an estimate.
  3. Stand up the Gateway and one HTTPRoute for a low-traffic tenant, with cert-manager issuing against the Gateway listener directly. Confirm TLS and routing both work before touching anything customer-facing.
  4. Convert your deploy pipeline's Ingress template to an HTTPRoute template. This is the change with the highest leverage — you likely template one Ingress per tenant today, so this is a one-time template rewrite, not thousands of manual edits.
  5. Migrate tenants in batches, DNS-weighted if your load balancer supports it, watching for the annotations flagged untranslatable in step 2 — those are exactly the requests that will 404 or misbehave first.
  6. Decommission the per-namespace secret-cloning controller last, only after every tenant's HTTPRoute is live and its cert is confirmed issued against the shared Gateway.
  7. Remove Ingress-NGINX and the Ingress CRDs it depended on once nothing references them — before March 2026, not after.

That last date isn't a soft deadline. After retirement there's no maintainer fixing the next CVE that gets filed against it, and "still running, unpatched" is a materially worse position than "migrated on a schedule you controlled."

Do This Before the Deadline Picks the Timeline For You

The honest case for starting now, rather than waiting: Ingress2Gateway's integration-tested annotation coverage and cert-manager's Gateway support are both mature today, which means the risk in this migration is concentrated in the two or three annotations you actually rely on that don't translate — not in the mechanics of Gateway API itself. Find those now, on your own schedule, with Ingress-NGINX still running as a fallback. Waiting until March compresses that same discovery work into whatever window is left before an unpatched, unmaintained ingress controller is the only thing standing between the internet and every tenant's app.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with per-tenant custom-domain routing built on Cluster API from the start. Star the repo on GitHub or deploy your first app today.

Sources

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