If your cluster still routes traffic through community ingress-nginx, you are running software that nobody patches. Kubernetes SIG Network and the Security Response Committee retired the project on March 24, 2026, and a migration write-up from revisit.tech states plainly that it should not be used for new deployments. Your existing pods keep serving requests. The Helm charts and container images are still downloadable. But no bug fix and no security patch is coming, which turns every future CVE into your own problem.
This tutorial walks through an ingress nginx migration to the Kubernetes Gateway API in 13 steps. You will build a throwaway lab cluster, convert real annotated Ingress objects with Ingress2Gateway 1.0, run both controllers side by side, compare their behavior request by request, shift traffic in stages, and only then remove the old controller. Plan on about 60 minutes for the lab. A production rollout takes longer, but the sequence stays identical.
One scoping note before we start. If you only need a fresh controller on a new cluster, our Kubernetes Ingress setup guide for the post-NGINX era covers installation from scratch. This article is about the harder job: moving live, annotation-heavy traffic without dropping requests.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why the Ingress NGINX Migration Cannot Wait
The retirement is not a rumor or a planning document. The project’s own site lists the final releases as March 2026, and the repository stayed archived as of October 2026. Nothing replaced it inside the Kubernetes organization. The Ingress API itself survives (it is GA, covered by stability guarantees, and Kubernetes has no plan to remove it), yet it is frozen. No new fields, no new features. Kubernetes documentation now points new work at the Gateway API instead.
That split matters for how you plan. Swapping one Ingress controller for another is the lazy path, and it works for simple setups. But you inherit a frozen API and keep the same annotation sprawl that made ingress-nginx hard to reason about. The Gateway API replaces annotations with typed, validated fields, separates the roles of infrastructure owner and application developer, and lets you attach filters like redirects, header edits, CORS and timeouts directly on a route.
What the Gateway API actually is
Gateway API is a specification and a set of CRDs, not a proxy. According to the official introduction, it does not include the Ingress kind, so every existing manifest needs a one-time conversion. Controllers such as NGINX Gateway Fabric, Cilium, Envoy Gateway or Istio implement the spec. That distinction trips people up, because “install Gateway API” is not a complete instruction. You install the CRDs and you install one implementation.
The three resources you will touch most are GatewayClass (which implementation), Gateway (listeners such as port 80 or 443 on a hostname), and HTTPRoute (how requests map to Services). A platform team usually owns the first two. Application teams own routes. In an Ingress world, one object carried all three concerns plus a pile of free-text annotations.
Where the tooling stands in October 2026
Several pieces are current as you read this. Ingress2Gateway reached 1.0 on March 20, 2026, and it converts supported Ingress resources and implementation-specific annotations into Gateway API resources, warning about behavior it cannot translate. Gateway API v1.6.3 is the current release, and the project has reportedly published v1.7.0 release candidates since, which you should skip for production. NGINX Gateway Fabric 2.8.0 reportedly lists Kubernetes 1.32 or later as its floor, so check your cluster version against it.
Prerequisites and Versions
You need a Linux or macOS workstation with Docker running, plus the tools in the table below. Every version listed comes from the current facts sheet or is a minimum, not a pin. Where this guide says “latest”, run the command and record what you get, so your rollout notes name real versions.
| Tool | Version used here | Purpose |
|---|---|---|
| Docker | Any current release | Runs the kind node containers |
| kind | Latest release | Local Kubernetes cluster using its default node image |
| kubectl | Within one minor version of the cluster | Apply manifests, inspect resources |
| Helm | 3 or newer | Installs ingress-nginx and NGINX Gateway Fabric |
| Ingress2Gateway | 1.0.0 | Converts Ingress to Gateway API |
| Gateway API CRDs | v1.6.3 standard channel | Adds Gateway, HTTPRoute, GatewayClass |
| NGINX Gateway Fabric | 2.8.0 | The Gateway API implementation we test |
| curl and jq | Any current release | Request comparison scripts |
A few ground rules apply throughout. Run everything in a lab first, never directly against production. Use the standard channel of Gateway API CRDs unless you know you need an experimental feature, since experimental CRDs carry an X prefix and may change shape. And keep your old controller running until the very last step. The whole method rests on the idea that both paths serve traffic at once for a while.
Time and effort budget
The 60-minute estimate breaks down roughly like this: 15 minutes for audit and lab setup, 10 for conversion and review, 15 for installing Gateway API and the controller, and 20 for verification and cutover rehearsal. If your real cluster holds hundreds of Ingress objects, multiply the audit and review phases, not the install phases.
Phase 1: Audit Your Ingress Estate and Build the Lab (Steps 1-4)
Most failed migrations skip this phase. Teams run the converter, see valid YAML, and assume the behavior carried over. It often does not. So we start by measuring what you have, then we recreate a small version of it where mistakes are free.
Step 1: Inventory Ingress objects and annotations
List every Ingress that your ingress-nginx controller owns, then count how often each nginx.ingress.kubernetes.io annotation appears. The count tells you which translation problems deserve attention first.
# Ingress objects served by the nginx class (new and legacy class syntax)
kubectl get ingress -A -o json | jq -r '
.items[]
| select(.spec.ingressClassName=="nginx" or .metadata.annotations["kubernetes.io/ingress.class"]=="nginx")
| "\(.metadata.namespace)/\(.metadata.name)"'
# Annotation frequency across the whole cluster
kubectl get ingress -A -o json | jq -r '
[.items[].metadata.annotations // {} | keys[] | select(startswith("nginx.ingress.kubernetes.io"))]
| group_by(.) | map({a: .[0], n: length}) | sort_by(-.n)[]
| "\(.n)\t\(.a)"'
# Controller-wide settings that also change behavior
kubectl -n ingress-nginx get configmap ingress-nginx-controller -o yaml
Save the output. The ConfigMap matters more than people expect, because global options such as body size limits, timeouts and header handling apply to every route and never show up on an Ingress object. Any value you find there needs an explicit home in your new setup.
Step 2: Create the lab cluster
Spin up a single-node kind cluster. We will reach both controllers through kubectl port-forward, so no extra port mappings or load balancer emulation are needed.
cat > kind-lab.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
EOF
kind create cluster --name gw-lab --config kind-lab.yaml
kubectl cluster-info --context kind-gw-lab
kubectl get nodes
You should see one node in the Ready state within a minute or so. If kind create cluster stalls on image pulls, check Docker’s disk space before anything else.
Step 3: Deploy a sample app and the legacy controller
Install ingress-nginx the way many clusters did, from its Helm chart. The chart and images remain available even though the project is retired, which is exactly why so many clusters still run it. Then deploy two small echo services, web and api, behind two Ingress objects that share one hostname.
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx --create-namespace
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s
Now the application and the Ingress objects. Save this as shop.yaml. The echo image prints the request line it received, which lets us see exactly what path each backend saw after any rewrite. Pin a specific image tag instead of the default tag when you adapt this outside a lab.
apiVersion: v1
kind: Namespace
metadata:
name: shop
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: web, namespace: shop }
spec:
replicas: 2
selector: { matchLabels: { app: web } }
template:
metadata: { labels: { app: web } }
spec:
containers:
- name: echo
image: jmalloc/echo-server
ports: [{ containerPort: 8080 }]
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: api, namespace: shop }
spec:
replicas: 2
selector: { matchLabels: { app: api } }
template:
metadata: { labels: { app: api } }
spec:
containers:
- name: echo
image: jmalloc/echo-server
ports: [{ containerPort: 8080 }]
---
apiVersion: v1
kind: Service
metadata: { name: web, namespace: shop }
spec:
selector: { app: web }
ports: [{ port: 80, targetPort: 8080 }]
---
apiVersion: v1
kind: Service
metadata: { name: api, namespace: shop }
spec:
selector: { app: api }
ports: [{ port: 80, targetPort: 8080 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-web
namespace: shop
spec:
ingressClassName: nginx
rules:
- host: shop.example.test
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: web, port: { number: 80 } } }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-api
namespace: shop
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
nginx.ingress.kubernetes.io/configuration-snippet: |
more_set_headers "X-Shop-Env: lab";
spec:
ingressClassName: nginx
rules:
- host: shop.example.test
http:
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend: { service: { name: api, port: { number: 80 } } }
Apply it with kubectl apply -f shop.yaml, then wait for the four pods with kubectl -n shop wait --for=condition=Ready pod --all --timeout=120s. Note the setup on purpose: the use-regex annotation sits on only one of the two Ingress objects, yet ingress-nginx applies regex semantics to every path on that host. The Kubernetes team documented that quirk in its February 2026 post on surprising ingress-nginx behaviors, and we will see it again in Step 11.
Step 4: Capture a behavioral baseline
Before you touch anything, record how the old controller answers a fixed list of requests. This file becomes your regression test. Open a port-forward in the background and run the capture script.
kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 8080:80 >/dev/null 2>&1 &
sleep 2
cat > probe.sh <<'EOF'
#!/usr/bin/env bash
set -u
BASE="${1:?usage: probe.sh http://127.0.0.1:PORT}"
HOST="shop.example.test"
for p in "/" "/api" "/api/" "/api/orders" "/api/orders/42" "/API/orders" "/api/orders?x=1" "//api//orders" "/missing"; do
code=$(curl -s --path-as-is -o /tmp/probe.body -w '%{http_code}' -H "Host: $HOST" "$BASE$p")
line=$(grep -m1 '^HTTP/1.1' /tmp/probe.body || echo "-")
printf '%s\t%s\t%s\n' "$p" "$code" "$line"
done
EOF
chmod +x probe.sh
./probe.sh http://127.0.0.1:8080 | tee baseline.tsv
Expect output along these lines. Pod hashes do not appear because the script keeps only the request line, and your exact results on the odd paths such as //api//orders may differ from the sample.
/ 200 HTTP/1.1 GET /
/api 200 HTTP/1.1 GET /
/api/ 200 HTTP/1.1 GET /
/api/orders 200 HTTP/1.1 GET /orders
/api/orders/42 200 HTTP/1.1 GET /orders/42
/API/orders 200 HTTP/1.1 GET /orders
/api/orders?x=1 200 HTTP/1.1 GET /orders?x=1
//api//orders 200 HTTP/1.1 GET /orders
/missing 200 HTTP/1.1 GET /missing
Read it carefully, because the baseline hides a few quirks. The uppercase /API/orders request reaches the API backend, since regex matching in ingress-nginx ignores case. The double-slash request gets normalized. Those two lines are the ones most likely to change when you switch controllers, and neither is a bug in the new system. They are legacy behavior that you must decide to keep or retire on purpose.
Whatever your baseline shows, commit it. If anything later looks wrong, baseline.tsv is the arbiter.
Phase 2: Convert the Manifests With Ingress2Gateway (Steps 5-7)
Now the translation work starts. Ingress2Gateway is a migration assistant, and the Kubernetes announcement is blunt about that: it translates what it can, warns about what it cannot, and expects a human to review every line. Treat its output as a first draft.
Step 5: Install Ingress2Gateway 1.0
You have two documented routes. If a Go toolchain is set up, install the tagged release. Otherwise use Homebrew, or download a binary from the project’s GitHub releases page.
# Option A: Go toolchain
go install github.com/kubernetes-sigs/[email protected]
# Option B: Homebrew
brew install ingress2gateway
# Confirm it runs and see the flags your build supports
ingress2gateway --help
ingress2gateway print --help
Check print --help even if you copy commands from this article. Flag sets move between releases, and the help text from your own binary beats any tutorial, including this one.
Step 6: Run the conversion and read every warning
The tool accepts manifest files, a single namespace, or the whole cluster. For the lab we read the shop namespace straight from the cluster. In production, start with one low-risk namespace instead of --all-namespaces, since a massive warning list is harder to triage than a small one.
# From files
ingress2gateway print --input-file shop.yaml --providers=ingress-nginx > gwapi.yaml
# From the live cluster, one namespace
ingress2gateway print --namespace shop --providers=ingress-nginx > gwapi.yaml 2> warnings.txt
# Whole cluster (do this later, not first)
ingress2gateway print --providers=ingress-nginx --all-namespaces > gwapi-all.yaml
The generated manifests go to standard output. The notifications typically land on standard error, which is why the second command captures them separately, but confirm that with your build. Open both files. You should find a Gateway, one or more HTTPRoute objects, and a list of notices tagged WARN or INFO. In the official announcement, the sample run produced messages like these, and your lab run should resemble them:
WARN Unsupported annotation nginx.ingress.kubernetes.io/configuration-snippet
INFO Using case-insensitive regex path matches. You may want to change this.
WARN ingress-nginx only supports TCP-level timeouts: best-effort translation to timeouts.request
WARN Gateway API does not support configuring URL normalization (RFC 3986, Section 6)
Those four messages map neatly to four decisions. The unsupported snippet means custom NGINX config has no standard equivalent, so you must find another mechanism. The regex notice flags the (?i) and trailing .* pattern the tool adds to mimic ingress-nginx’s case-insensitive prefix behavior. The timeout warning says the translation is approximate. The URL normalization notice says behavior now depends on the implementation you choose.
What the implementation flags do
The converter can also emit implementation-specific extras. According to the 1.0 announcement, adding --emitter with agentgateway, envoy-gateway or kgateway produces additional resources that try to capture settings such as maximum body size, which has no standard Gateway API field. NGINX Gateway Fabric is not on that list, so expect more manual work if you pick it. That is a fair trade for a lab, and a point to weigh when you choose an implementation for production.
Step 7: Review and hand-edit the output
This is the step the tool’s authors call the most critical one, and they are right. Work through the draft with five questions:
- Does
gatewayClassNamematch a GatewayClass that exists in your cluster? The converter copies the Ingress class name, so check it againstkubectl get gatewayclass. - Should the regex path become a plain prefix? Most teams want case-sensitive prefix matching, not a regex.
- Do you still want the automatic HTTP-to-HTTPS redirect route the tool adds when TLS is present?
- Which annotations were dropped, and who owns each replacement?
- Does every Service reference sit in the same namespace as the route, or does it need a
ReferenceGrant?
For the lab, replace the generated draft with this reviewed version. It trades the regex for PathPrefix, swaps the rewrite annotation for a URLRewrite filter, and replaces the NGINX snippet with a standard ResponseHeaderModifier filter. Save it as gateway.yaml.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shop-gw
namespace: shop
spec:
gatewayClassName: nginx
listeners:
- name: http
hostname: shop.example.test
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop-web
namespace: shop
spec:
parentRefs:
- name: shop-gw
hostnames: ["shop.example.test"]
rules:
- matches:
- path: { type: PathPrefix, value: / }
backendRefs:
- name: web
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop-api
namespace: shop
spec:
parentRefs:
- name: shop-gw
hostnames: ["shop.example.test"]
rules:
- matches:
- path: { type: PathPrefix, value: /api }
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
- type: ResponseHeaderModifier
responseHeaderModifier:
set:
- name: X-Shop-Env
value: lab
backendRefs:
- name: api
port: 80
Two deliberate omissions deserve a note. We left out the request timeout the converter proposed, because you should confirm your chosen controller’s compatibility page lists timeout support before relying on it. And we left out the CORS filter, since filter support differs by implementation. Both belong in your production review list, not your lab.
Also keep an eye on a subtle shift: PathPrefix is case-sensitive, so /API/orders will now fall through to the web route instead of reaching the API. In Step 11 we will confirm that, and you will decide whether it is the behavior you want.
Phase 3: Stand Up Gateway API Next to the Old Controller (Steps 8-10)
The core safety idea of this migration is coexistence. Gateway API resources and Ingress resources can live in the same cluster, served by different controllers, with no conflict between them. That lets you prove the new path works while the old one still carries real traffic.
Step 8: Install the Gateway API CRDs
Gateway API ships as CRDs that you install yourself, since Kubernetes does not bundle them. Pin the version in a variable so your rollout notes and your scripts agree. We use the standard channel, which holds the stable resources.
export GATEWAY_API_VERSION=v1.6.3
kubectl apply --server-side -f \
https://github.com/kubernetes-sigs/gateway-api/releases/download/${GATEWAY_API_VERSION}/standard-install.yaml
kubectl get crd | grep gateway.networking.k8s.io
You should see gatewayclasses, gateways, httproutes, grpcroutes and referencegrants at minimum. Newer releases may list more, since the Gateway API project moved TCPRoute and UDPRoute into the standard channel in its v1.6 line. If you plan to use Cilium as your implementation, its 1.20 documentation reportedly requires CRDs at v1.6.1 or newer, so always read your controller’s compatibility page before you choose a CRD version. The CRDs are cluster-wide. Two controllers cannot ask for two different versions.
Step 9: Install NGINX Gateway Fabric 2.8.0
We use NGINX Gateway Fabric for the lab because it keeps the data plane on NGINX, which makes behavior comparisons with ingress-nginx easier to reason about. Do not confuse it with the retired community project. It is a separate F5 NGINX product that implements the Gateway API. The current documentation also names 2.8.0 as the newest release and describes a fresh install, not an in-place upgrade, when moving from the 1.x series to 2.x.
The NGINX Gateway Fabric manifest install guide covers the kubectl route. The Helm route below is shorter.
helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \
--create-namespace --namespace nginx-gateway --version 2.8.0
kubectl -n nginx-gateway rollout status deploy --timeout=180s
kubectl get gatewayclass
If Helm rejects --version 2.8.0, run helm show chart oci://ghcr.io/nginx/charts/nginx-gateway-fabric and use the version it reports. The chart is the source of truth for your install. A healthy result looks like this, though the controller string and age will reflect your environment:
NAME CONTROLLER ACCEPTED AGE
nginx gateway.nginx.org/nginx-gateway-controller True 30s
The class is called nginx, the same word as the old IngressClass. Those are different Kubernetes kinds, so they do not collide, but the shared name explains why the converter’s gatewayClassName: nginx works untouched in this lab.
Step 10: Apply the Gateway and routes
Apply the reviewed manifests, then check the status conditions the controller writes back. Never assume that a successful kubectl apply means traffic will flow, because Gateway API reports problems through status, not through apply errors.
kubectl apply -f gateway.yaml
kubectl -n shop get gateway,httproute
kubectl -n shop describe httproute shop-api | sed -n '/Conditions/,/Events/p'
Look for Accepted: True and ResolvedRefs: True on each route’s parent status. Accepted means the Gateway took the route. ResolvedRefs means every backend reference points to a real Service. When either reads False, the Message field usually names the cause in plain language, and the troubleshooting section later in this article lists the common ones.
On creation of a Gateway, NGINX Gateway Fabric 2.x provisions a data-plane deployment and Service for it in the Gateway’s namespace. Find the Service and open a second port-forward on a different local port so both controllers stay reachable.
kubectl -n shop get svc -l gateway.networking.k8s.io/gateway-name=shop-gw
# Use the service name returned above (commonly shop-gw-nginx)
kubectl -n shop port-forward svc/shop-gw-nginx 8081:80 >/dev/null 2>&1 &
sleep 2
curl -s -H "Host: shop.example.test" http://127.0.0.1:8081/api/orders
A reply beginning with Request served by and showing HTTP/1.1 GET /orders means the new path works and the prefix rewrite stripped /api. At this point you have two live entry points into the same Services, one through the legacy controller and one through Gateway API. Nothing about your real traffic has changed yet.
Phase 4: Verify, Shift Traffic and Retire ingress-nginx (Steps 11-13)
Everything so far was preparation. These last three steps decide whether users notice the migration at all. Move slowly here, and keep the old path alive until the very end.
Step 11: Compare old and new behavior request by request
Run the same probe against the Gateway API entry point, then diff it against the baseline you saved in Step 4. Any difference is a behavior change you must accept or fix deliberately.
./probe.sh http://127.0.0.1:8081 | tee gateway.tsv
diff baseline.tsv gateway.tsv && echo "IDENTICAL" || echo "DIFFERENCES FOUND"
On this lab, expect a short diff, something like the following. Your output may differ on the odd paths, because URL normalization depends on each implementation.
6c6
< /API/orders 200 HTTP/1.1 GET /orders
---
> /API/orders 200 HTTP/1.1 GET /API/orders
That one line is the case-sensitivity change from Step 7. The old controller treated /API/orders as an API call because regex matching ignored case, while the new route only matches the lowercase prefix, so the request fell through to the web backend. Three outcomes are possible, and each is a legitimate decision:
- Accept it. If no real client sends uppercase paths, the new behavior is cleaner. Check access logs for a week before you say so.
- Add an explicit match. Gateway API has a
RegularExpressionpath match type, but its support level varies by implementation, so confirm your controller’s compatibility page first. - Redirect or normalize at the edge. A CDN or load balancer rule can lowercase paths before they reach the cluster.
The Kubernetes project flagged five behaviors like this in its February 27, 2026 post, “Before You Migrate: Five Surprising Ingress-NGINX Behaviors You Need to Know.” Among them: use-regex applies to every path on a host across all Ingress objects, rewrite-target implies regex matching, requests missing a trailing slash get redirected to the slashed path, and URLs get normalized. Add test lines to probe.sh for each of those four in your own environment, since each can break a client that worked fine yesterday.
Step 12: Shift traffic in stages
In production you will have two entry points with two public addresses, one per controller. Shift users between them using weighted DNS records, your cloud load balancer’s weighted target groups, or your platform’s traffic-splitting features. The Ingress2Gateway announcement recommends exactly this gradual approach, because it lets you recover quickly from a misconfiguration that slipped past testing.
Our lab has no DNS, so a small script plays the role of the weighted record. It sends each request to the Gateway API port with the percentage you pass in, and to the old port otherwise.
cat > shift.sh <<'EOF'
#!/usr/bin/env bash
PCT="${1:?percent of requests sent to Gateway API}"
N=200; ok=0; bad=0
for i in $(seq 1 "$N"); do
if (( RANDOM % 100 < PCT )); then port=8081; else port=8080; fi
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: shop.example.test" "http://127.0.0.1:${port}/api/orders")
if [ "$code" = "200" ]; then ok=$((ok+1)); else bad=$((bad+1)); fi
done
echo "gateway=${PCT}% ok=${ok} bad=${bad}"
EOF
chmod +x shift.sh
for pct in 1 10 50 100; do ./shift.sh "$pct"; done
Healthy output shows zero bad responses at every stage:
gateway=1% ok=200 bad=0
gateway=10% ok=200 bad=0
gateway=50% ok=200 bad=0
gateway=100% ok=200 bad=0
Real rollouts need real dwell time between stages. A reasonable pattern, offered as advice and not a measured standard, is a tiny canary slice for at least a full business cycle, then 10, 25, 50 and 100 percent, with a written rollback trigger at each stage. Examples of triggers: a 5xx rate above your normal baseline, p95 latency climbing past an agreed limit, or a spike in 4xx responses on a route that used to be quiet. Lower your DNS TTL well before the first shift, so a rollback takes effect in minutes instead of hours.
Step 13: Decommission ingress-nginx and clean up
Only remove the old controller after all traffic has run on Gateway API for a full cycle that includes your busiest period. Delete the Ingress objects first, then the controller, then the leftover IngressClass. Re-run the probe afterward to prove nothing depended on the old path.
kubectl -n shop delete ingress shop-web shop-api
helm -n ingress-nginx uninstall ingress-nginx
kubectl delete namespace ingress-nginx
kubectl get ingressclass
./probe.sh http://127.0.0.1:8081 | diff gateway.tsv - && echo "STILL IDENTICAL"
If an nginx IngressClass survives the uninstall, remove it with kubectl delete ingressclass nginx. Then search your repositories and CI pipelines for stale references: Helm values that still set ingressClassName: nginx, documentation that points engineers at the old annotations, and alerts that watch ingress-nginx metrics. Dead alerts are a quiet failure mode, because they stop paging you about the thing that replaced them.
Finish the lab with kind delete cluster --name gw-lab. For a production cluster, keep your exported Ingress manifests and your baseline.tsv in version control for at least one release cycle. They are your rollback map if a rarely used route misbehaves a month later.
Choosing a Gateway API Implementation
This tutorial used NGINX Gateway Fabric, but the Gateway API only describes behavior. Your real decision is which controller implements it. Because the spec is shared, you can change your mind later without rewriting routes, though implementation-specific policies will not travel with you.
| Implementation | Data plane | Migration help | Watch for |
|---|---|---|---|
| NGINX Gateway Fabric 2.8.0 | NGINX | Documented install and upgrade guides, 1.x to 2.x needs a fresh install | Not on the converter’s --emitter list, so more manual annotation work |
| Cilium 1.20 | Envoy managed by the Cilium agent and operator | Official NGINX-annotation migration guide | Reportedly needs Gateway API CRDs v1.6.1 or newer |
| Envoy Gateway | Envoy | Converter --emitter envoy-gateway | Implementation-specific extensions are not portable |
| kgateway | Envoy | Converter --emitter kgateway | Same portability trade-off |
| agentgateway | Its own proxy | Converter --emitter agentgateway | Documents conformance to the Gateway API specification |
| Istio | Envoy | Gateway API support in the mesh | Mesh scope may exceed what you need |
Use the table as a shortlist, not a verdict. Pick by constraint. If your team already trusts NGINX configuration and wants familiar debugging, Gateway Fabric is the least surprising move. If you run Cilium for networking and want one fewer moving part, its built-in Gateway API support is attractive. Our Istio vs Linkerd vs Cilium comparison explains how Cilium’s eBPF design changes the sidecar question, and this explainer on what a service mesh actually does helps you decide whether you need mesh features at all.
Selection checklist
Before committing, answer four questions. Does the controller list every feature your annotations used, such as timeouts, CORS or IP allowlists, on its own compatibility page? Does it support the Gateway API CRD version you plan to install? Who provides support when something breaks at 3 a.m., a vendor or your own team? And can you test it against your baseline.tsv in a lab that mirrors production? A controller that fails any of those should drop down the list, no matter how good its benchmarks look.
Annotation Translation Cheat Sheet
Your annotation inventory from Step 1 maps onto Gateway API features like this. The right-hand column tells you whether the standard API covers the case. For deeper annotation-by-annotation guidance, Cilium’s NGINX annotation migration documentation stresses that migration is not always one-to-one and that unsupported behavior may need redesign.
| ingress-nginx annotation | Gateway API approach | Coverage |
|---|---|---|
use-regex | PathPrefix or Exact, or RegularExpression where supported | Partial, support varies |
rewrite-target | URLRewrite filter (ReplacePrefixMatch or ReplaceFullPath) | Standard |
ssl-redirect | RequestRedirect filter on a port 80 route | Standard |
enable-cors | CORS filter on the route | Check implementation |
proxy-read-timeout, proxy-send-timeout | timeouts.request on the rule, a best-effort mapping | Check implementation |
proxy-body-size | No standard field, use an implementation policy | Not standard |
configuration-snippet | Unsupported by the converter, replace with filters or a policy | Redesign |
whitelist-source-range | Implementation policy, for example an access policy | Not standard |
| Canary and weighted routing | weight on backendRefs | Standard |
Backend HTTPS (backend-protocol) | BackendTLSPolicy | Check implementation |
Two lessons come out of this table. First, the standard API now covers most day-to-day routing, redirects and rewrites. Second, anything involving raw NGINX configuration, body limits or IP controls pushes you into implementation-specific territory. Count those annotations in your Step 1 inventory. That number is your real lock-in risk.
Common Pitfalls That Break Migrations
These are the mistakes that cost teams the most time, roughly in the order they bite.
Trusting valid YAML. The converter produces manifests that apply cleanly and still behave differently. Valid and equivalent are not the same word. Always run the baseline diff from Step 11.
Missing the host-wide regex effect. In ingress-nginx, use-regex on one Ingress turns on regex matching for every path on that host across all Ingress objects. A team that annotated only one object may have silently converted routes it never meant to touch. After migration, those routes use plain prefix matching, and anything that depended on regex semantics can change.
Forgetting that rewrite-target implies regex. The same Kubernetes post warns that the rewrite annotation switches on regex handling. When you move to a URLRewrite filter, test paths with trailing slashes and empty captures such as /api alone.
Ignoring the trailing slash redirect. The old controller redirects a request lacking a trailing slash to the slashed form in some cases. Gateway API implementations may not. Clients that follow redirects will not notice. Clients that do not follow redirects will see a 404.
Skipping URL normalization checks. Double slashes, encoded characters and dot segments get cleaned by ingress-nginx, and the converter itself warns that Gateway API has no standard setting for this. Test your implementation with ugly paths.
Overlooking global ConfigMap settings. Controller-level options never appear on Ingress objects, so the converter cannot see them. Compare the ConfigMap from Step 1 against your new controller’s defaults.
Mixing CRD versions. Installing CRDs from one release and a controller built for another produces confusing errors, so match them to the controller’s stated compatibility.
Cutting over DNS with a long TTL. If a rollback needs a DNS change and the TTL is 24 hours, your rollback plan is fiction. Lower the TTL days ahead of time.
Troubleshooting: 12 Problems and Fixes
1. Route shows Accepted: False with a parent mismatch. The parentRefs name, namespace or section name does not match a Gateway listener, or the listener’s allowedRoutes blocks your namespace. Fix the reference, or set allowedRoutes.namespaces.from appropriately.
2. ResolvedRefs: False with BackendNotFound. A backendRefs name or port does not match a Service. Compare against kubectl -n shop get svc and remember that the port is the Service port, not the container port.
3. ResolvedRefs: False with RefNotPermitted. The route points to a Service in another namespace. Create a ReferenceGrant in the target namespace that allows the reference.
4. Gateway has no address, Programmed not true. In kind and other clusters without a load balancer implementation, the generated Service of type LoadBalancer stays pending. Port-forward for lab tests, or install a bare-metal load balancer, or change the Service type through your controller’s configuration option.
5. 404 from the new entry point. Usually a hostname mismatch. The listener hostname, the route hostnames and the Host header you send must align. Test with curl -H "Host: ..." and confirm the listener spelling.
6. 503 responses with healthy-looking pods. The Service selector may not match pod labels, leaving it with no endpoints. Run kubectl -n shop get endpointslices and look for empty address lists.
7. “no matches for kind HTTPRoute”. The CRDs are missing, or you applied manifests before installing them. Install the standard-channel CRDs first (Step 8) and wait until kubectl get crd lists them.
8. Helm install fails on existing NGINX Gateway Fabric resources. Moving from the 1.x series to 2.x is documented as a fresh install: remove the old Gateway Fabric CRDs and deployment, then install again. Do not try an in-place upgrade across that boundary.
9. Ingress2Gateway prints nothing. Check the --providers value, the namespace, and whether your Ingress objects actually use the nginx class. An empty input gives an empty output.
10. Rewritten path has a wrong or doubled slash. ReplacePrefixMatch is sensitive to trailing slashes on both the match and the replacement. Test /api, /api/ and /api/x explicitly, and adjust the replacement value.
11. Redirect loop after porting ssl-redirect. If an upstream load balancer terminates TLS and forwards plain HTTP, a redirect rule on port 80 can bounce forever. Apply the redirect only where clients truly arrive over HTTP.
12. A filter is silently ignored. Some filters sit in the experimental or extended feature sets. Read your implementation’s compatibility page, and check route conditions for messages about unsupported values before blaming the network.
Advanced Tips for Production Rollouts
The lab proves the method. Real clusters add TLS, many teams, and the pressure of live revenue. These practices separate a calm migration from a long weekend.
Move TLS into the Gateway listener. In Ingress, certificates hang off each object. In Gateway API, the HTTPS listener references a Secret through certificateRefs, and the platform team owns that listener. Keep certificate issuance with cert-manager or your existing process, point the listener at the resulting Secret, and use a ReferenceGrant if the Secret lives in another namespace. The converter’s output already includes a listener with a Secret reference when your Ingress had a tls block, so start from that.
Run the converter in CI. Treat Ingress2Gateway as a continuous check, not a one-off. A pipeline job that converts every changed Ingress and fails on new WARN lines catches teams who add fresh annotations while the migration is underway. Pair it with kubectl apply --dry-run=server against a staging cluster to validate the generated resources against the real CRDs.
Use route weights for application canaries. Once on Gateway API, weight fields on backendRefs let you split traffic between two versions of a service inside one rule. That replaces the old canary annotations and keeps the logic visible in the manifest instead of hidden in controller behavior.
Split ownership on purpose. Give the platform team the GatewayClass and Gateway objects, and let application teams own HTTPRoute objects in their own namespaces. Set allowedRoutes on each listener to the narrowest scope that works. That is the main organizational payoff of the new model, and a migration is the cheapest moment to introduce it.
Watch for adjacent options. A Gateway API controller is not your only choice at the edge. If your API surface needs authentication, quotas and developer portals, a managed layer may fit better, and our AWS API Gateway REST tutorial and the Kong vs AWS API Gateway vs Apigee comparison show where that route pays off. Both can sit in front of, not instead of, an in-cluster gateway.
Plan for AI and agent traffic. The Gateway API project has an Inference Extension, and Cilium’s development documentation reportedly lists support marked beta. If model-serving traffic is on your roadmap, read our roundup of cloud-native AI gateways for Kubernetes teams before you lock in a controller. For service-to-service exposure across accounts and VPCs on AWS, VPC Lattice is a different tool that also speaks Kubernetes Gateway API.
Write the rollback before the rollout. A rollback here is a weight change, not a redeploy. Document exactly which DNS record or load balancer weight to flip, who is allowed to flip it, and the metric that triggers it. Rehearse it once in staging.
Complete Working Project: One Script, Whole Lab
Everything in this guide fits in a small repository. Keep it in version control so teammates can rerun the lab and compare their own results with yours.
gw-migration-lab/
kind-lab.yaml cluster definition (Step 2)
shop.yaml app plus legacy Ingress objects (Step 3)
probe.sh request probe (Step 4)
gateway.yaml reviewed Gateway and HTTPRoutes (Step 7)
shift.sh traffic shift simulator (Step 12)
baseline.tsv saved output from the old controller
run-lab.sh end-to-end runner (below)
The runner below stitches the install steps together and stops at the comparison, so a human reads the diff and decides. Automating the decision itself would defeat the purpose of the review.
#!/usr/bin/env bash
set -euo pipefail
GATEWAY_API_VERSION="${GATEWAY_API_VERSION:-v1.6.3}"
NGF_VERSION="${NGF_VERSION:-2.8.0}"
kind create cluster --name gw-lab --config kind-lab.yaml
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx --create-namespace
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s
kubectl apply -f shop.yaml
kubectl -n shop wait --for=condition=Ready pod --all --timeout=120s
kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 8080:80 >/dev/null 2>&1 &
sleep 2
./probe.sh http://127.0.0.1:8080 | tee baseline.tsv
kubectl apply --server-side -f \
"https://github.com/kubernetes-sigs/gateway-api/releases/download/${GATEWAY_API_VERSION}/standard-install.yaml"
helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \
--create-namespace --namespace nginx-gateway --version "${NGF_VERSION}"
kubectl -n nginx-gateway rollout status deploy --timeout=180s
kubectl apply -f gateway.yaml
sleep 20
SVC=$(kubectl -n shop get svc -l gateway.networking.k8s.io/gateway-name=shop-gw -o jsonpath='{.items[0].metadata.name}')
kubectl -n shop port-forward "svc/${SVC}" 8081:80 >/dev/null 2>&1 &
sleep 2
./probe.sh http://127.0.0.1:8081 | tee gateway.tsv
diff baseline.tsv gateway.tsv || echo "Review the differences above before shifting traffic."
Run it with bash run-lab.sh, read the diff, then continue with ./shift.sh and the cleanup from Step 13. When you are done exploring, the broader cloud computing section has related guides on Kubernetes networking, cost control and cluster operations.
FAQ
Is community ingress-nginx still safe to run?
Not for long-lived production use. The project was retired on March 24, 2026, and its repository remains archived. Existing deployments keep working, and the Helm charts and images stay available, but no bug fixes or security updates will arrive. Plan the move now rather than after the next vulnerability disclosure.
Do I have to move to Gateway API, or can I pick another Ingress controller?
You can pick another actively maintained Ingress controller, and the Ingress API is not being removed. It is frozen, though, with no further development, and Kubernetes documentation recommends Gateway API for new work. Choose a replacement Ingress controller if you need a fast, minimal change. Choose Gateway API if you want the supported direction of the ecosystem.
Does Ingress2Gateway convert everything automatically?
No. Version 1.0 supports more than 30 common ingress-nginx annotations and tests them against real controllers, but it also prints warnings for settings it cannot translate, such as configuration snippets. Treat the output as a draft, review each warning, and test behavior before you ship it.
Can Ingress and Gateway API run in the same cluster?
Yes. Different controllers serve each API, so you can deploy Gateway API resources alongside your existing Ingress and shift traffic gradually. This coexistence is what makes a low-risk migration possible.
Which Gateway API version should I install?
Gateway API v1.6.3 is the version this tutorial uses. Release candidates for v1.7.0 have reportedly appeared since, but avoid release candidates in production. Always check your chosen controller’s compatibility page first, because it decides which CRD versions are safe.
Is NGINX Gateway Fabric the same thing as ingress-nginx?
No. NGINX Gateway Fabric is a separate F5 NGINX product that implements the Gateway API. The community ingress-nginx controller is the retired project, and F5’s separate NGINX Ingress Controller is a third thing. Mixing up the three is a common source of wrong advice online.
How long does a real migration take?
The lab in this guide takes about 60 minutes. A production migration depends on how many Ingress objects you have, how many unusual annotations they use, and how much dwell time you require between traffic stages. The audit in Step 1 gives you the best early estimate.





