How to Migrate From ingress-nginx to Gateway API: Step-by-Step (2026)
This guide is for anyone who still has ingress-nginx in front of production traffic and needs to move to Gateway API without an outage. By the end you will have an inventory of what your Ingresses actually depend on, a Gateway API controller running next to ingress-nginx, translated and reviewed routes, a tested DNS cutover, and a rollback path that works until the moment you uninstall the old controller. Commands and fields match Gateway API v1.6.3, Envoy Gateway v1.9.2, ingress2gateway v1.2.0 and cert-manager v1.21, the current releases in October 2026. I use Envoy Gateway as the example implementation; the Gateway API resources are identical for Cilium, Istio, NGINX Gateway Fabric, Traefik or kgateway, and only the install step and the GatewayClass change. You need Kubernetes 1.31 or newer, cluster-admin access, kubectl, helm 3.x and jq. If you want the background on why the retirement happened and how to choose an implementation, read Ingress NGINX Is Retired: What Replaces It and How to Choose in 2026 first.
Step 1: Inventory ingress-nginx and every annotation you depend on
The translation is the easy part. The work is in finding out which nginx behaviours your apps rely on without anyone knowing. Start with the controller itself, then the Ingress objects, then the global ConfigMap.
# 1. Where is ingress-nginx running, and which version?
kubectl get pods -A -l app.kubernetes.io/name=ingress-nginx \
-o custom-columns='NS:.metadata.namespace,POD:.metadata.name,IMAGE:.spec.containers[0].image'
# 2. Which IngressClasses exist, and which Ingresses use the nginx one?
kubectl get ingressclass
kubectl get ingress -A \
-o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,CLASS:.spec.ingressClassName,HOSTS:.spec.rules[*].host'
# 3. Annotation usage across the cluster, most common first
kubectl get ingress -A -o json \
| jq -r '.items[].metadata.annotations // {} | keys[]
| select(startswith("nginx.ingress.kubernetes.io/"))' \
| sort | uniq -c | sort -rn
# 4. Global behaviour set in the controller ConfigMap (name varies by install)
kubectl get configmap -n ingress-nginx ingress-nginx-controller -o yaml
# 5. Back up everything before you touch it
kubectl get ingress -A -o yaml > ingress-backup-$(date +%F).yaml
Sort the annotation list into three buckets. Things Gateway API covers natively: host and path routing, rewrite-target, ssl-redirect, permanent-redirect, canary weights and headers, CORS, timeouts. Things your implementation covers with its own policy CRDs: rate limits, IP allow lists, body size, external auth. And things with no clean equivalent: configuration-snippet, server-snippet, Lua, and anything that templates raw nginx config. The third bucket is where your schedule goes.
Then read the Kubernetes blog post on five surprising ingress-nginx behaviours. If any host uses use-regex or rewrite-target, ingress-nginx treats every path on that host, across all Ingresses, as a case-insensitive prefix regex. Gateway API does not. That difference is the most common source of post-migration 404s.
Step 2: Install Gateway API CRDs and a controller alongside ingress-nginx
Install the Gateway API CRDs from the Standard channel, then the controller. Envoy Gateway's Helm chart bundles Experimental-channel CRDs by default, so the Envoy Gateway install docs recommend its separate CRD chart when you want the Standard channel. Use server-side apply: the CRDs are large.
# Gateway API v1.6.3 Standard channel + Envoy Gateway CRDs (no controller yet)
helm template eg-crds oci://docker.io/envoyproxy/gateway-crds-helm \
--version v1.9.2 \
--set crds.gatewayAPI.enabled=true \
--set crds.gatewayAPI.channel=standard \
--set crds.envoyGateway.enabled=true \
| kubectl apply --server-side -f -
# Envoy Gateway controller v1.9.2, told not to manage CRDs itself
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.9.2 \
-n envoy-gateway-system --create-namespace \
--set crds.enabled=false
kubectl wait --timeout=5m -n envoy-gateway-system \
deployment/envoy-gateway --for=condition=Available
If you use a different implementation, install the upstream CRDs directly with kubectl apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.3/standard-install.yaml and follow its install docs. Since Gateway API v1.5, that manifest includes a safe-upgrades ValidatingAdmissionPolicy that refuses Experimental CRDs on top of Standard ones and refuses downgrades below v1.5. Pick one channel and one owner for the CRDs (your platform team, not every chart) and stick to it.
Now create the GatewayClass and a shared Gateway. I put the Gateway in its own namespace and only let labelled namespaces attach routes, which is the RBAC boundary ingress-nginx never had.
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: v1
kind: Namespace
metadata:
name: gateway-system
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: edge
namespace: gateway-system
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod # optional, see Step 4
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
edge-gateway: allowed
- name: https-shop
protocol: HTTPS
port: 443
hostname: shop.example.com
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: shop-example-com-tls # must live in gateway-system for cert-manager
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
edge-gateway: allowed
kubectl apply -f gateway.yaml
kubectl label namespace shop edge-gateway=allowed
kubectl wait --for=condition=Programmed -n gateway-system gateway/edge --timeout=5m
kubectl get gateway -n gateway-system edge
# NAME CLASS ADDRESS PROGRAMMED AGE
# edge eg 203.0.113.25 True 48s
The Gateway gets its own load balancer address. ingress-nginx keeps serving production on its address, untouched.
Step 3: Convert Ingresses with ingress2gateway
ingress2gateway is the SIG Network migration assistant. Version 1.0 added support for over 30 ingress-nginx annotations and prints a warning for everything it cannot translate. Install v1.2.0 and convert one namespace at a time.
# Install (pick one)
brew install ingress2gateway
go install github.com/kubernetes-sigs/ingress2gateway@v1.2.0
# Convert from a live cluster, one namespace
ingress2gateway print --providers=ingress-nginx --namespace shop > shop-gwapi.yaml
# Or from backed-up manifests, with Envoy Gateway-specific policies emitted
ingress2gateway print --providers=ingress-nginx \
--input-file ingress-backup-2026-10-20.yaml \
--emitter envoy-gateway > all-gwapi.yaml
Expect warnings like these from the tool's own documented output:
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; i2gw has made a
best-effort translation to Gateway API timeouts.request.
WARN Gateway API does not support configuring URL normalization (RFC 3986, Section 6).
Do not apply the output as-is. The generated Gateway takes its gatewayClassName from your ingress class (usually nginx), so either delete the generated Gateway and point routes at edge in gateway-system, or rename the class. Treat the file as a first draft that a human signs off.
Step 4: Translate TLS, redirects, rewrites and canaries by hand where needed
These are the patterns I see in nearly every ingress-nginx estate, with Gateway API equivalents.
Routes with HTTPS and an HTTP redirect. ingress-nginx redirects to HTTPS by default when TLS is configured. In Gateway API you write it, as the migration guide shows:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop
namespace: shop
spec:
parentRefs:
- name: edge
namespace: gateway-system
sectionName: https-shop
hostnames: ["shop.example.com"]
rules:
- matches:
- path: {type: PathPrefix, value: /}
backendRefs:
- name: shop-web
port: 8080
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop-redirect
namespace: shop
spec:
parentRefs:
- name: edge
namespace: gateway-system
sectionName: http
hostnames: ["shop.example.com"]
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
Certificates. With cert-manager, enable Gateway support (--set config.gatewayAPI.enabled=true on the Helm chart, per the cert-manager Gateway docs) and annotate the Gateway as in Step 2. cert-manager only issues for listeners with a hostname, tls.mode: Terminate and a Secret in the Gateway's own namespace. For HTTP-01, switch your issuer's solver to gatewayHTTPRoute with parentRefs pointing at the Gateway. If you would rather reuse existing Secrets in app namespaces, add a ReferenceGrant there:
apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
name: allow-edge-gateway-secrets
namespace: shop
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: gateway-system
to:
- group: ""
kind: Secret
The certificateRef on the listener then needs namespace: shop. More on certificate failures in the cert-manager troubleshooting guide.
rewrite-target. The classic path: /api(/|$)(.*) plus rewrite-target: /$2 strips a prefix. Gateway API does it without regex:
rules:
- matches:
- path: {type: PathPrefix, value: /api}
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: api
port: 8080
Canary by weight and header. ingress-nginx needs a second Ingress with canary: "true" plus canary-weight or canary-by-header. In Gateway API it is one route (traffic splitting guide). A rule with a header match beats a rule with the same path and no header match, so the header rule always wins for testers:
rules:
- matches:
- path: {type: PathPrefix, value: /}
headers:
- name: X-Canary
value: always
backendRefs:
- {name: shop-web-v2, port: 8080}
- matches:
- path: {type: PathPrefix, value: /}
backendRefs:
- {name: shop-web-v1, port: 8080, weight: 90}
- {name: shop-web-v2, port: 8080, weight: 10}
Timeouts. proxy-read-timeout and friends are TCP-level in nginx. Gateway API has timeouts.request and timeouts.backendRequest per rule (HTTP timeouts guide). Set them deliberately rather than copying the converted value. If you need longer timeouts for streaming or WebSockets, test them explicitly.
Step 5: Check route status conditions and test against the Gateway IP
Every Gateway API object reports why it is or is not working, which is the single biggest debugging improvement over ingress-nginx. Check them before sending a single request.
# Gateway: want Accepted=True and Programmed=True
kubectl get gateway -n gateway-system edge \
-o jsonpath='{range .status.conditions[*]}{.type}={.status} ({.reason}){"\n"}{end}'
# HTTPRoute: per parent, want Accepted=True and ResolvedRefs=True
kubectl get httproute -n shop shop \
-o jsonpath='{range .status.parents[*].conditions[*]}{.type}={.status} ({.reason}) {.message}{"\n"}{end}'
# Healthy output starts with (messages vary by implementation):
# Accepted=True (Accepted) ...
# ResolvedRefs=True (ResolvedRefs) ...
Then hit the new data plane directly while DNS still points at ingress-nginx. curl --resolve pins the hostname to the Gateway IP, so TLS and SNI behave exactly as they will after cutover:
GW_IP=$(kubectl get gateway -n gateway-system edge -o jsonpath='{.status.addresses[0].value}')
NGINX_IP=$(kubectl get svc -n ingress-nginx ingress-nginx-controller \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}')
for ip in "$NGINX_IP" "$GW_IP"; do
for path in / /api/health /checkout /CHECKOUT /old-path; do
printf '%-15s %-14s ' "$ip" "$path"
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' \
--resolve "shop.example.com:443:${ip}" "https://shop.example.com${path}"
done
done
Compare the two columns line by line. Any difference is either a bug in your routes or an ingress-nginx quirk your users depend on. Decide which, on purpose. Include odd-case paths, missing trailing slashes, and double slashes, because those are exactly the behaviours that differ.
Step 6: Cut over with DNS, then decommission
Lower the TTL on each hostname to 60 seconds and wait out the old TTL. If you run external-dns, add a Gateway source (for example --source=gateway-httproute) before cutover, or it will not create records for the new routes and may later delete records when you remove the Ingresses. Then move traffic: either flip the record to GW_IP, or use weighted records to send 10%, then 50%, then 100%. Watch the old controller drain:
# Requests per second still arriving at ingress-nginx, by host
sum by (host) (rate(nginx_ingress_controller_requests[5m]))
Rollback, at any point before decommissioning, is pointing DNS back at the ingress-nginx IP. That only works because you left the Ingress objects and controller in place. Once traffic has been at zero for a few days, remove ingress-nginx:
kubectl delete ingress -n shop --all # after DNS is stable on the Gateway
helm uninstall ingress-nginx -n ingress-nginx
kubectl delete ingressclass nginx --ignore-not-found
kubectl get validatingwebhookconfiguration | grep ingress-nginx # should be empty
If you see 502s or 504s after cutover, the 502 and 504 troubleshooting guide covers the backend side.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Gateway Programmed=False, reason AddressNotAssigned |
No LoadBalancer provider, or quota exhausted | Check the generated Service in envoy-gateway-system; on bare metal install MetalLB or use NodePort |
HTTPRoute Accepted=False, reason NotAllowedByListeners |
Route namespace not allowed by allowedRoutes |
Label the namespace (edge-gateway=allowed) or widen the selector |
HTTPRoute Accepted=False, reason NoMatchingListenerHostname |
Route hostnames do not intersect the listener hostname |
Add the host to the listener, or use a wildcard listener |
HTTPRoute Accepted=False, reason NoMatchingParent |
Wrong sectionName or port in parentRefs |
Match sectionName to an existing listener name |
HTTPRoute ResolvedRefs=False, reason BackendNotFound |
Service name or port typo, or Service in another namespace | Fix the backendRef; cross-namespace needs a ReferenceGrant |
ResolvedRefs=False, reason RefNotPermitted |
Cross-namespace Secret or Service without a ReferenceGrant | Create a ReferenceGrant in the target namespace |
Listener ResolvedRefs=False, reason InvalidCertificateRef |
Secret missing, wrong type, or not yet issued | kubectl describe certificate; check cert-manager Gateway support is enabled |
| Apply fails: Experimental CRDs on top of standard prohibited | Gateway API v1.5+ safe-upgrades policy |
Use one channel; only delete the policy if you deliberately want Experimental |
| 404 for paths that worked before | ingress-nginx case-insensitive prefix regex or trailing slash redirect | Use RegularExpression with (?i) or add explicit redirects, or fix the clients |
| Uploads fail with 413 | ingress-nginx proxy-body-size has no standard field |
Use your implementation's policy CRD (try --emitter envoy-gateway) |
FAQ
Can I run ingress-nginx and Gateway API at the same time?
Yes, and you should during migration. They watch different resources and each gets its own load balancer IP, so they do not conflict. That is what makes DNS the rollback mechanism.
Does ingress2gateway handle every ingress-nginx annotation?
No. Version 1.0 and later cover over 30 common ones, including CORS, rewrites, canaries, timeouts, redirects and backend TLS, and print warnings for the rest. Snippet annotations have no equivalent and need a decision, usually a policy CRD from your implementation or a change in the application.
Do I need the Experimental channel?
Usually not. Since v1.5 and v1.6, HTTPRoute, GRPCRoute, ListenerSet, CORS, TLSRoute, TCPRoute, UDPRoute and ReferenceGrant are all in the Standard channel. Stay on Standard unless you need a specific Experimental feature, and remember that switching later is blocked by the safe-upgrades policy.
How long does a migration take?
It depends almost entirely on your annotation inventory. Clusters that only use host and path routing with TLS can move in days. Clusters with snippets, custom auth and regex-heavy hosts take longer because each behaviour needs a deliberate decision and a test.
What I check first on any cluster is the annotation count from Step 1 and whether anything uses use-regex or rewrite-target, because that tells me how much of the migration is translation and how much is archaeology. In production I keep both controllers running for at least a few days after DNS moves, compare request rates and error rates per host, and only then uninstall. Official references: the Gateway API migration guide, the ingress2gateway repository, the Envoy Gateway Helm install docs, the cert-manager Gateway docs and the Gateway API implementations list.
Related: Ingress NGINX Is Retired: What Replaces It and How to Choose in 2026, Kubernetes as the Agent Control Plane in 2026 and Observability in 2026.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Do Canary Deployments with Argo Rollouts 1.10 (2026)
Step-by-step Argo Rollouts canary guide for 2026: install v1.10.1 with Helm and the Gateway API plugin v0.17.0, write a Rollout with weighted steps, add a Prometheus AnalysisTemplate, watch automated rollback, and fix the common errors.
14 minOct 20, 2026How to Set Up cert-manager with Let's Encrypt and Fix Errors (2026)
Install cert-manager v1.21 with Helm, create Let's Encrypt ClusterIssuers for HTTP-01 (Gateway API or Ingress) and DNS-01, then debug a Certificate stuck not ready by walking CertificateRequest, Order and Challenge, with rate limits, ARI and alerts.
14 minOct 20, 2026How to Run Chaos Experiments on Kubernetes with Chaos Mesh (2026)
Step-by-step Chaos Mesh 2.8.5 guide: install with Helm on containerd, opt namespaces in, write PodChaos, NetworkChaos and StressChaos YAML, gate experiments on a Prometheus SLO, schedule them, and run the same test with a LitmusChaos promProbe, plus a troubleshooting table.
16 min