How to Migrate From ingress-nginx to Gateway API: Step-by-Step (2026)

Oct 20, 2026 · 13 min · Ajay Kumar

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.

ingress-nginx to Gateway API: run both, verify, then move DNS 1 Inventory versions, annotations, ConfigMap 2 Install CRDs v1.6.3 controller next to ingress-nginx 3 Convert ingress2gateway print, per namespace 4 Review regex, rewrites, snippets, TLS, trailing slash 5 Test status conditions curl --resolve to Gateway IP 6 Cut over low TTL, then move DNS, watch old RPS rollback = point DNS back at the ingress-nginx IP Keep every Ingress object and the ingress-nginx controller running until the old controller serves zero requests. Both data planes get their own load balancer IP, so there is no in-place swap and nothing to undo except DNS. Uninstalling ingress-nginx is the only step that removes the rollback path. Do it last, days later.
The migration flow used in this guide. The side-by-side, DNS-based cutover follows the approach recommended in the Kubernetes ingress2gateway 1.0 announcement and Microsoft's AKS migration guide.

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