
Ingress-NGINX Is Retired: Migrate to Gateway API
Summary
Ingress-NGINX hit EOL in 2026. Convert to Gateway API with ingress2gateway 1.0.
On March 24, 2026 the Kubernetes community-maintained ingress-nginx controller officially reached end of life. There are no more releases, no bug fixes, and — the part that matters most for production — no more CVE patches. Because ingress-nginx sits directly in your layer-7 data path, running an unpatched copy is exactly the kind of finding that trips SOC 2, PCI-DSS, ISO 27001, and HIPAA audits. Some teams are already seeing deploys blocked by policy.
The official recommendation from SIG Network is to move to the Gateway API, the modern successor to Ingress. To make that move tractable, on March 20, 2026 the project shipped ingress2gateway 1.0, a stable conversion tool that understands 30+ ingress-nginx annotations and emits Gateway API resources that are forward-compatible with Gateway API v1.4. This guide walks the full migration: inventory, controller choice, automated conversion, a safe parallel cutover, and the gotchas that bite people in the annotations that don't translate cleanly.
Why Gateway API, not another Ingress controller
You have two legitimate paths off the retired controller. You can swap in another actively maintained Ingress controller (HAProxy, Traefik, or the commercially supported F5 NGINX Ingress, which is a different project), or you can adopt the Gateway API. Swapping controllers buys you time but keeps you on an API that the community now treats as feature-frozen. Gateway API is where the investment is going, so for most teams it is the move that you only make once.
The conceptual shift is that Gateway API is role-oriented. Ingress crammed infrastructure config, routing rules, and vendor-specific behavior into one object plus a pile of annotations. Gateway API splits those concerns into separate resources owned by different people:
| Resource | Owned by | Responsibility |
|---|---|---|
| GatewayClass | Infra / platform | Which controller implementation backs Gateways (cluster-scoped) |
| Gateway | Platform / cluster ops | Listeners: ports, protocols, TLS certs, hostnames |
| HTTPRoute | App / service team | Path and header matching, backends, weights, filters |
| ReferenceGrant | Namespace owner | Explicitly allows cross-namespace references |
The practical payoff: app teams change routing in their own namespace without touching shared listener config, TLS lives in one place, and behavior that used to be a magic annotation string (rewrites, redirects, header mutation, traffic splitting) is now a typed, validated field. The cost is more objects and a genuinely new mental model — which is why an automated converter matters.
Prerequisites
- A Kubernetes cluster (v1.26+) where you currently run ingress-nginx, with kubectl access.
- Cluster-admin rights to install CRDs and a new controller.
- Go 1.22+ if you want to
go installingress2gateway, or grab a release binary. - A non-production namespace or staging cluster to validate against first.
- Your DNS / load-balancer setup documented, since the final cutover is a DNS or address change.
Step 1 — Inventory what you actually run
Before converting anything, find every Ingress object and, critically, every annotation in use. The annotations are where migrations succeed or fail.
# List all Ingress resources across the cluster
kubectl get ingress -A
# Pull every nginx.ingress.kubernetes.io annotation in use, ranked by frequency
kubectl get ingress -A -o json \
| jq -r '.items[].metadata.annotations // {} | keys[]' \
| grep nginx.ingress.kubernetes.io \
| sort | uniq -c | sort -rn
Example output — this is your migration risk map:
42 nginx.ingress.kubernetes.io/rewrite-target
31 nginx.ingress.kubernetes.io/ssl-redirect
18 nginx.ingress.kubernetes.io/proxy-body-size
9 nginx.ingress.kubernetes.io/configuration-snippet
4 nginx.ingress.kubernetes.io/auth-url
Rewrites and ssl-redirect convert cleanly. proxy-body-size maps to a controller-specific setting. The two that should make you pause are configuration-snippet (raw NGINX config — there is no portable equivalent) and auth-url (external auth, which becomes a controller extension or a policy resource). Flag those for manual handling now.
Step 2 — Install the Gateway API CRDs
Gateway API ships as a set of CRDs that are versioned independently of your controller. Install the standard channel for v1.4, which gives you GA GatewayClass, Gateway, and HTTPRoute:
kubectl apply -f \
https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml
# Confirm the CRDs registered
kubectl get crd | grep gateway.networking.k8s.io
Expected output confirms the API surface is present before you install an implementation:
gatewayclasses.gateway.networking.k8s.io 2026-05-21T18:02:11Z
gateways.gateway.networking.k8s.io 2026-05-21T18:02:11Z
httproutes.gateway.networking.k8s.io 2026-05-21T18:02:11Z
referencegrants.gateway.networking.k8s.io 2026-05-21T18:02:11Z
Step 3 — Pick and install a Gateway controller
The CRDs are just the contract; you need an implementation that watches them and programs a real proxy. Common choices and the trade-off each one makes:
| Controller | Best when | Trade-off |
|---|---|---|
| Envoy Gateway | You want a clean, Envoy-based standard with no service mesh baggage | Newer ecosystem, fewer legacy extensions |
| Istio | You already run or want a full service mesh | Heavier footprint than you may need for plain ingress |
| kgateway (ex Gloo) | You need rich edge features and external-auth out of the box | Another vendor abstraction to learn |
| NGINX Gateway Fabric | Your team's muscle memory is NGINX and you want continuity | Younger than the retired ingress-nginx it replaces |
For a vendor-neutral migration, Envoy Gateway is a reasonable default. Install it and wait for it to be ready:
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.4.0 -n envoy-gateway-system --create-namespace
kubectl wait --timeout=5m -n envoy-gateway-system \
deployment/envoy-gateway --for=condition=Available
# Each controller ships its own GatewayClass; confirm it is Accepted
kubectl get gatewayclass
Step 4 — Convert with ingress2gateway 1.0
Now the centerpiece. Install the converter, then have it read your live ingress-nginx objects and print equivalent Gateway API YAML. The print command never mutates the cluster — it only reads and emits — so it is safe to run against production.
# Install the stable 1.0 release
go install github.com/kubernetes-sigs/ingress2gateway@v1.0.0
# Convert every ingress-nginx Ingress in the cluster and write the result to a file
ingress2gateway print \
--providers=ingress-nginx \
> gateway-resources.yaml
# Scope it to a single namespace while you test
ingress2gateway print --providers=ingress-nginx --namespace=shop
Given a typical Ingress like this:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
namespace: shop
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: shop.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
ingress2gateway emits a Gateway plus an HTTPRoute. The path becomes a typed match and the backend becomes a backendRef:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop
namespace: shop
spec:
parentRefs:
- name: shop
hostnames:
- shop.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: api
port: 8080
Step 5 — Review the diff and fix what didn't translate
Never apply the generated YAML blind. ingress2gateway will warn (on stderr) about annotations it could not convert. Read those warnings and reconcile each one. The usual suspects, and what to do about them:
- rewrite-target / use-regex: becomes an
URLRewritefilter on the route rule. Confirm capture-group rewrites survived — complex regexes sometimes need a hand-writtenfilters[].urlRewrite.path. - ssl-redirect / force-ssl-redirect: becomes a
RequestRedirectfilter or a dedicated HTTP-to-HTTPS listener on the Gateway. Verify the redirect is actually emitted, not silently dropped. - configuration-snippet / server-snippet: does not convert. There is no portable Gateway API equivalent for raw NGINX config. Re-express the intent using a filter, a BackendTrafficPolicy, or your controller's extension CRD.
- auth-url / auth-signin: external auth is a controller-specific extension (for example Envoy Gateway's SecurityPolicy). Wire it up manually and test the 401/redirect path.
- proxy-body-size, proxy-read-timeout: map to a controller policy resource (client-traffic or backend-traffic policy), not to the HTTPRoute itself.
Validate the manifests against the cluster without applying them using server-side dry-run, which runs full admission and CRD validation:
kubectl apply --dry-run=server -f gateway-resources.yaml
Step 6 — Deploy alongside ingress-nginx (do not cut over yet)
The safe pattern is to run Gateway API and ingress-nginx in parallel on different addresses. Your new Gateway gets its own external IP from the Envoy Gateway service, while production DNS still points at the old ingress-nginx LoadBalancer. Apply the reviewed resources and wait for the Gateway to be programmed:
kubectl apply -f gateway-resources.yaml
# The Gateway should report Accepted and Programmed = True
kubectl get gateway -A
kubectl describe gateway shop -n shop | grep -A3 Conditions
# Grab the address the new data plane is listening on
kubectl get gateway shop -n shop -o jsonpath='{.status.addresses[0].value}'
Smoke-test the new path by sending traffic to the Gateway address with the production Host header, bypassing DNS entirely:
GW=$(kubectl get gateway shop -n shop -o jsonpath='{.status.addresses[0].value}')
curl -sS -H 'Host: shop.example.com' "http://$GW/api/health" -i
# HTTP/1.1 200 OK
# ...
# {"status":"ok"}
Step 7 — Shift traffic and decommission
Once the Gateway serves every route correctly, move production traffic to it. Prefer a gradual DNS-weighted shift (or a load-balancer change) over a hard flip so you can watch error rates and roll back instantly by repointing DNS:
- Lower the TTL on the production DNS record a day ahead so changes propagate fast.
- Shift a small slice of traffic (or a canary hostname) to the Gateway address and watch 5xx rates and latency.
- Ramp to 100% once metrics hold steady through a full traffic cycle.
- Leave ingress-nginx running for a rollback window (24-72h), then scale it to zero.
- Delete the old Ingress objects and uninstall the ingress-nginx controller for good.
# After the rollback window closes
kubectl delete ingress --all -n shop
helm uninstall ingress-nginx -n ingress-nginx
# Make sure nothing still references the retired controller
kubectl get ingress -A
Common pitfalls and gotchas
- Applying generated YAML without reading stderr. The converter prints clean YAML to stdout and warnings to stderr. If you only capture stdout, you silently lose every unconvertible annotation. Always read the warnings.
- Cross-namespace backends fail closed. Gateway API requires an explicit
ReferenceGrantfor an HTTPRoute to reach a Service in another namespace. Ingress allowed this implicitly; Gateway API does not. Missing grants surface as aRouteRejectedcondition, not a 404. - Forgetting the HTTP-to-HTTPS listener. ssl-redirect was one annotation. In Gateway API you typically need both an HTTP listener with a redirect filter and an HTTPS listener. Drop one and you either lose the redirect or the redirect loops.
- configuration-snippet has no equivalent. Teams that leaned on raw NGINX config have the most work. Budget time to re-express that behavior as a policy or accept that some bespoke tuning is gone.
- Mismatched CRD and controller versions. Install CRDs the controller actually supports. A controller built for Gateway API v1.3 will ignore fields that only exist in v1.4 CRDs, and you will chase phantom config that never takes effect.
- TLS Secret references across namespaces. A Gateway listener referencing a certificate Secret in another namespace also needs a ReferenceGrant. This is a frequent cause of a Gateway stuck in
Programmed: False.
Automate the conversion so it stays honest
A one-off conversion drifts the moment someone adds a new Ingress during the migration window. Treat the conversion as a check that runs in CI: regenerate the Gateway API resources from the live (or git-stored) Ingress objects, and fail the build if anyone still ships a raw Ingress for a service that has already moved. A minimal GitHub Actions step looks like this:
- name: Convert and validate Gateway API resources
run: |
go install github.com/kubernetes-sigs/ingress2gateway@v1.0.0
ingress2gateway print \
--input-file=ingress/ \
--providers=ingress-nginx \
> generated/gateway.yaml 2> warnings.txt
# Fail loudly if any annotation could not be converted
if grep -qi 'unsupported\|could not' warnings.txt; then
echo '::error::Unconvertible annotations remain:'; cat warnings.txt; exit 1
fi
Note the --input-file flag: ingress2gateway can read manifests from disk instead of a live cluster, which is what you want in CI where there is no kubeconfig. Pair this with a kind cluster step that applies the v1.4 CRDs and runs kubectl apply --dry-run=server on the output, and you have a gate that proves every change is convertible before it merges.
On the observability side, before you shift any traffic, make sure your new data plane exports the same signals you relied on from ingress-nginx. Envoy-based controllers expose rich per-route metrics, but the metric names differ. Wire up a dashboard for request rate, p99 latency, and 5xx ratio per HTTPRoute and confirm it populates while you smoke-test in Step 6 — discovering your alerts are blind right after cutover is a bad time to find out.
Quick reference: Ingress to Gateway API
| Ingress concept | Gateway API equivalent |
|---|---|
| ingressClassName | GatewayClass + Gateway parentRef |
| host + path rules | HTTPRoute hostnames + matches |
| backend service/port | rules[].backendRefs[] |
| TLS section | Gateway listener with TLS + certificateRefs |
| rewrite-target annotation | URLRewrite filter |
| ssl-redirect annotation | RequestRedirect filter / HTTPS listener |
| canary-weight annotations | backendRefs[].weight (native traffic split) |
| auth-url annotation | Controller SecurityPolicy / extension CRD |
| configuration-snippet | No portable equivalent — re-implement |
Next steps
You now have a repeatable migration loop: inventory annotations, install the v1.4 CRDs and a controller, convert with ingress2gateway 1.0, reconcile the warnings, run in parallel, then shift DNS. Do it once in staging end-to-end before you touch production, and keep ingress-nginx scaled to zero (not deleted) until you have lived on the Gateway for a full traffic cycle.
From here, lean into what Gateway API unlocks that Ingress never did cleanly: native weighted traffic splitting for canaries, header-based routing, request mirroring for shadow testing, and policy attachment for timeouts and retries. Those used to be annotation soup; now they are typed fields you can review in a pull request.
Comments
Be the first to comment