Lesson 38 of 66 · 19 min read · Updated

Migrating from Ingress NGINX to Gateway API with ingress2gateway

In the previous article you shared one Gateway between several teams and used ReferenceGrant to let routes and certificates cross namespace boundaries safely. That completes the Gateway API toolbox. This final article answers the question most readers actually arrive with: I already run Ingress NGINX in production. How do I get from there to here without an outage?

The urgency is real. In November 2025, Kubernetes SIG Network and the Security Response Committee announced the retirement of the Ingress NGINX controller. Best-effort maintenance ended in March 2026. Since then there have been no new releases, no bug fixes, and no security patches. Existing installations keep running and the images stay downloadable, but every new CVE in that controller is now permanent.

Two things are often confused here, so it is worth being precise:

  1. The Ingress NGINX controller (the kubernetes/ingress-nginx project) is retired.
  2. The Ingress API (networking.k8s.io/v1 Ingress) is not deprecated or removed. It is feature-frozen: it still works, but it will not gain new capabilities. Other controllers still implement it.

So you have two options: switch to another Ingress controller, or move to Gateway API. The official recommendation is Gateway API, and with the earlier articles of this series behind you, it is the natural choice. The tool that does most of the mechanical work is ingress2gateway.

We will cover the following topics:

  1. Building an inventory of your Ingress objects and the NGINX annotations they depend on
  2. Installing ingress2gateway and running ingress2gateway print
  3. The difference between the standard and envoy-gateway emitters
  4. Which annotations convert automatically and which need manual work
  5. Running Ingress NGINX and Envoy Gateway side by side and comparing them
  6. Cutting over DNS and decommissioning Ingress NGINX
  7. A migration checklist you can reuse

Technical Requirements

  1. The minikube cluster and Envoy Gateway v1.9.2 from the earlier articles.
  2. The kiosk-api:1.0 image from the Services series, loaded into minikube.
  3. The minikube Ingress addon, which installs Ingress NGINX, to play the role of the legacy setup.
  4. jq, and either Homebrew or a Go toolchain to install ingress2gateway.

Core Concepts

What ingress2gateway Does, and What It Does Not

ingress2gateway is a SIG Network project. It reads Ingress resources, and provider-specific resources, from your cluster or from files. It then writes the equivalent Gateway API resources to standard output. It never changes the cluster. You review the output, edit it, and apply it yourself.

Internally it has two halves:

ComponentJobExamples
ProviderReads one controller's Ingress dialect, including its annotations, into a neutral modelingress-nginx, kong, traefik, istio, cilium, apisix, gce
EmitterTurns the neutral model into Gateway API YAML, optionally with implementation extrasstandard (default), envoy-gateway, kgateway, agentgateway, gce

The standard emitter produces only portable Gateway API resources: Gateway, HTTPRoute, GRPCRoute, TLSRoute, BackendTLSPolicy. Anything that has no portable equivalent, such as a request body size limit, is reported as a warning and dropped.

The envoy-gateway emitter produces the same resources plus Envoy Gateway policies for the things Gateway API itself cannot express. For example, it turns proxy-body-size into a BackendTrafficPolicy and whitelist-source-range into a SecurityPolicy. Since this series runs Envoy Gateway, that is the emitter to use.

The project is explicit about one thing: it is not meant to copy every annotation. Some widely used annotations still have no translation. The tool tells you which ones it skipped, and that warning list is your manual work.

How NGINX Annotations Map to Gateway API

The table below covers the annotations you are most likely to meet. "Auto" means the ingress-nginx provider converts it.

NGINX annotationGateway API equivalentConversion
rewrite-target (no capture groups)HTTPRoute URLRewrite filter, ReplaceFullPathAuto
rewrite-target with $1, $2Usually URLRewrite with ReplacePrefixMatchManual, flagged as unsupported
ssl-redirect / TLS on the IngressRequestRedirect filter with scheme: https, status 308Auto
permanent-redirect, temporal-redirect, app-rootRequestRedirect filterAuto
canary, canary-weightWeighted backendRefsAuto
canary-by-header, canary-by-header-valueHeader matches on a ruleAuto
canary-by-cookie, canary-by-header-patternHeader or regex match written by handManual, warning
proxy-connect-timeout, proxy-read-timeout, proxy-send-timeoutRoute timeouts.requestAuto, best-effort, with a warning to review the value
proxy-body-sizeEnvoy Gateway BackendTrafficPolicy requestBuffer.limitAuto with envoy-gateway emitter
whitelist-source-range, denylist-source-rangeEnvoy Gateway SecurityPolicy authorization by client CIDRAuto with envoy-gateway emitter
enable-cors and cors-*HTTPRoute CORS filter (Standard since v1.5)Auto
backend-protocol: GRPCGRPCRoute instead of HTTPRouteAuto
ssl-passthroughTLSRoute with a Passthrough listenerAuto
upstream-vhostRequestHeaderModifier setting HostAuto
custom-headers, configuration-snippet, server-snippetNo portable equivalentManual, redesign

The snippet annotations deserve a special mention. They let you inject raw NGINX configuration, and they were behind several serious Ingress NGINX vulnerabilities. There is nothing to convert them to. Treat every snippet as a small design task: work out what it was for, then express that with a filter or a policy.

The Migration Strategy

A safe migration has five phases, and only the last one is irreversible:

  1. Inventory: know every Ingress, host and annotation in the cluster.
  2. Convert: generate Gateway API resources with ingress2gateway, then fix the warnings by hand.
  3. Run side by side: apply the new resources. Envoy Gateway gets its own address, while Ingress NGINX keeps serving real traffic.
  4. Cut over: compare both paths, then move DNS to the new address.
  5. Decommission: once no traffic reaches Ingress NGINX, delete the Ingress objects and uninstall the controller.

Because both controllers can run at the same time, you can migrate one hostname at a time, and roll back by pointing DNS back.

Hands-On: Kubernetes Commands

List every Ingress in the cluster

The starting point of the inventory.

kubectl get ingress -A

List the IngressClasses

Tells you which controllers are installed and which one each Ingress uses.

kubectl get ingressclass

Find the running Ingress NGINX version

Old versions carry known CVEs and are a reason to hurry.

kubectl get pods -A -l app.kubernetes.io/name=ingress-nginx -o jsonpath='{range .items[*]}{.spec.containers[0].image}{"\n"}{end}'

Count which NGINX annotations you actually use

This tells you how much manual work is ahead.

kubectl get ingress -A -o json | jq -r '.items[].metadata.annotations // {} | keys[]' | grep nginx.ingress.kubernetes.io | sort | uniq -c | sort -rn

Install ingress2gateway

Homebrew on macOS and Linux, or go install with a Go toolchain.

brew install ingress2gateway
go install github.com/kubernetes-sigs/[email protected]

Convert one namespace

--providers is the only required flag.

ingress2gateway print --providers=ingress-nginx -n storefront

Convert the whole cluster with Envoy Gateway extras

-A reads every namespace; --emitter adds Envoy Gateway policies.

ingress2gateway print --providers=ingress-nginx -A --emitter=envoy-gateway > gateway-api.yaml

Convert from files instead of the cluster

Useful when your Ingresses live in Git or come out of helm template. The flag can be repeated.

ingress2gateway print --providers=ingress-nginx --input-file=ingress.yaml --emitter=envoy-gateway

Step-by-Step Example

The Scenario

The storefront has been running behind Ingress NGINX for two years. It has three Ingress objects that use the annotations you meet most often in the field:

  1. kiosk-api serves shop.storefront.local, with a /api prefix that must be stripped before reaching the app. It also has a body size limit and a longer read timeout.
  2. kiosk-api-canary sends 20% of that traffic to a canary Deployment.
  3. kiosk-admin serves admin.storefront.local and is restricted to the office IP range.

You will create this legacy setup, convert it, fix what the tool cannot, run both controllers side by side, and finally remove Ingress NGINX.

Step 1 — Build the Legacy Setup

Enable the Ingress NGINX addon. On minikube this installs the controller into the ingress-nginx namespace with IngressClass nginx.

minikube addons enable ingress
kubectl get pods -n ingress-nginx
kubectl get ingressclass
NAME CONTROLLER PARAMETERS AGE
nginx k8s.io/ingress-nginx <none> 40s

On Docker Desktop's built-in kind cluster or a plain kind cluster there is no addon. Install the controller with its Helm chart instead; the chart is still downloadable, it just receives no updates:

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace --wait

If the first kubectl apply of an Ingress fails with failed calling webhook "validate.nginx.ingress.kubernetes.io" ... x509: certificate signed by unknown authority, the chart's admission webhook was not fully patched yet. Wait a few seconds and apply again.

Deploy the stable and canary versions of the app. Create 1-apps.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: kiosk-api
namespace: storefront
spec:
replicas: 3
selector:
matchLabels:
app: kiosk-api
track: stable
template:
metadata:
labels:
app: kiosk-api
track: stable
spec:
containers:
- name: kiosk-api
image: kiosk-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
apiVersion: v1
kind: Service
metadata:
name: kiosk-api
namespace: storefront
spec:
selector:
app: kiosk-api
track: stable
ports:
- port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kiosk-api-canary
namespace: storefront
spec:
replicas: 1
selector:
matchLabels:
app: kiosk-api
track: canary
template:
metadata:
labels:
app: kiosk-api
track: canary
spec:
containers:
- name: kiosk-api
image: kiosk-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
apiVersion: v1
kind: Service
metadata:
name: kiosk-api-canary
namespace: storefront
spec:
selector:
app: kiosk-api
track: canary
ports:
- port: 80
targetPort: 8080

If you still have the kiosk-api Deployment from earlier articles, delete it first. Its selector lacks the track label, and a Deployment's selector cannot be changed in place.

kubectl delete deployment kiosk-api -n storefront --ignore-not-found
kubectl apply -f 1-apps.yaml

Step 2 — The Legacy Ingress Objects

Create 2-legacy-ingress.yaml. These are exactly the kind of annotations you will find in an older cluster.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kiosk-api
namespace: storefront
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/proxy-body-size: 8m
nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
spec:
ingressClassName: nginx
rules:
- host: shop.storefront.local
http:
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: kiosk-api
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kiosk-api-canary
namespace: storefront
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "20"
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/use-regex: "true"
spec:
ingressClassName: nginx
rules:
- host: shop.storefront.local
http:
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: kiosk-api-canary
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kiosk-admin
namespace: storefront
annotations:
nginx.ingress.kubernetes.io/whitelist-source-range: 10.20.0.0/16
spec:
ingressClassName: nginx
rules:
- host: admin.storefront.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kiosk-api
port:
number: 80

Apply it and confirm the legacy path works. Port-forward to the Ingress NGINX controller in a separate terminal:

kubectl apply -f 2-legacy-ingress.yaml
kubectl -n ingress-nginx port-forward service/ingress-nginx-controller 9999:80
for i in $(seq 1 10); do curl -s -H "Host: shop.storefront.local" http://localhost:9999/api/ | jq -r .servedByPod; done
kiosk-api-7c9f8d5b6d-4m2xq
kiosk-api-7c9f8d5b6d-9tn6c
kiosk-api-canary-6b5d8c7f4-zx8lp
...

Roughly two out of ten responses come from the canary, and /api/ reaches the app as /. That is the behaviour you must preserve.

Step 3 — Take the Inventory

Before converting anything, write down what you have.

kubectl get ingress -n storefront
NAME CLASS HOSTS ADDRESS PORTS AGE
kiosk-admin nginx admin.storefront.local 192.168.49.2 80 1m
kiosk-api nginx shop.storefront.local 192.168.49.2 80 1m
kiosk-api-canary nginx shop.storefront.local 192.168.49.2 80 1m
kubectl get ingress -n storefront -o json | jq -r '.items[].metadata.annotations // {} | keys[]' | grep nginx.ingress.kubernetes.io | sort | uniq -c | sort -rn
2 nginx.ingress.kubernetes.io/use-regex
2 nginx.ingress.kubernetes.io/rewrite-target
1 nginx.ingress.kubernetes.io/whitelist-source-range
1 nginx.ingress.kubernetes.io/proxy-read-timeout
1 nginx.ingress.kubernetes.io/proxy-body-size
1 nginx.ingress.kubernetes.io/canary-weight
1 nginx.ingress.kubernetes.io/canary

Check every annotation in this list against the mapping table in Core Concepts. Here only one needs attention in advance: rewrite-target uses a capture group, $2, which the tool flags as unsupported.

Step 4 — Run ingress2gateway

Install the tool, then convert the namespace with the Envoy Gateway emitter:

brew install ingress2gateway
ingress2gateway print --providers=ingress-nginx -n storefront --emitter=envoy-gateway > 4-converted.yaml

The warnings are printed to the terminal. Read them before you look at the YAML; they are the to-do list. For this setup, expect a warning that the capture-group references in rewrite-target cannot be translated.

With ingress2gateway v1.2.0 the warnings for this setup are:

WARN Failed to apply metadata.annotations."nginx.ingress.kubernetes.io/rewrite-target" from rewrite-target from Ingress storefront/kiosk-api: Path rewrites with capture group references are not supported
WARN Ingress NGINX serves TLS traffic for host "shop.storefront.local" with a self-signed certificate. This behavior will not be translated and the host will not be accessible via HTTPS.
WARN Ingress NGINX serves TLS traffic for host "admin.storefront.local" with a self-signed certificate. ...
INFO Using case-insensitive regex path matches. You may want to change this.
WARN ingress-nginx only supports TCP-level timeouts; i2gw has made a best-effort translation to Gateway API timeouts.request. Please verify that this meets your needs.

The TLS warnings refer to Ingress NGINX's built-in fallback certificate on port 443. The lab never used HTTPS, so they can be ignored here; in a real cluster they remind you to move certificates to Gateway listeners.

Open 4-converted.yaml. Generator annotations and empty status blocks are left out below:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: nginx
namespace: storefront
spec:
gatewayClassName: nginx
listeners:
- hostname: admin.storefront.local
name: admin-storefront-local-http
port: 80
protocol: HTTP
- hostname: shop.storefront.local
name: shop-storefront-local-http
port: 80
protocol: HTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kiosk-api-shop-storefront-local
namespace: storefront
spec:
hostnames:
- shop.storefront.local
parentRefs:
- name: nginx
rules:
- backendRefs:
- name: kiosk-api
port: 80
weight: 80
- name: kiosk-api-canary
port: 80
weight: 20
matches:
- path:
type: RegularExpression
value: (?i)/api(/|$)(.*).*
name: rule-0
timeouts:
request: 5m0s
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: kiosk-api-shop-storefront-local
namespace: storefront
spec:
requestBuffer:
limit: 8Mi
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: kiosk-api-shop-storefront-local
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: kiosk-admin-admin-storefront-local
namespace: storefront
spec:
authorization:
defaultAction: Deny
rules:
- action: Allow
principal:
clientCIDRs:
- 10.20.0.0/16
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: kiosk-admin-admin-storefront-local

The file also contains a plain HTTPRoute kiosk-admin-admin-storefront-local with a PathPrefix / rule for the admin host.

Look at what the tool already did for you:

  1. The two Ingresses for the same host and path were merged into one rule, with the canary weight turned into weight: 80 and weight: 20.
  2. use-regex became a RegularExpression path match. The tool made it case-insensitive with (?i), because NGINX regex locations are case-insensitive, and told you so.
  3. proxy-read-timeout: "30" became a route timeout, but not a 30-second one: the tool wrote request: 5m0s and warned that NGINX timeouts are TCP-level and cannot be mapped exactly. Decide the value yourself.
  4. proxy-body-size: 8m became a BackendTrafficPolicy with requestBuffer.limit. The standard emitter would have dropped it with a warning.
  5. whitelist-source-range became a SecurityPolicy that denies by default and allows the office range.

Step 5 — Fix What the Tool Could Not

Three edits turn the generated file into something you would put in Git.

1. Use your GatewayClass and a sensible Gateway name. The tool named the Gateway after the IngressClass and set gatewayClassName: nginx, which does not exist in your cluster. Change both:

metadata:
name: storefront-gateway
namespace: storefront
spec:
gatewayClassName: eg

Update every parentRefs entry to name: storefront-gateway. If you still have storefront-gateway from earlier articles, you can delete the generated Gateway entirely and attach the routes to the existing one, as long as its http listener does not restrict hostname.

2. Replace the regex and capture group with a prefix rewrite. The intent of /api(/|$)(.*) with rewrite-target: /$2 is simply "strip /api". Gateway API expresses that directly, without regular expressions:

rules:
- matches:
- path:
type: PathPrefix
value: /api
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
timeouts:
request: 30s
backendRefs:
- name: kiosk-api
port: 80
weight: 80
- name: kiosk-api-canary
port: 80
weight: 20

This is clearer than the original and portable across implementations, while regex path matching is only an extended feature. The snippet also sets the timeout back to the 30 seconds the old Ingress intended, instead of the generated 5m0s.

3. Review the policies. The BackendTrafficPolicy and SecurityPolicy are Envoy Gateway resources. Keep them, but note in your repository that they tie these routes to Envoy Gateway. If you ever change implementation, these are the files to revisit.

Save the edited file as 5-gateway-api.yaml.

Step 6 — Run Both Side by Side

Apply the Gateway API resources. Nothing changes for users yet: DNS, or in this lab your port-forward on 9999, still points to Ingress NGINX.

kubectl apply -f 5-gateway-api.yaml
kubectl wait --for=condition=Accepted gateway/storefront-gateway -n storefront --timeout=120s
kubectl get httproute -n storefront

Check that every route is Accepted and every reference resolved:

kubectl get httproute -n storefront -o jsonpath='{range .items[*]}{.metadata.name}{": "}{range .status.parents[*].conditions[*]}{.type}={.status} {end}{"\n"}{end}'
kubectl get backendtrafficpolicy,securitypolicy -n storefront

Port-forward to Envoy in another terminal, exactly as in the earlier articles:

export ENVOY_SERVICE=$(kubectl get svc -n envoy-gateway-system \
--selector=gateway.envoyproxy.io/owning-gateway-namespace=storefront,gateway.envoyproxy.io/owning-gateway-name=storefront-gateway \
-o jsonpath='{.items[0].metadata.name}')
kubectl -n envoy-gateway-system port-forward service/${ENVOY_SERVICE} 8888:80

Step 7 — Compare the Old and New Paths

Now send the same requests to both controllers. A small script makes the comparison repeatable. Save it as compare.sh:

#!/usr/bin/env bash
# Usage: ./compare.sh <host> <path>
host=$1; path=$2
old=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $host" "http://localhost:9999$path")
new=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: $host" "http://localhost:8888$path")
printf "%-25s %-12s nginx=%s envoy=%s %s\n" "$host" "$path" "$old" "$new" "$([ "$old" = "$new" ] && echo OK || echo DIFF)"
chmod +x compare.sh
./compare.sh shop.storefront.local /api/
./compare.sh shop.storefront.local /api/missing
./compare.sh shop.storefront.local /other
./compare.sh admin.storefront.local /
shop.storefront.local /api/ nginx=200 envoy=200 OK
shop.storefront.local /api/missing nginx=404 envoy=404 OK
shop.storefront.local /other nginx=404 envoy=404 OK
admin.storefront.local / nginx=403 envoy=403 OK

/api/missing is rewritten to /missing on both paths, which the app does not serve, so both return the same 404. The admin host answers 403 on both, because port-forwarded traffic arrives from 127.0.0.1, which is not in 10.20.0.0/16. That is the IP restriction working identically on both paths. Check the canary split on the new path too:

for i in $(seq 1 50); do curl -s -H "Host: shop.storefront.local" http://localhost:8888/api/ | jq -r .servedByPod; done | sed 's/-[a-z0-9]*-[a-z0-9]*$//' | sort | uniq -c
40 kiosk-api
10 kiosk-api-canary

Finally, test the body size limit. A 10 MB upload should be refused by both, with the same 413 status. Run this test last: Envoy rejects the oversized body early and resets the connection, and kubectl port-forward does not survive that. It exits with lost connection to pod, and you have to start the port-forward to Envoy again before further tests.

head -c 10485760 /dev/zero > big.bin
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: shop.storefront.local" --data-binary @big.bin http://localhost:9999/api/
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: shop.storefront.local" --data-binary @big.bin http://localhost:8888/api/

In a real migration, run these comparisons for every hostname in your inventory, ideally with your existing smoke tests pointed at both addresses.

Step 8 — Cut Over DNS

On a cloud cluster the two controllers have different external IP addresses:

kubectl get svc -n ingress-nginx ingress-nginx-controller
kubectl get gateway storefront-gateway -n storefront -o jsonpath='{.status.addresses[0].value}'

The cut-over is a DNS change, done one hostname at a time:

  1. A day before, lower the TTL of the DNS records to something short, such as 60 seconds.
  2. Point the record for one hostname at the Gateway's address.
  3. Watch Envoy's access logs and your error rates. Watch the Ingress NGINX logs fall to zero for that host.
  4. If anything looks wrong, point the record back. The old path is still fully configured.
  5. Repeat for the next hostname. Restore the normal TTL when you are done.

To see whether stragglers still reach the old controller (clients with cached DNS, hard-coded IPs), count its access log lines. The default Ingress NGINX log format does not contain the host name, but it does contain the upstream in the form [namespace-service-port]:

kubectl logs -n ingress-nginx deploy/ingress-nginx-controller --since=1h | grep -c "\[storefront-kiosk-api-80\]"

If several hosts share one Service, as shop and admin do here, add $host to the controller's log-format-upstream setting for the duration of the migration so you can tell them apart.

Step 9 — Decommission Ingress NGINX

When the old controller has served no traffic for an agreed period, typically a few days, remove it. Delete the Ingress objects first, so nothing is left pointing at a controller that no longer exists:

kubectl delete -f 2-legacy-ingress.yaml
kubectl get ingress -A

Then remove the controller itself. On minikube that is the addon; in a Helm-based installation it would be helm uninstall:

minikube addons disable ingress

For a Helm installation, the equivalent is:

helm uninstall ingress-nginx -n ingress-nginx

Check with kubectl get ingressclass. Afterwards, remove the nginx IngressClass if it remains, and delete the legacy manifests from your Git repository so nobody re-applies them.

Step 10 — Clean Up

Remove the lab resources from this article.

kubectl delete -f 5-gateway-api.yaml
kubectl delete -f 1-apps.yaml
rm -f big.bin compare.sh 4-converted.yaml

Leave storefront-gateway in place if you deleted the generated Gateway and reused the existing one.

A Migration Checklist

Use this as the template for a real migration ticket.

PhaseTaskDone when
InventoryList all Ingresses, hosts, IngressClasses and the controller versionEvery hostname is in a spreadsheet with an owner
InventoryCount annotations, flag every *-snippet and capture-group rewriteEach one has a planned replacement
PrepareInstall the Gateway API implementation and a GatewayClassGatewayClass is Accepted
ConvertRun ingress2gateway print with the right emitterOutput and warnings saved in Git
ConvertFix every warning; set your GatewayClass; replace regex rewriteskubectl apply --dry-run=server succeeds
Run in parallelApply Gateway API resources next to the old IngressesAll routes Accepted and ResolvedRefs=True
VerifyCompare status codes, headers, redirects, canary split, limitsSmoke tests pass against both addresses
CertificatesMove TLS to Gateway listeners (cert-manager or Secrets plus ReferenceGrant)HTTPS works on the new address
Cut overLower TTL, switch DNS per hostname, watch metricsOld controller logs show no traffic
DecommissionDelete Ingress objects, uninstall the controller, remove manifests from Gitkubectl get ingress -A is empty

Summary

  1. The Ingress NGINX controller is retired: announced in November 2025, with no releases or security fixes since March 2026. The Ingress API is feature-frozen, not removed.
  2. ingress2gateway reads Ingress resources from the cluster (-n, -A) or from files (--input-file) and prints Gateway API YAML. It never changes the cluster.
  3. Providers read a controller's dialect (--providers=ingress-nginx); emitters write the output. Use --emitter=envoy-gateway to also get BackendTrafficPolicy and SecurityPolicy for body size limits and IP allow lists.
  4. Canary weights and headers, redirects, timeouts, CORS, gRPC and simple rewrites convert automatically. Capture-group rewrites, cookie canaries and snippet annotations need manual work, and the warnings tell you where.
  5. Always edit the output: set your own gatewayClassName, give the Gateway a real name, and prefer ReplacePrefixMatch over regex rewrites.
  6. Run both controllers side by side, compare responses for every hostname, then cut over DNS one hostname at a time with a short TTL so you can roll back.
  7. Decommission last: delete the Ingress objects, uninstall the controller, and remove the old manifests from Git.

That closes this series. You started with the limits of Ingress and finished with a migration plan. On the way you covered Envoy Gateway, the role-based resource model, HTTPS with cert-manager, header and path routing, canary releases, gRPC, and shared Gateways across namespaces: the full toolkit for running .NET services behind Gateway API in production.