Join our Discord Server
Ajeet Raina Ajeet Singh Raina is a former Docker Captain, Community Leader and Distinguished Arm Ambassador. He is a founder of Collabnix blogging site and has authored more than 700+ blogs on Docker, Kubernetes and Cloud-Native Technology. He runs a community Slack of 9800+ members and discord server close to 2600+ members. You can follow him on Twitter(@ajeetsraina).

Ingress-NGINX Is Retired: A Hands-On Migration to Gateway API with ingress2gateway 1.0

8 min read

The Kubernetes project stopped maintaining the Ingress-NGINX controller in March 2026. This is not a soft deprecation where the project keeps limping along for a few more years. Maintenance has ceased. No new features, no bug fixes, and most importantly, no security patches.

If you are still running Ingress-NGINX in production, every CVE that lands from here on is yours to deal with. That is a real problem for a component that sits directly on the public internet and terminates TLS for your whole cluster.

The good news is that the migration path is clearer than most people expect. The Kubernetes SIG Network team shipped ingress2gateway 1.0 in March 2026, a tool that reads your existing Ingress resources and converts them into Gateway API resources. In this tutorial we will build a small lab cluster, deploy an app behind Ingress-NGINX, convert it with ingress2gateway, and serve the same traffic through Envoy Gateway using the Gateway API.

By the end you will have a working migration you can repeat against your own clusters, plus a clear picture of what the converter handles for you and what it cannot.

Why Ingress-NGINX was retired

Two things drove the decision, and both are worth understanding before you pick a replacement.

The first is the maintainer situation. Ingress-NGINX was used by millions of clusters but was kept alive by one or two people working on it in their spare time. Best effort maintenance is fine for a side project. It is not fine for the piece of software that fronts a large share of the world’s Kubernetes traffic.

The second is the design of the controller itself. Ingress is a small API, so Ingress-NGINX extended it with annotations. Some of those annotations, particularly the snippet annotations, let you inject raw NGINX configuration from a Kubernetes object. Anyone who could create an Ingress in any namespace could influence the config of the shared proxy. That is a config injection surface that is very hard to make safe.

Gateway API was designed with those lessons in mind. There is no raw config injection. Instead there is a role oriented model where the infrastructure provider owns the GatewayClass, the cluster operator owns the Gateway, and application teams own their HTTPRoutes. Each of those maps cleanly to Kubernetes RBAC, so a developer can publish a route without being able to touch listener or TLS config.

What you need before you start

  • A Kubernetes cluster on 1.34 or newer. Kubernetes 1.37 is the current stable release. A local kind or minikube cluster is fine for this lab.
  • kubectl matching your cluster version
  • Helm 3
  • Go 1.25 or Homebrew, to install ingress2gateway
  • Around 20 minutes

Everything below runs on a laptop. Nothing here needs a cloud load balancer.

Step 1: Create the lab cluster

Create a kind cluster with ports mapped so we can reach the ingress controller from the host.

cat > kind-config.yaml <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  kubeadmConfigPatches:
  - |
    kind: InitConfiguration
    nodeRegistration:
      kubeletExtraArgs:
        node-labels: "ingress-ready=true"
  extraPortMappings:
  - containerPort: 80
    hostPort: 80
    protocol: TCP
  - containerPort: 443
    hostPort: 443
    protocol: TCP
EOF

kind create cluster --name gwapi-lab --config kind-config.yaml

Check that the node is up:

kubectl get nodes
kubectl version

Step 2: Deploy an app behind Ingress-NGINX

We need something to migrate. Install the last Ingress-NGINX release and put a simple app behind it. This mirrors what most teams already have running.

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=180s

Now the application. Two deployments so we can test path based routing, which is where most real Ingress objects earn their keep.

cat > app.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-web
spec:
  replicas: 2
  selector:
    matchLabels: { app: shop-web }
  template:
    metadata:
      labels: { app: shop-web }
    spec:
      containers:
      - name: app
        image: hashicorp/http-echo:1.0
        args: ["-text=hello from shop-web", "-listen=:5678"]
        ports:
        - containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
  name: shop-web
spec:
  selector: { app: shop-web }
  ports:
  - port: 80
    targetPort: 5678
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: shop-api
spec:
  replicas: 2
  selector:
    matchLabels: { app: shop-api }
  template:
    metadata:
      labels: { app: shop-api }
    spec:
      containers:
      - name: app
        image: hashicorp/http-echo:1.0
        args: ["-text=hello from shop-api", "-listen=:5678"]
        ports:
        - containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
  name: shop-api
spec:
  selector: { app: shop-api }
  ports:
  - port: 80
    targetPort: 5678
EOF

kubectl apply -f app.yaml

And the Ingress that we are going to convert:

cat > legacy-ingress.yaml <<'EOF'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
  ingressClassName: nginx
  rules:
  - host: shop.example.com
    http:
      paths:
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: shop-api
            port:
              number: 80
      - path: /
        pathType: Prefix
        backend:
          service:
            name: shop-web
            port:
              number: 80
EOF

kubectl apply -f legacy-ingress.yaml

Confirm it works before you change anything. Migrations go badly when you are not sure what the starting state was.

curl -s -H "Host: shop.example.com" http://localhost/
curl -s -H "Host: shop.example.com" http://localhost/api

You should see hello from shop-web and hello from shop-api.

Step 3: Install ingress2gateway 1.0

Pick whichever install method suits you.

# Homebrew
brew install ingress2gateway

# or with Go
go install github.com/kubernetes-sigs/ingress2gateway@v1.0.0

# verify
ingress2gateway --help

Binaries are also published on the GitHub releases page if you would rather not build anything.

The tool ships converters for nine providers: APISIX, Cilium, GCE, Ingress-NGINX, Istio, Kong, NGINX, OpenAPI and Traefik. That matters because each of those controllers layered its own annotations on top of the Ingress spec, and the converter knows how to translate the common ones.

Step 4: Convert your Ingress resources

The main command is print. It reads from your current kubecontext and writes Gateway API YAML to stdout.

ingress2gateway print --providers=ingress-nginx --all-namespaces

Useful flags:

  • --providers is required. Comma separated if you run more than one ingress controller.
  • -n, --namespace scopes the conversion to one namespace.
  • -A, --all-namespaces reads the whole cluster.
  • --input-file reads manifests from disk instead of a live cluster. This is the flag you want in CI, so you can convert what is in Git rather than what happens to be running.
  • -o, --output takes yaml, json or kyaml.
  • --kubeconfig if you are not using the default context.

Save the output so you can review it properly:

ingress2gateway print \
  --providers=ingress-nginx \
  --input-file=legacy-ingress.yaml \
  -o yaml > converted.yaml

cat converted.yaml

Step 5: Read the output before you apply it

The converter turns one Ingress into two kinds of object. A Gateway, which owns the listener and the port, and an HTTPRoute, which owns the hostnames, paths and backends. The output looks roughly like this:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: nginx
  namespace: default
spec:
  gatewayClassName: nginx
  listeners:
  - name: shop-example-com-http
    hostname: shop.example.com
    port: 80
    protocol: HTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: shop
  namespace: default
spec:
  parentRefs:
  - name: nginx
  hostnames:
  - shop.example.com
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /api
    backendRefs:
    - name: shop-api
      port: 80
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: shop-web
      port: 80

This is the part of the migration worth slowing down for. Notice the split of ownership. The Gateway is a cluster level concern. The HTTPRoute lives with the application team and attaches itself to the Gateway through parentRefs. On the Ingress side, all of that was in one object that anyone with namespace access could edit.

Notice also that pathType: Prefix became type: PathPrefix, and the ordering of rules is now explicit. Gateway API has defined precedence rules rather than the controller specific ordering behaviour that Ingress had.

Step 6: Install Envoy Gateway

ingress2gateway produces vendor neutral Gateway API YAML, so you need a controller that implements it. Envoy Gateway is a common choice for teams coming off Ingress-NGINX because it is a straight replacement for a shared cluster edge proxy. Cilium and the cloud provider Gateways are equally valid.

helm install eg oci://docker.io/envoyproxy/gateway-helm \
  --version v1.5.9 \
  -n envoy-gateway-system \
  --create-namespace

kubectl wait --timeout=5m -n envoy-gateway-system \
  deployment/envoy-gateway --for=condition=Available

The Helm chart installs the Gateway API CRDs for you along with Envoy Gateway’s own CRDs. If your platform team manages CRDs separately, there is a dedicated Gateway CRDs chart you can use instead.

Confirm the CRDs landed:

kubectl get crd | grep gateway.networking.k8s.io

Step 7: Apply the converted resources

The converted Gateway references a GatewayClass called nginx, which does not exist in an Envoy Gateway install. Create the Envoy GatewayClass and point the Gateway at it.

cat > gatewayclass.yaml <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: eg
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
EOF

kubectl apply -f gatewayclass.yaml

# point the converted Gateway at the new class
sed -i.bak 's/gatewayClassName: nginx/gatewayClassName: eg/' converted.yaml
kubectl apply -f converted.yaml

Watch the Gateway become ready. This is the equivalent of waiting for an Ingress to get an address, except the status is far more informative.

kubectl get gateway
kubectl describe gateway nginx

Look at the conditions. Accepted tells you the controller claimed the Gateway. Programmed tells you the underlying proxy is actually configured. If Programmed is false, the message will usually name the exact listener that failed, which is a large improvement over reading controller logs.

Check the HTTPRoute attached properly:

kubectl describe httproute shop

Under Parents you want ResolvedRefs: True and Accepted: True. If ResolvedRefs is false, the backend Service name or port is wrong, or the route is trying to reach a Service in another namespace without a ReferenceGrant.

Step 8: Test the new path

Envoy Gateway creates a Service for each Gateway. Port forward to it and send the same requests you sent in step 2.

export ENVOY_SERVICE=$(kubectl get svc -n envoy-gateway-system \
  --selector=gateway.envoyproxy.io/owning-gateway-namespace=default,gateway.envoyproxy.io/owning-gateway-name=nginx \
  -o jsonpath='{.items[0].metadata.name}')

kubectl -n envoy-gateway-system port-forward service/$ENVOY_SERVICE 8888:80 &

curl -s -H "Host: shop.example.com" http://localhost:8888/
curl -s -H "Host: shop.example.com" http://localhost:8888/api

Same two responses as before. At this point both paths are live at once: the old Ingress-NGINX path and the new Gateway API path. That overlap is the whole point. In a real cluster you keep both running, shift a small slice of DNS or load balancer traffic to the new Gateway, watch your error rates and latency, and only then move the rest.

What the converter cannot do for you

This is where migrations actually get stuck, so plan for it. ingress2gateway handles the structural conversion and the common annotations. It cannot invent equivalents for behaviour that only existed inside NGINX.

  • Snippet annotations. Anything using configuration-snippet or server-snippet has no Gateway API equivalent by design. You need to work out what that raw config was doing and rebuild it with a filter or a controller specific policy CRD.
  • Rewrites and redirects. Gateway API has first class URLRewrite and RequestRedirect filters, but the semantics do not always match the NGINX rewrite-target annotation, especially when capture groups were involved. Test these individually.
  • Auth annotations. External auth, basic auth and OAuth annotations map to controller specific policies. In Envoy Gateway that is a SecurityPolicy. In Cilium it is something else. This part is not portable.
  • Rate limiting. Same story. It becomes a BackendTrafficPolicy or the equivalent in your chosen controller.
  • Default backends and custom error pages. These need rethinking rather than converting.
  • Regex paths. Gateway API supports exact, prefix and regex matching, but the regex flavour and precedence may differ from what NGINX did.

A practical way to size the work is to count how many of your Ingress objects carry annotations at all:

kubectl get ingress -A -o json \
  | jq -r '.items[] | select(.metadata.annotations != null)
      | .metadata.namespace + "/" + .metadata.name + " -> "
        + (.metadata.annotations | keys | join(","))'

In most clusters the large majority of Ingresses are plain host and path routing, which converts cleanly. A small tail uses snippets and auth, and that tail is where the effort goes. Knowing the ratio early makes the migration plan much easier to defend.

Troubleshooting

Gateway stuck with Programmed: False

Usually the GatewayClass name does not match any installed class, or two listeners collide on the same port and hostname. Run kubectl get gatewayclass and check the Accepted condition on the class itself.

HTTPRoute shows NotAllowedByListeners

The Gateway listener has an allowedRoutes setting that does not include your route’s namespace. This is Gateway API being strict on purpose. Set allowedRoutes.namespaces.from: Same or Selector deliberately rather than opening it to All everywhere.

404 from Envoy but the route looks correct

Check the Host header. Gateway API matches hostnames strictly, and a request without the right Host will not match a listener that declares one. Also confirm the backend Service has endpoints with kubectl get endpointslices.

Cross namespace backends fail

Gateway API requires an explicit ReferenceGrant in the target namespace before a route can send traffic across a namespace boundary. Ingress had no such control, so this is new behaviour rather than a bug.

A note on Gateway API 1.5

Gateway API v1.5 landed in February 2026 and promoted six features from experimental to standard, including ListenerSet, TLSRoute, the HTTPRoute CORS filter, client certificate validation and certificate selection for Gateway TLS. Several of those close gaps that used to force people back to annotations. If you evaluated Gateway API a year ago and decided it was not ready, the CORS filter and client cert validation being stable are both worth a second look.

Cleanup

kind delete cluster --name gwapi-lab
rm -f kind-config.yaml app.yaml legacy-ingress.yaml converted.yaml converted.yaml.bak gatewayclass.yaml

Wrapping up

The retirement of Ingress-NGINX is not something you can wait out. The controller still runs, but it is unmaintained software on the edge of your cluster, and that risk grows every month.

The migration itself splits into two very different halves. The structural part, converting hosts and paths and backends, is largely mechanical and ingress2gateway does it for you. The annotation part, covering auth, rate limits and snippets, is real engineering work and needs a controller by controller answer. Run the annotation audit first so you know which half of the problem you actually have.

Start in a lab like the one above, then run both paths side by side in staging before you touch DNS in production.

References

Have Queries? Join https://launchpass.com/collabnix

Ajeet Raina Ajeet Singh Raina is a former Docker Captain, Community Leader and Distinguished Arm Ambassador. He is a founder of Collabnix blogging site and has authored more than 700+ blogs on Docker, Kubernetes and Cloud-Native Technology. He runs a community Slack of 9800+ members and discord server close to 2600+ members. You can follow him on Twitter(@ajeetsraina).

Kueue on Kubernetes: GPU Job Queueing and Fair-Share Quotas…

The default Kubernetes scheduler has no idea your GPUs are shared between teams. This hands-on lab uses Kueue to add job level admission, per...
Ajeet Raina
7 min read

Kyverno 1.19 Deprecates ClusterPolicy: A Hands-On Guide to CEL…

Kyverno 1.19 deprecated ClusterPolicy and Policy, with removal coming in v1.20. This hands-on lab covers the new CEL based ValidatingPolicy and MutatingPolicy types, a...
Ajeet Raina
8 min read

Getting Started With Kubernetes

Kubernetes conversations this week span everything from managed-service comparisons and storage volumes to scheduler research and key-management integrations. With so much happening in the...
Tanvir Kour
2 min read

Leave a Reply

Join our Discord Server