Lesson 38 of 66 · 19 min read · Updated
Migrating from Ingress NGINX to Gateway API with ingress2gateway
On this page
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:
- The Ingress NGINX controller (the
kubernetes/ingress-nginxproject) is retired. - The Ingress API (
networking.k8s.io/v1Ingress) 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:
- Building an inventory of your Ingress objects and the NGINX annotations they depend on
- Installing ingress2gateway and running
ingress2gateway print - The difference between the
standardandenvoy-gatewayemitters - Which annotations convert automatically and which need manual work
- Running Ingress NGINX and Envoy Gateway side by side and comparing them
- Cutting over DNS and decommissioning Ingress NGINX
- A migration checklist you can reuse
Technical Requirements
- The minikube cluster and Envoy Gateway v1.9.2 from the earlier articles.
- The
kiosk-api:1.0image from the Services series, loaded into minikube. - The minikube Ingress addon, which installs Ingress NGINX, to play the role of the legacy setup.
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:
| Component | Job | Examples |
|---|---|---|
| Provider | Reads one controller's Ingress dialect, including its annotations, into a neutral model | ingress-nginx, kong, traefik, istio, cilium, apisix, gce |
| Emitter | Turns the neutral model into Gateway API YAML, optionally with implementation extras | standard (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 annotation | Gateway API equivalent | Conversion |
|---|---|---|
rewrite-target (no capture groups) | HTTPRoute URLRewrite filter, ReplaceFullPath | Auto |
rewrite-target with $1, $2 | Usually URLRewrite with ReplacePrefixMatch | Manual, flagged as unsupported |
ssl-redirect / TLS on the Ingress | RequestRedirect filter with scheme: https, status 308 | Auto |
permanent-redirect, temporal-redirect, app-root | RequestRedirect filter | Auto |
canary, canary-weight | Weighted backendRefs | Auto |
canary-by-header, canary-by-header-value | Header matches on a rule | Auto |
canary-by-cookie, canary-by-header-pattern | Header or regex match written by hand | Manual, warning |
proxy-connect-timeout, proxy-read-timeout, proxy-send-timeout | Route timeouts.request | Auto, best-effort, with a warning to review the value |
proxy-body-size | Envoy Gateway BackendTrafficPolicy requestBuffer.limit | Auto with envoy-gateway emitter |
whitelist-source-range, denylist-source-range | Envoy Gateway SecurityPolicy authorization by client CIDR | Auto with envoy-gateway emitter |
enable-cors and cors-* | HTTPRoute CORS filter (Standard since v1.5) | Auto |
backend-protocol: GRPC | GRPCRoute instead of HTTPRoute | Auto |
ssl-passthrough | TLSRoute with a Passthrough listener | Auto |
upstream-vhost | RequestHeaderModifier setting Host | Auto |
custom-headers, configuration-snippet, server-snippet | No portable equivalent | Manual, 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:
- Inventory: know every Ingress, host and annotation in the cluster.
- Convert: generate Gateway API resources with ingress2gateway, then fix the warnings by hand.
- Run side by side: apply the new resources. Envoy Gateway gets its own address, while Ingress NGINX keeps serving real traffic.
- Cut over: compare both paths, then move DNS to the new address.
- 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.
List the IngressClasses
Tells you which controllers are installed and which one each Ingress uses.
Find the running Ingress NGINX version
Old versions carry known CVEs and are a reason to hurry.
Count which NGINX annotations you actually use
This tells you how much manual work is ahead.
Install ingress2gateway
Homebrew on macOS and Linux, or go install with a Go toolchain.
Convert one namespace
--providers is the only required flag.
Convert the whole cluster with Envoy Gateway extras
-A reads every namespace; --emitter adds Envoy Gateway policies.
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.
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:
kiosk-apiservesshop.storefront.local, with a/apiprefix that must be stripped before reaching the app. It also has a body size limit and a longer read timeout.kiosk-api-canarysends 20% of that traffic to a canary Deployment.kiosk-adminservesadmin.storefront.localand 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.
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:
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:
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.
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.
Apply it and confirm the legacy path works. Port-forward to the Ingress NGINX controller in a separate terminal:
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.
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:
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:
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:
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:
- The two Ingresses for the same host and path were merged into one rule, with the canary weight turned into
weight: 80andweight: 20. use-regexbecame aRegularExpressionpath match. The tool made it case-insensitive with(?i), because NGINX regex locations are case-insensitive, and told you so.proxy-read-timeout: "30"became a route timeout, but not a 30-second one: the tool wroterequest: 5m0sand warned that NGINX timeouts are TCP-level and cannot be mapped exactly. Decide the value yourself.proxy-body-size: 8mbecame aBackendTrafficPolicywithrequestBuffer.limit. Thestandardemitter would have dropped it with a warning.whitelist-source-rangebecame aSecurityPolicythat 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:
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:
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.
Check that every route is Accepted and every reference resolved:
Port-forward to Envoy in another terminal, exactly as in the earlier articles:
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:
/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:
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.
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:
The cut-over is a DNS change, done one hostname at a time:
- A day before, lower the TTL of the DNS records to something short, such as 60 seconds.
- Point the record for one hostname at the Gateway's address.
- Watch Envoy's access logs and your error rates. Watch the Ingress NGINX logs fall to zero for that host.
- If anything looks wrong, point the record back. The old path is still fully configured.
- 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]:
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:
Then remove the controller itself. On minikube that is the addon; in a Helm-based installation it would be helm uninstall:
For a Helm installation, the equivalent is:
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.
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.
| Phase | Task | Done when |
|---|---|---|
| Inventory | List all Ingresses, hosts, IngressClasses and the controller version | Every hostname is in a spreadsheet with an owner |
| Inventory | Count annotations, flag every *-snippet and capture-group rewrite | Each one has a planned replacement |
| Prepare | Install the Gateway API implementation and a GatewayClass | GatewayClass is Accepted |
| Convert | Run ingress2gateway print with the right emitter | Output and warnings saved in Git |
| Convert | Fix every warning; set your GatewayClass; replace regex rewrites | kubectl apply --dry-run=server succeeds |
| Run in parallel | Apply Gateway API resources next to the old Ingresses | All routes Accepted and ResolvedRefs=True |
| Verify | Compare status codes, headers, redirects, canary split, limits | Smoke tests pass against both addresses |
| Certificates | Move TLS to Gateway listeners (cert-manager or Secrets plus ReferenceGrant) | HTTPS works on the new address |
| Cut over | Lower TTL, switch DNS per hostname, watch metrics | Old controller logs show no traffic |
| Decommission | Delete Ingress objects, uninstall the controller, remove manifests from Git | kubectl get ingress -A is empty |
Summary
- 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.
- ingress2gateway reads Ingress resources from the cluster (
-n,-A) or from files (--input-file) and prints Gateway API YAML. It never changes the cluster. - Providers read a controller's dialect (
--providers=ingress-nginx); emitters write the output. Use--emitter=envoy-gatewayto also getBackendTrafficPolicyandSecurityPolicyfor body size limits and IP allow lists. - 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.
- Always edit the output: set your own
gatewayClassName, give the Gateway a real name, and preferReplacePrefixMatchover regex rewrites. - 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.
- 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.