Migrating from ingress-nginx to the Kubernetes Gateway API

2026-08-31 · 13 min read · gen:2m 44s · tok:15480
#kubernetes #gateway-api #ingress-nginx #devops #advanced-tutorial #english

ingress-nginx is archived. A hands-on guide to migrating real traffic to the Gateway API with Envoy Gateway or Cilium: HTTPRoutes, filters, canary, TLS and a safe cutover.

Migrating from ingress-nginx to the Kubernetes Gateway API: A Hands-On Cutover Guide

The ingress-nginx project was archived at the start of 2026. No more releases, no more bug fixes, and β€” the part that should worry you β€” no more security patches. If you run Kubernetes in production, there’s a decent chance this affects you directly: recent surveys put ingress-nginx usage around half of all clusters. “Do nothing” is now a slow-motion incident.

The community has settled on the Gateway API as the long-term replacement for Ingress. It’s not a new controller; it’s a new set of Kubernetes resources (GatewayClass, Gateway, HTTPRoute) that several controllers implement β€” Envoy Gateway, Cilium, Istio, Kong, and others. This guide walks a real migration: install a Gateway controller alongside your existing ingress-nginx, convert the Ingress objects, translate the annotations that don’t convert automatically, and cut traffic over host by host without a big-bang maintenance window.

Prerequisites

  • A Kubernetes cluster on 1.29+ with kubectl access and cluster-admin for the install steps
  • Helm 3.14+
  • An existing ingress-nginx deployment with real Ingress objects to migrate
  • cert-manager already running if you terminate TLS in-cluster (this guide assumes you do)
  • ingress2gateway v0.4.0+ installed locally (go install github.com/kubernetes-sigs/ingress2gateway@latest)
  • Familiarity with how your current Ingress annotations behave (rewrites, redirects, timeouts, auth)

You should be comfortable reading and diffing YAML manifests and rolling DNS or load-balancer changes.

Architecture and Key Concepts

Ingress packed everything β€” listeners, hostnames, paths, TLS, and vendor-specific behavior β€” into one object plus a soup of annotations. The Gateway API splits that surface into three resources owned by three different roles.

flowchart TD
    subgraph Infra["Infrastructure team"]
        GC[GatewayClass<br/>controllerName: envoy / cilium]
    end
    subgraph Cluster["Cluster operators"]
        GW[Gateway<br/>listeners :80 / :443<br/>TLS cert refs]
    end
    subgraph Dev["Application teams"]
        R1[HTTPRoute<br/>shop.example.com]
        R2[HTTPRoute<br/>api.example.com]
    end
    GC --> GW
    GW --> R1
    GW --> R2
    R1 --> S1[(Service: storefront)]
    R2 --> S2[(Service: api)]
    R2 -. weighted 90/10 .-> S3[(Service: api-canary)]
  • GatewayClass names the controller (spec.controllerName). You install one per controller and rarely touch it again.
  • Gateway defines listeners: ports, protocols, hostnames, and TLS certificate references. Cluster operators own it. allowedRoutes decides which namespaces may attach routes.
  • HTTPRoute holds the routing rules: hostnames, matches (path, headers, method, query), filters (header mutation, redirects, rewrites, mirroring), and backendRefs with optional weights for traffic splitting. App teams own these, in their own namespaces.

The practical wins: annotations become typed, validated fields; canary routing is native instead of a second Ingress with canary-weight; and a developer can’t accidentally change the TLS config or another team’s hostname because those live in objects they don’t control.

Choosing a controller

ControllerData planeBest when
Envoy GatewayEnvoy (userspace)You want the reference implementation, high Gateway API conformance, and portability across clouds
CiliumeBPF for L4, Envoy for L7Cilium is already your CNI; you want in-kernel L4 load balancing near line rate
IstioEnvoyYou already run Istio and want the mesh and edge to share config

This guide uses Envoy Gateway for the examples because it’s self-contained and cloud-neutral. Every manifest shown is standard Gateway API v1 β€” only the GatewayClass controllerName and the install differ for Cilium or Istio.

Step-by-Step Implementation

Installing a Gateway controller alongside ingress-nginx

The two can coexist. ingress-nginx watches Ingress objects; the Gateway controller watches Gateway/HTTPRoute. Install the Gateway API CRDs and Envoy Gateway:

1
2
3
4
5
6
7
8
9
# Gateway API CRDs (standard channel)
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml

# Envoy Gateway
helm install envoy-gateway oci://docker.io/envoyproxy/gateway-helm \
  --version v1.3.0 \
  --namespace envoy-gateway-system --create-namespace

kubectl -n envoy-gateway-system rollout status deploy/envoy-gateway

Create the GatewayClass and a shared Gateway. The Gateway gets its own external IP / load balancer, separate from the one ingress-nginx already has β€” that separation is what makes the cutover safe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: eg
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: shared-gateway
  namespace: gateway-system
spec:
  gatewayClassName: eg
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: example-com-wildcard-tls
      allowedRoutes:
        namespaces:
          from: All
1
2
3
4
kubectl create namespace gateway-system
kubectl apply -f gateway.yaml
kubectl get gateway -n gateway-system shared-gateway \
  -o jsonpath='{.status.addresses[0].value}'   # note this IP/hostname for DNS later

πŸ’‘ Keep TLS certificates in the Gateway, not in every route. One wildcard certificateRef on the listener covers every *.example.com HTTPRoute. If you need per-host certs, add more listeners with specific hostname values.

Bulk-converting Ingress objects with ingress2gateway

Don’t hand-write dozens of routes. ingress2gateway reads your live Ingress objects (and ingress-nginx annotations it understands) and emits Gateway API YAML:

1
2
3
4
ingress2gateway print \
  --providers ingress-nginx \
  --namespace shop \
  > shop-routes.generated.yaml

For a typical Ingress like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# BEFORE: ingress-nginx
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: storefront
  namespace: shop
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
spec:
  ingressClassName: nginx
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /app(/|$)(.*)
            pathType: ImplementationSpecific
            backend:
              service:
                name: storefront
                port:
                  number: 8080

…the tool produces the HTTPRoute skeleton. You still review it β€” ingress2gateway converts what maps cleanly and leaves comments where it can’t:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# AFTER: Gateway API (reviewed)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: storefront
  namespace: shop
spec:
  parentRefs:
    - name: shared-gateway
      namespace: gateway-system
  hostnames:
    - shop.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /app
      filters:
        - type: URLRewrite
          urlRewrite:
            path:
              type: ReplacePrefixMatch
              replacePrefixMatch: /
      timeouts:
        backendRequest: 30s
      backendRefs:
        - name: storefront
          port: 8080

Translating annotations that don’t auto-convert

Most ingress-nginx behavior maps to a Gateway API filter or field. The ones you’ll hit most:

ingress-nginx annotationGateway API equivalent
rewrite-targetURLRewrite filter β†’ path.type: ReplacePrefixMatch
ssl-redirect / force-ssl-redirectRequestRedirect filter with scheme: https, statusCode: 301 on an HTTP-listener route
permanent-redirectRequestRedirect filter (hostname, path, statusCode: 301)
proxy-read-timeout / proxy-send-timeoutrules[].timeouts.backendRequest
configuration-snippet adding headersRequestHeaderModifier / ResponseHeaderModifier filters
canary + canary-weightmultiple backendRefs with weight
enable-cors + cors-*ResponseHeaderModifier (static) or the controller’s CORS policy CRD
auth-url / auth-signin (external auth)controller-specific: Envoy Gateway SecurityPolicy, Cilium CiliumEnvoyConfig

The HTTPβ†’HTTPS redirect is worth showing because it’s on almost every Ingress. Attach a redirect-only route to the HTTP listener:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: https-redirect
  namespace: gateway-system
spec:
  parentRefs:
    - name: shared-gateway
      sectionName: http           # only the :80 listener
  hostnames:
    - "*.example.com"
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301

Anything annotation-driven that has no field yet β€” mTLS to the backend, complex Lua, rate limiting β€” is where you reach for the controller’s own policy CRDs (BackendTrafficPolicy, SecurityPolicy, ClientTrafficPolicy in Envoy Gateway). Inventory those before you start; they’re the long tail of the migration.

TLS and cert-manager

cert-manager understands the Gateway API. Annotate the Gateway instead of an Ingress and it manages the listener certificate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "shop.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: shop-example-com-tls   # cert-manager creates this

Enable the cert-manager Gateway API integration with --set config.enableGatewayAPI=true on its Helm chart.

Production Configuration

Canary and traffic splitting

This is native. No second object, no annotation β€” just weights on backendRefs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
rules:
  - matches:
      - path:
          type: PathPrefix
          value: /
    backendRefs:
      - name: api
        port: 8080
        weight: 90
      - name: api-canary
        port: 8080
        weight: 10

Weights are relative, not percentages β€” 9 and 1 behave the same as 90 and 10. Progressive delivery tools (Argo Rollouts, Flagger) drive these numbers automatically and both support the Gateway API as a first-class provider.

Cross-namespace routes with ReferenceGrant

By default an HTTPRoute can only reference Services in its own namespace, and a Gateway only accepts routes from namespaces its allowedRoutes permits. To route to a Service in another namespace, the owning namespace must publish a ReferenceGrant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
  name: allow-gateway-routes
  namespace: payments
spec:
  from:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      namespace: shop
  to:
    - group: ""
      kind: Service

This is a feature, not friction: cross-namespace access is now explicit and auditable instead of implicit.

The side-by-side cutover

Run both stacks and move traffic per hostname:

  1. Deploy the Gateway with its own load balancer IP. Nothing points at it yet.
  2. Create HTTPRoutes for one hostname. Verify with a host header against the Gateway IP directly:
    1
    2
    
    curl -H "Host: shop.example.com" -kso /dev/null -w "%{http_code}\n" \
      https://<gateway-ip>/healthz
    
  3. Shift DNS for shop.example.com from the ingress-nginx LB to the Gateway LB. Lower the TTL to 60s a day before so rollback is fast.
  4. Watch both sets of access logs. ingress-nginx traffic for that host drains to zero as caches expire.
  5. Repeat per hostname. When the last one is moved and ingress-nginx logs are quiet for a full day, delete the Ingress objects, then uninstall ingress-nginx.

Rollback at any step is a DNS change back. No route is ever served by both stacks at once, so there’s no double-routing risk.

Common Mistakes and Troubleshooting

HTTPRoute shows Accepted: False with reason NotAllowedByListeners. The Gateway listener’s allowedRoutes.namespaces doesn’t include the route’s namespace, or the route’s hostnames don’t intersect the listener hostname. Check kubectl describe httproute β€” the status conditions tell you exactly which.

404 from Envoy but the route looks right. parentRefs is missing the namespace (it defaults to the route’s own namespace, not the Gateway’s), or sectionName points at a listener that doesn’t exist.

rewrite-target regex captures don’t translate. Gateway API rewrites are prefix-based, not regex. path: /app(/|$)(.*) β†’ rewrite /$2 becomes PathPrefix: /app + ReplacePrefixMatch: /. Genuinely regex-dependent rewrites need RegularExpression path match (implementation-specific) or a small change to the app’s routing.

TLS handshake fails after cutover. The certificateRef Secret is in the Gateway’s namespace but the Gateway is elsewhere, or cert-manager hasn’t issued yet. kubectl get certificate -A and check the Gateway status Programmed condition.

External auth / OAuth2 proxy stopped working. auth-url has no Gateway API core equivalent. You need the controller’s SecurityPolicy (Envoy Gateway) or an equivalent, wired to the same OIDC provider. Don’t cut this hostname over until that’s tested.

Client IP is now the load balancer’s. Set externalTrafficPolicy: Local on the Gateway’s Service, or configure the controller’s ClientTrafficPolicy to trust X-Forwarded-For from your LB.

Performance and Scalability

  • Cilium’s eBPF data plane handles L4 in the kernel and only punts L7 (HTTP header edits, gRPC) to Envoy. If you’re already on Cilium as your CNI, this is measurably lower latency and CPU than a userspace-only proxy at high connection counts.
  • Envoy Gateway provisions one Envoy deployment per Gateway (or per GatewayClass, configurable). Size it with an EnvoyProxy resource and an HPA; the default two replicas is a starting point, not a production number.
  • Fewer reloads. ingress-nginx reloaded and re-forked NGINX on many Ingress changes. Envoy applies route changes via xDS with no connection drops, so high-churn environments stop paying the reload tax.
  • One Gateway, many routes. Don’t create a Gateway per team β€” that’s a load balancer per team. Share a Gateway, isolate with namespaces and allowedRoutes.
  • Keep route objects small. A few hundred HTTPRoutes is fine; a single 5,000-rule route is not. Split by hostname or path group.

Conclusion and Next Steps

ingress-nginx being archived forces a decision, but the Gateway API is a genuine upgrade rather than a lateral move: typed configuration instead of annotation strings, native canary, real role separation, and a spec that multiple controllers implement so you’re not locked to one project’s lifecycle again.

The migration itself is mechanical if you stage it: install a controller beside ingress-nginx, convert with ingress2gateway, translate the annotation long tail into filters and policy CRDs, then cut over one hostname at a time behind DNS.

Next steps:

  • Run ingress2gateway print against every namespace now, just to see the size of your annotation long tail.
  • Pick the controller that matches your stack (Cilium if it’s your CNI, Envoy Gateway otherwise) and stand up a non-prod Gateway.
  • Wire Argo Rollouts or Flagger to the Gateway API so canaries are automated from day one.
  • Add BackendTrafficPolicy for retries, timeouts, and circuit breaking that used to live in annotations.

Additional Resources