Gateway API vs. Ingress: The Migration Every Kubernetes Team Now Owes

Sources

Ingress NGINX was, for a decade, the default answer to "how do we get traffic into this cluster." On March 19, 2026, the project shipped its final releases — controller v1.15.1 and v1.14.5 — and the repositories went read-only. No more bug fixes, no more security patches, nothing. If you are still running it, every ingress-nginx pod in your fleet is now frozen code on a hostile internet, and the Kubernetes Steering and Security Response Committees were blunt about it: "choosing to remain with Ingress NGINX after its retirement leaves you exposed to attack."

This guide is not a "Gateway API is nicer" opinion piece. It is the migration plan you owe your platform: what actually changed in the API, which implementation to pick, the annotation conversion table that will consume most of your engineering time, the CRD upgrade traps that can eat your routes, and a staged cutover that keeps revenue traffic safe. Everything here targets Gateway API v1.6.2 (the current patch line, Sept 3, 2026) on Kubernetes 1.37, with verified manifests — nothing is a sketch.

One framing note before the detail: if you already run Cilium as your CNI, the migration calculus changed this month. Cilium 1.20 (September 14, 2026) jumped from Gateway API v1.4 support to v1.6.1, adding ExternalAuth, CORS filters, ListenerSets, and GA TCPRoute/UDPRoute — with the release blog explicitly telling ingress-nginx refugees: "now is the time to let the CNI you already run take on that traffic management too." That option belongs in your evaluation alongside the usual controller candidates, and it gets a full section below.

TL;DR

What Actually Changed, and When

The migration deadline was not a surprise sprung on anyone. The timeline, verified from the primary announcements:

Date Event What it means for you
Mar 24, 2025 IngressNightmare (CVE-2025-1974, CVSS 9.8) disclosed — unauthenticated RCE in the ingress-nginx controller pod The dress rehearsal. Five CVEs in one disclosure, all fixed because maintainers still existed. Post-retirement, the same class of bug ships unfixed forever.
Nov 12, 2025 SIG Network and the Security Response Committee announce ingress-nginx retirement: best-effort maintenance until March 2026 The writing on the wall, in committee-official language.
Jan 29, 2026 Steering + Security Response joint statement: "We cannot overstate the severity of this situation" Two months of runway left. Datadog research cited: ~50% of cloud native environments run ingress-nginx.
Mar 19, 2026 Final releases: controller v1.15.1 / v1.14.5 / v1.13.9 + matching charts. Repositories read-only. Frozen code. Anything discovered after this date in the controller, admission webhook, or chart is unpatchable upstream.
Mar 20, 2026 ingress2gateway 1.0 released — the SIG-Network migration assistant hits stable The conversion tool got a 1.0 the day after retirement. Use it as a starting point, not a finish line.
Jun 30 / Aug 3, 2026 Gateway API v1.6.0: TCPRoute and UDPRoute graduate to Standard (v1); experimental resources move to the gateway.networking.x-k8s.io group with an X prefix The API now covers raw L4 — the last "we still need Service type LoadBalancer for that" argument is gone.
Sept 3, 2026 Gateway API v1.6.2 patch release The current stable line to pin your CRDs to.
Sept 14, 2026 Cilium 1.20: Gateway API v1.4 → v1.6.1, ExternalAuth, CORS filters, ListenerSets, TCPRoute/UDPRoute, stable MCS API The "let the CNI do it" option became fully credible this month.

The strategic picture underneath the timeline: the Ingress API is frozen (it has had no new features since it went GA in v1.19 of Kubernetes), and the entire ecosystem's innovation moved to Gateway API. The v1.6 API group split — experimental resources living in gateway.networking.x-k8s.io with an X prefix (XBackend, XMesh) until they graduate — is the project's way of making that boundary machine-visible. Your cluster's routing layer is now a versioned product you track, not a set of annotations you accumulate.

Ingress vs. Gateway API: The Structural Differences That Bite

Most "Ingress vs Gateway API" comparisons stop at "Gateway API is more expressive." True and useless. The differences that bite during a real migration are structural:

Dimension Ingress (frozen) Gateway API (v1.6) Migration consequence
API shape One object: host + path → service. Everything else is annotation. Separate GatewayClass (infra vendor), Gateway (listener/LB instance), Route objects (per-app, per-protocol). N ingresses become 1 Gateway + N Routes. Cross-team ownership boundaries change: infra owns Gateways, app teams own Routes.
Vendor-specific behavior ~200 ingress-nginx annotations; config lives in a ConfigMap + snippets Standardized fields for most of it (CORS, timeouts, retries experimental, header manipulation); extension points for the rest ingress2gateway converts standard fields mechanically; the annotation long tail is your manual work queue.
Protocols HTTP/HTTPS + TLS passthrough (annotation-flavored) HTTPRoute, GRPCRoute, TLSRoute (v1), TCPRoute (v1), UDPRoute (v1) — all Standard channel in v1.6 Your databases, brokers, DNS, and game servers can now ride the same Gateway model instead of ad-hoc LoadBalancer services.
Cross-namespace references Implicit and frequently accidental (any ingress can route to any service) Explicit via ReferenceGrant (v1 in 1.5); cross-namespace backend refs are rejected without one Audit every cross-namespace route in advance. This is a security feature you must configure deliberately, and it will block migrations done blind.
RBAC granularity One object type to rule them all Infra role manages Gateways; app role manages Routes in their namespace Finally a clean platform-team/app-team split — the core original selling point.

Architecture: Same Cluster, New Traffic Path

The mental model shift, in one diagram — note where policy enforcement moves and where the app team's YAML ends up:

  INGRESS ERA                                    GATEWAY API ERA
  ───────────────────────────────               ───────────────────────────────
  Internet                                       Internet
     │                                              │
     ▼                                              ▼
  Cloud LB (static)                              Cloud LB (per Gateway)
     │                                              │
     ▼                                              ▼
  ingress-nginx Deployment                        GatewayClass (cilium / ngf / eg /
     │  nginx.conf from:                             gke-l7 / lattice)  ← infra team
     │    Ingress objects +                          │
     │    ~50 annotations/object +                Gateway (listeners, TLS,
     │    ConfigMap + snippets  ← platform           certRefs)        ← infra team
     ▼                                              │
  Service → Pods                                    ▼
                                                 HTTPRoute / TCPRoute / UDPRoute
                                                   │  host+path match, filters,
                                                   │  weights   ← app team
                                                   ▼
                                                 Service → Pods
  ownership: ONE platform-owned object          ownership: infra owns Gateway;
  consumed by everyone                               app teams own Routes;
                                                    ReferenceGrant governs the
                                                    boundary between them

Two things to internalize before writing a single manifest, because they drive the migration plan:

  1. The Gateway is shared infrastructure again — deliberately. One Gateway (or a few, per environment) fronts many Routes from many namespaces. You are not creating one Gateway per app unless you want N load balancers. Design your Gateway layout (per-cluster? per-env? internal + external?) before converting anything.
  2. Route attachment is where apps meet infra. A Route references a Gateway via parentRefs; if the Route's namespace is not allowed by the listener's allowedRoutes, it silently does not attach. Test attachment early — "route created but nothing listens" is the most common first-week failure.

Choosing Your Implementation: The 2026 Field

Gateway API is a specification; you still must pick a data plane. The credible options as of September 2026, with the verification steps you should run regardless of which you pick:

Implementation GatewayClass / controllerName Current line Pick it when Watch out
Cilium (CNI-embedded) cilium / io.cilium/gateway-controller 1.20.x (chart 1.20.2), Gateway API v1.6.1 Cilium is already your CNI; you want ingress without a new dataplane to babysit; you value network-policy integration at the edge Requires kube-proxy replacement + L7 proxy (TPROXY); client-IP handling differs from classic controllers (XFF trusted-hops model); source-IP passthrough nuances documented thoroughly in Cilium docs
NGINX Gateway Fabric (F5) nginx / gateway.nginx.org/nginx-gateway-controller 2.7.2 You want NGINX semantics to survive the migration — the natural ingress-nginx heir; TCP/UDP support, rate limiting, session persistence shipped in 2.4.0+ It is a rewrite, not a config translation: snippets and ConfigMap behaviors do not carry over; budget real review time
Envoy Gateway eg / gateway.envoyproxy.io/gatewayclass-controller v1.9.1 You standardized on Envoy for mesh/API-gateway reasons; you want the richest extension surface (BackendTrafficPolicy etc.) with Envoy as a single runtime More policy surface to learn; check conformance-profile coverage against your must-have list before committing
GKE Gateway controller (Google-hosted) gke-l7-global-external-managed etc. / networking.gke.io/gateway GKE 1.35+ installs Gateway API 1.5.0 CRDs GCP-only fleet; you want Google-managed, out-of-band controller reconciliation into Cloud Load Balancing HTTPRoute only — TCPRoute/UDPRoute/TLSRoute unsupported; Standard channel only; requires VPC-native clusters, HttpLoadBalancing add-on; proxy-only subnet for internal LBs
AWS Gateway API Controller (VPC Lattice) amazon-vpc-lattice / gateway.networking.k8s.aws/gateway-api-controller Active project (aws-application-networking-k8s) EKS-heavy shop that wants Lattice service networks as the routing layer Lattice is a separate AWS service with its own pricing; controller maturity trails the NGINX/Envoy/Cilium options — pilot before fleet-wide use

Decision heuristic: if Cilium is your CNI, start there — gatewayAPI.enabled=true is one Helm flag on an existing chart, and the per-node Envoy you would otherwise deploy as a new controller is already running. If NGINX config fluency is concentrated in your platform team's muscle memory, NGINX Gateway Fabric minimizes retraining. If neither applies, run a two-week bake-off between Envoy Gateway and your incumbent alternative — with your own top-20 ingresses as the test corpus, not vendor demos.

Step 1 — Install the CRDs (Correctly, Once)

Gateway API CRDs are not shipped by Kubernetes — you (or your cloud provider) own installing and upgrading them. This is the single most common source of Gateway API outages, so it gets the paranoid treatment:

# Pin Gateway API CRDs to the v1.6.2 standard channel (current stable line, Sept 3, 2026).
# --server-side is required: the experimental channel CRDs exceed the client-side apply limit.
kubectl apply --server-side -f \
  https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.2/standard-install.yaml

# Verify what landed: v1 CRDs for the routes you care about
kubectl get crd | grep gateway.networking.k8s.io
#   gateways, gatewayclasses, httproutes, grpcroutes, tlsroutes,
#   tcproutes, udproutes, backendtlspolicies, referencegrants, listenersets

# Never do this:
# kubectl apply -f .../experimental-install.yaml   ← over a Standard install
# The safe-upgrades ValidatingAdmissionPolicy (shipped in v1.5) exists to block
# channel mixes and pre-1.5 downgrades — it fails closed for a reason.

Why the paranoia: the v1.5→v1.6 line moved graduated resources to v1 and deprecated their v1alpha2 forms. If you install a Standard channel that lacks a version your stored objects use, the API server will still serve your objects in the versions it knows — but your controller may see nothing, and tools like ingress2gateway will generate for the channel they find. Version drift here is a silent data-visibility outage, not a loud failure. The Cilium upgrade guide's Gateway API v1.6.1 + TLSRoute warning is representative of the whole class: read the CRD notes before every bump.

Step 2 — Deploy the Controller (Reference Manifests)

Pick exactly one controllerName per GatewayClass — that is the spec's contract. Reference installs for the three leading self-managed options:

# ── Option A: Cilium (if it's your CNI) ─────────────────────────────
# Gateway API CRDs (v1.6.1+, Standard channel) must be installed FIRST.
helm upgrade cilium cilium/cilium --version 1.20.2 \
  --namespace kube-system \
  --reuse-values \
  --set kubeProxyReplacement=true \
  --set gatewayAPI.enabled=true
kubectl -n kube-system rollout restart deployment/cilium-operator
kubectl -n kube-system rollout restart ds/cilium
# Chart creates GatewayClass "cilium" (controllerName: io.cilium/gateway-controller)

# ── Option B: NGINX Gateway Fabric 2.7.2 ────────────────────────────
helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \
  --create-namespace -n nginx-gateway
kubectl wait --timeout=5m -n nginx-gateway deployment/ngf-nginx-gateway-fabric \
  --for=condition=Available
# Deploys GatewayClass "nginx" (controllerName: gateway.nginx.org/nginx-gateway-controller)

# ── Option C: Envoy Gateway v1.9.1 ──────────────────────────────────
helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.9.1 \
  -n envoy-gateway-system --create-namespace
kubectl wait --timeout=5m -n envoy-gateway-system deployment/envoy-gateway \
  --for=condition=Available
# Quickstart GatewayClass is "eg" (controllerName: gateway.envoyproxy.io/gatewayclass-controller)

# ── Option D: GKE built-in ──────────────────────────────────────────
gcloud container clusters update CLUSTER_NAME \
    --location=CLUSTER_LOCATION \
    --gateway-api=standard
# Up to 45 min to reconcile; then gke-l7-* GatewayClasses appear automatically.

Sanity check after install, before converting anything:

kubectl get gatewayclass
# NAME    CONTROLLER                                     ACCEPTED   AGE
# cilium  io.cilium/gateway-controller                  True       2m
# Check the "supportedFeatures" field if you need to know exactly what your
# controller implements (shipped in v1.4, Standard) — it is the spec's
# machine-readable conformance claim, and your migration checklist should
# reconcile against it, not against vendor marketing.

Step 3 — Convert the Ingresses (The Actual Work)

ingress2gateway (SIG-Network, v1.2.0 current, 1.0 shipped March 20, 2026 — the day after retirement) is the standard converter. It emits warnings for everything it cannot fully translate; those warnings are your work queue:

# Install
brew install ingress2gateway
# or: go install github.com/kubernetes-sigs/ingress2gateway@v1.2.0

# Inventory first: what are we actually migrating?
kubectl get ingress -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,CLASS:.spec.ingressClassName,ANNOTATIONS:.metadata.annotations' | head -30

# Convert the whole cluster, ingress-nginx flavor
ingress2gateway print --providers=ingress-nginx --all-namespaces > gwapi-all.yaml 2> i2g-warnings.log

# Read the warnings. This is the migration's critical path:
grep -c "warning" i2g-warnings.log
cat i2g-warnings.log
# Expect hits for: configuration-snippet, proxy-body-size, rate-limit,
# proxy-read/send-timeout, regex path behaviors, URL normalization

What the tool does mechanically, verified against its documented conversion table:

Ingress field / annotation Gateway API destination Fidelity
spec.ingressClassName Gateway.spec.gatewayClassName Direct
rules[].host Listener hostname + Route hostnames Direct (distinct hosts → distinct listeners/routes)
tls[].hosts + secretName HTTPS listener, tls.mode: Terminate, certificateRefs Direct
paths[] with pathType: Prefix / Exact Route matches[].path.type PathPrefix / Exact Direct
nginx.ingress.kubernetes.io/rewrite-target URLRewrite filter Direct
ssl-redirect / force-ssl-redirect RequestRedirect filter (scheme https) Direct (verify the port-80 listener and redirect route get created)
enable-cors / cors-* CORS filter (Standard since v1.5, GEP-1767) Direct for common fields
whitelist-source-range Route matches on source CIDR — or keep it at the cloud LB / NetworkPolicy layer Partial — implementation-specific policies fill the gap
proxy-body-size None in core Gateway API — implementation-specific policy (BackendTrafficPolicy etc.) Manual — check your implementation's body-size default, which now applies to your traffic
rate-limit annotation Implementation-specific policy (e.g. NGF RateLimitPolicy, EG BackendTrafficPolicy) Manual
configuration-snippet / server-snippet No equivalent — deliberately. The snippet escape hatch does not exist in Gateway API Manual, case-by-case — the single biggest migration cost item. Each snippet must be re-expressed as filters/policies or moved into the app
Regex paths (nginx implementationSpecific) Core HTTPRoute RegularExpression match type Partial — ingress-nginx regexes were case-insensitive prefix matches by default; ingress2gateway flags the (?i) artifacts, and most teams want to normalize to case-sensitive here
proxy-read-timeout / proxy-send-timeout timeouts on the route rule (BackendTrafficPolicy-adjacent; exact surface varies) Partial — tool makes a best-effort 10s default; review per-route

The honest sizing answer: a plain host+path+TLS ingress converts in minutes. Every ingress carrying configuration-snippet, custom rate limiting, or subtle timeout behavior needs an engineer. Budget accordingly — a 500-ingress fleet with 40 snippet users is a quarter-long program with the snippets as the critical path, not the YAML conversion.

Step 4 — The Cutover (Zero-Downtime, Boring On Purpose)

The proven pattern: run both stacks in parallel, switch at DNS/load-balancer level, keep rollback one step away, delete the old world only after burn-in. Boring is the goal:

# 0. Prereqs (done above): CRDs pinned, controller up, GatewayClass ACCEPTED

# 1. Create the shared Gateway (infra team). One Gateway fronts many Routes.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: edge
  namespace: edge-system
spec:
  gatewayClassName: cilium            # or nginx / eg / gke-l7-global-external-managed
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - name: example-wildcard      # cert-manager can target Gateways directly
      allowedRoutes:
        namespaces:
          from: All
# kubectl apply -f gateway.yaml
# Wait for PROGRAMMED status — the LB is real only when this is True:
kubectl get gateway -n edge-system edge -o jsonpath='{.status.conditions[?(@.type=="Programmed")].status}'

# 2. Attach your first converted Route — one app, one namespace
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: shop-frontend
  namespace: shop
spec:
  parentRefs:
    - name: edge
      namespace: edge-system
  hostnames: ["shop.example.com"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: / }
      filters:
        - type: RequestRedirect        # http→https, replaces ssl-redirect
          requestRedirect: { scheme: https, port: 443 }
    - matches:
        - path: { type: PathPrefix, value: / }
      backendRefs:
        - name: shop-frontend
          port: 80
# NOTE: the redirect rule and the serve rule are separate rules on the SAME route
# (http listener hosts the redirect; https listener serves). Review ingress2gateway's
# output for the split it generates — do not hand-roll the http listener away.

# 3. Smoke test through the new path, Host header first:
GW_IP=$(kubectl get gateway -n edge-system edge -o jsonpath='{.status.addresses[0].value}')
curl -sv -H "Host: shop.example.com" "http://$GW_IP/" | head -5
# Expect: 301 to https. Then against 443 with --resolve:
curl -sv --resolve shop.example.com:443:$GW_IP https://shop.example.com/ -o /dev/null -w '%{http_code}\n'

# 4. Cutover per-hostname at DNS (or per-backend at the cloud LB):
#    TTL dropped to 60s a day earlier; watch 5xx/latency dashboards per host.
# 5. Burn-in (1-2 weeks typical), then delete Ingress resources and uninstall
#    the retired controller. Keep the Ingress objects in git history — they are
#    your audit trail for what the annotations meant.

Cross-namespace wiring: the Route above lives in shop and attaches to a Gateway in edge-system. That works because the listener's allowedRoutes permits all namespaces — but if the Route's backend Service lives in a third namespace, or your policy tightens allowedRoutes, you need a ReferenceGrant (v1) making the reference explicit. Same when Gateways reference certificates across namespaces. Audit first, migrate second — this is the #1 cause of "route accepted but 500s everywhere" tickets in the first week.

Post-Migration: The Gateway API v1.6 Features You Should Actually Use

Migrating off retired software is necessary. These are the reasons the new world is worth the effort beyond survival:

Standardized filters that used to be annotations

  • TCPRoute / UDPRoute (GA, v1): the database/broker/DNS/game-server ports that used to need raw LoadBalancer services now attach to the same Gateway as HTTP routes, each on its own listener, sharing one LB address.
  • ExternalAuth (GEP-1494): per-route authentication outsourced to an authorizer service (oauth2-proxy, Authelia, OPA, your IDP) before traffic reaches the app. Cilium 1.20 implemented it; NGF and Envoy Gateway have their own policy flavors. This replaces a decade of auth-url/auth-snippet annotation hacks.
  • CORS filter (Standard since v1.5): no more enable-cors annotation drift. Schema follows upstream CORS semantics — allowCredentials is strictly boolean since v1.4's breaking change; review legacy annotations for stringy values.
  • BackendTLSPolicy (v1): gateway→backend TLS with certificate validation — the answer to "is the TLS between my edge and my pods actually verified?" that ingress-nginx answered with a shrug.
  • ListenerSets (Standard in v1.5): additional listeners on a Gateway without redefining the main spec — useful for multi-tenant edges.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: demo-gateway
  namespace: default
spec:
  gatewayClassName: cilium
  listeners:
    - name: web
      protocol: HTTP
      port: 80
      allowedRoutes:
        kinds:
          - kind: HTTPRoute
    - name: mongo
      protocol: TCP
      port: 27017
      allowedRoutes:
        kinds:
          - kind: TCPRoute
    - name: dns
      protocol: UDP
      port: 53
      allowedRoutes:
        kinds:
          - kind: UDPRoute
---
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
  name: mongo
  namespace: default
spec:
  parentRefs:
    - name: demo-gateway
      sectionName: mongo
  rules:
    - backendRefs:
        - name: pacman-mongo
          port: 27017
---
apiVersion: gateway.networking.k8s.io/v1
kind: UDPRoute
metadata:
  name: dns
  namespace: default
spec:
  parentRefs:
    - name: demo-gateway
      sectionName: dns
  rules:
    - backendRefs:
        - name: coredns
          port: 53

Operational Costs Nobody Puts in the Migration Deck

The migration itself is bounded. The ongoing costs are where the honest comparison lives:

Cost area What to expect
CRD lifecycle ownership You now track Gateway API releases (v1.5 → v1.6 → …) as a product. Pin versions, read release notes before every bump, keep the Standard channel discipline. Managed providers (GKE) do this for you — that is part of what you are buying.
Annotation archaeology The one-time cost everyone underestimates: configuration-snippet users need case-by-case redesign. Count yours before promising a timeline: kubectl get ingress -A -o json | jq '[.items[] | select(.metadata.annotations["nginx.ingress.kubernetes.io/configuration-snippet"] != null)] | length'.
Learning curve & on-call New failure modes to learn: routes not attaching (allowedRoutes/ReferenceGrant), silent policy gaps, per-implementation conformance deltas. Expect a month of on-call ramp; run both stacks' dashboards side by side during burn-in.
Per-implementation surprises Cilium: TPROXY prerequisites and client-IP/XFF semantics differ from ingress-nginx — read the "How Cilium Ingress and Gateway API differ" doc before assuming parity. NGF: it's a rewrite, not a lift. GKE: HTTPRoute-only is a hard ceiling if you have TCP/UDP/TLS passthrough needs. Envoy Gateway: policy surface breadth is a feature and a review burden.
Dual-stack running costs During migration you pay for two data planes. Fine. The trap is staying there: dual-running beyond a quarter turns into an unmaintainable snowflake edge. Set a deletion deadline for the old stack when you start.

Who Should Skip This Migration (Honestly)

Nobody who runs ingress-nginx gets to skip entirely — retirement removed that option. But the pace and target differ:

The Bottom Line

Ingress NGINX retired on March 19, 2026 and is not coming back. Gateway API v1.6 is a genuinely better API — role-oriented, protocol-complete to L4, portable across implementations — but the migration is real engineering, not a find-and-replace. The work splits cleanly: CRD discipline and controller choice are platform-team decisions with a right answer you can verify; annotation conversion is per-app archaeology where the snippets live; the cutover is boring if you let it be. Do the inventory this week. Every day on retired edge code is unpriced risk, and the industry-wide migration wave means migration expertise will be scarce exactly when you need it.

References & Further Reading