The Kubernetes project announced the retirement of ingress-nginx. ingress-nginx retires in March 2026. No more patches, no more updates. No successor was named — just a recommendation to migrate to Gateway API.
The reason is straightforward: not enough maintainers. For years, effectively one or two people had kept the project running in their spare time. After the retirement announcement, there were plans to develop a successor controller (InGate) in collaboration with the Gateway API community, but no contributors came forward, and InGate was retired as well. No successor was named because none could be named.
Existing deployments won't break overnight. Helm charts and container images remain usable. But security patches stop. When new vulnerabilities are found, they'll go unfixed. The system keeps running while the risk accumulates slowly.
ingress-nginx had a kind of self-evidence: "if you need HTTP in Kubernetes, use this." Gateway API dissolves that.
This article covers how to choose a Gateway API implementation and how to migrate ingress-nginx annotations by sorting them into three categories — with before/after manifests throughout.
What "migrate to Gateway API" actually means
Gateway API is a Kubernetes specification. It is not software.
In the Ingress era, HTTP routing configuration looked like this:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ingress
spec:
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 8080
Apply this YAML and it worked. Assuming ingress-nginx was installed in the cluster — but since "using Kubernetes Ingress" and "using ingress-nginx" were nearly synonymous, that distinction rarely came up. Spec and implementation mapped almost one-to-one.
Gateway API is different. Writing an HTTPRoute does nothing unless you have separate software installed that reads those resources and actually routes requests.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-route
spec:
parentRefs:
- name: my-gateway
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: my-service
port: 8080
Apply this YAML without a Gateway controller installed in the cluster, and nothing happens. Gateway API is a spec defining what resources exist — it's not a process that handles traffic.
"Migrating to Gateway API" meant picking an implementation and installing it. The question of which software was actually running — obvious in the Ingress world — is now something you have to decide yourself.
Too many implementations
"Pick one" sounds simple, but the options are many. In the Envoy-based family alone: Envoy Gateway, kgateway, Istio, Contour, Cilium. Outside Envoy: Traefik, Kong, NGINX Gateway Fabric. Cloud providers each offer their own. And all of them claim "Gateway API conformant."
It's tempting to think: "if they're all conformant, they'll behave the same within the standard, and I can switch later." That doesn't hold.
Three reasons "conformant" isn't uniform
First, conformance levels vary. Gateway API features are divided into three tiers: Core, Extended, and Implementation-specific. Core is the minimum every implementation must support. Extended is "recommended but optional." Implementation-specific is the open space for vendor extensions. Claiming "Gateway API conformant" only requires Core support. Extended and above vary across implementations.
Second, the features you actually need in production tend to land in vendor extensions. Auth, rate limiting, circuit breaking — these commonly belong to Extended or Implementation-specific. Envoy Gateway uses a SecurityPolicy CRD; Istio uses AuthorizationPolicy. The moment you write one of those resources, you're locked into that implementation.
Third, there's no default. When ingress-nginx was around, there was a clear first choice. Now there isn't. Every team is being forced to make that choice simultaneously, without a settled answer.
The first pick carries weight. The comparison below shows why.
Comparing the major implementations
| Envoy Gateway | Istio | Traefik | NGINX Gateway Fabric | |
|---|---|---|---|---|
| Data plane | Envoy | Envoy | Traefik | nginx |
| Service mesh support | Partial | Full | None | None |
| Migration ease from ingress-nginx | Good | Moderate | Good | Excellent |
| Extended feature coverage | Excellent | Excellent | Good | Good |
| Development momentum | Excellent | Excellent | Good | Moderate |
Istio is the only one with full service mesh support — east-west traffic control and sidecar injection included. The other three have no mesh capabilities. NGINX Gateway Fabric scores highest on migration ease because it shares the same nginx data plane. Istio scores lower because installing the entire service mesh stack is a prerequisite. The difference in Extended feature coverage becomes concrete once you read the Category B before/after examples. For development momentum, Envoy Gateway (CNCF incubating) and Istio (CNCF graduated) lead, while NGINX Gateway Fabric has a lower release cadence.
No recommendation here — the right answer depends on whether you already have a service mesh, how you weigh the annotation rewrite cost, and whether your team has Envoy operational experience. But before picking an implementation, there's work to do: inventory your current ingress-nginx annotations.
Sorting annotations into three categories
The before/after manifests and validation scripts used in this article are at shinagawa-web/ingress-nginx-to-gateway-api. before/ holds the ingress-nginx manifests, after/category-a/ the standard Gateway API equivalents, and after/category-b-envoy-gateway/ and after/category-b-istio/ the implementation-specific ones.
Envoy Gateway and Istio were chosen for the Category B manifests because they have broad coverage of features that can't be expressed in the standard spec — external auth, rate limiting, and so on. Traefik and NGINX Gateway Fabric can handle basic routing just as well, but their vendor extensions are less mature. Putting Envoy-based (Envoy Gateway) next to service-mesh-capable (Istio) also makes the architectural difference visible. Links to the relevant files appear at the end of each category section so you can run them locally as you read.
The goal is to inventory every annotation you have and sort each into one of three categories. The approach differs fundamentally by category. Note that Category C is evaluated line by line within the annotation, not annotation by annotation. A single configuration-snippet can contain lines that fall into Category A and lines that require Category B.
Category A: migrates directly to standard Gateway API
rewrite
A rewrite is the process of rewriting the URL path before the request reaches the backend. If a client hits /api/hello and the app should receive /hello, that's a rewrite. In ingress-nginx, you'd use regex capture groups.
# before/02-rewrite.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: rewrite
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/use-regex: "true"
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: echo-v1
port:
number: 8080
The pattern /api(/|$)(.*) captures the second group ($2) and forwards it as the path. A request to /api/hello puts hello into $2, delivering /hello to the backend.
Gateway API expresses the same thing with the URLRewrite filter in HTTPRoute. (URLRewrite is an Extended-tier feature — not Core. Verify your chosen implementation supports it; the four implementations in this article's comparison table all do.)
# after/category-a/02-rewrite.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: rewrite
spec:
parentRefs:
- name: eg
rules:
- matches:
- path:
type: PathPrefix
value: /api
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: echo-v1
port: 8080
Read it as: replace the /api prefix with /. No regex — prefix replacement is explicit.
canary
Canary deployment means routing a small percentage of traffic to a new version, then gradually increasing it rather than doing an immediate cutover. In ingress-nginx, this required two Ingress resources: a primary route to v1 and a canary route to v2, with the canary's weight set in an annotation.
# before/03-canary.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: canary-primary
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: echo-v1
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: canary
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "20"
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: echo-v2
port:
number: 8080
Two Ingress resources define the same host: app.example.com and path /. Normally that's a conflict, but ingress-nginx treats any Ingress with canary: "true" as a weighted companion to the other. canary-weight: "20" sends 20% of traffic to echo-v2.
One routing decision split across two resources is the problem. Reading canary-primary alone gives you no visibility into the actual traffic split.
Gateway API fits it into a single HTTPRoute.
# after/category-a/03-canary.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: canary
spec:
parentRefs:
- name: eg
rules:
- backendRefs:
- name: echo-v1
port: 8080
weight: 80
- name: echo-v2
port: 8080
weight: 20
List v1 and v2 in backendRefs with weight ratios. 80:20 sends 80% to v1, 20% to v2. One resource, full picture.
The conversions in this category can be automated with the Ingress2Gateway CLI (1.0 GA). Worth using to reduce manual work.
- before/02-rewrite.yaml → after/category-a/02-rewrite.yaml, before/03-canary.yaml → after/category-a/03-canary.yaml
Category B: requires implementation-specific extensions
external auth
External auth delegates authentication decisions to a separate service rather than the application itself. On each incoming request, the proxy asks the auth service whether to allow it. Only when the auth service responds OK does the request proceed to the app. Because the auth logic lives outside the app, the same auth service can cover multiple apps.
In ingress-nginx, two annotation lines did it.
# before/04-auth.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: auth
annotations:
nginx.ingress.kubernetes.io/auth-url: http://auth-service.default.svc.cluster.local/auth
nginx.ingress.kubernetes.io/auth-response-headers: X-Auth-User
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /protected
pathType: Prefix
backend:
service:
name: echo-v1
port:
number: 8080
auth-url is the auth service endpoint. auth-response-headers lists headers from the auth service response to forward to the backend. When the auth service returns 200, the specified headers are added to the upstream request. Two annotation lines cover delegation and response propagation.
The sample repository includes a mock auth service in app/auth-service.yaml — a Python server that returns 200 for Authorization: Bearer valid-token and 401 for everything else.
With Envoy Gateway, HTTPRoute alone has no place to write auth configuration. You create a separate SecurityPolicy CRD — an Envoy Gateway-specific resource — and bind it to the target HTTPRoute.
# after/category-b-envoy-gateway/04-auth.yaml (excerpt)
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: auth
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: protected
extAuth:
http:
backendRefs:
- group: ""
kind: Service
name: auth-service
port: 80
headersToBackend:
- x-auth-user
targetRefs specifies which HTTPRoute the policy applies to — here, the HTTPRoute named protected. extAuth.http.backendRefs points to the auth service.
The intent is clearer than in ingress-nginx. Annotations were scoped to their Ingress resource, so tracking which route had which auth policy meant reading every Ingress. SecurityPolicy stands as an independent resource with its target made explicit via targetRefs.
To forward X-Auth-User from the auth service response to the backend, headersToBackend must be configured explicitly. Unlike ingress-nginx's auth-response-headers, Envoy Gateway does not forward auth response headers automatically — only the headers listed in headersToBackend are propagated.
Migrating to Istio changes the architecture. AuthorizationPolicy evaluates request attributes locally — headers, JWT claims, source principals — rather than calling out to an external service.
# after/category-b-istio/04-auth.yaml (excerpt)
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: auth
spec:
selector:
matchLabels:
app: echo
version: v1
action: DENY
rules:
- when:
- key: request.headers[authorization]
notValues:
- "Bearer valid-token"
action: DENY means "deny requests that match the condition." Requests where request.headers[authorization] is not Bearer valid-token are denied — only valid-token requests get through. Note that selector.matchLabels scopes the policy to the workload, not to the /protected path — the DENY rule applies to all traffic reaching the matched pods. To restrict it to a specific path, add to.operation.paths to the rule. Where Envoy Gateway's extAuth asks an external service for an OK/NG decision, Istio's AuthorizationPolicy evaluates the policy inside the cluster. The mechanism is fundamentally different from ingress-nginx's auth-url callback model, so migrating to Istio means rethinking the auth design.
AuthorizationPolicy evaluates request attributes locally. For logic that requires an external decision — like checking permissions against a database — Istio provides AuthorizationPolicy with action: CUSTOM plus an extensionProviders entry in meshConfig. This is the proper external auth delegation path in Istio, comparable in ergonomics to Envoy Gateway's extAuth. The EnvoyFilter route can also be used but writes Envoy internals directly.
Category A works the same way regardless of which implementation you choose. Category B changes fundamentally depending on which one you picked. This is the "operational features drift into vendor extensions" reality the comparison table pointed to: you can't form a migration plan for this category until you've chosen an implementation.
- before/04-auth.yaml → after/category-b-envoy-gateway/04-auth.yaml / after/category-b-istio/04-auth.yaml
rate limit
ingress-nginx's rate limit was self-contained in annotations, but there's a trap: queueing behavior changes after migration.
# before/05-rate-limit.yaml (excerpt)
annotations:
nginx.ingress.kubernetes.io/limit-rps: "2"
nginx.ingress.kubernetes.io/limit-burst-multiplier: "1"
limit-rps: "2" allows at most 2 requests per second per IP. Requests over the limit don't get rejected immediately — they queue up to the limit set by limit-burst-multiplier. With limit-burst-multiplier: "1", the queue cap is limit-rps × 1 = 2. Once the queue fills, subsequent requests get a 503 immediately.
Envoy Gateway uses BackendTrafficPolicy. Like SecurityPolicy for external auth, it's a separate resource from HTTPRoute, with targetRefs pointing at the target.
# after/category-b-envoy-gateway/05-rate-limit.yaml (excerpt)
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: rate-limit
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: rate-limited
rateLimit:
type: Local
local:
rules:
- limit:
requests: 2
unit: Second
requests: 2, unit: Second expresses 2 requests per second, but there's no field corresponding to limit-burst-multiplier. The queueing behavior can't be reproduced.
Istio configures the token bucket directly via EnvoyFilter. There's no abstraction layer like BackendTrafficPolicy — you're writing Envoy internals directly.
# after/category-b-istio/05-rate-limit.yaml (excerpt)
token_bucket:
max_tokens: 2
tokens_per_fill: 2
fill_interval: 1s
tokens_per_fill: 2, fill_interval: 1s replenishes 2 tokens per second (= 2 req/s). max_tokens: 2 is the burst cap — the maximum number of tokens the bucket can hold. This corresponds to ingress-nginx's limit-rps: "2", limit-burst-multiplier: "1". The algorithm differs from nginx's queuing model, but the behavior — 2 requests per second, burst cap of 2 — is equivalent. Whether reproducing limit-burst-multiplier is worth trading against implementation choice is a judgment call that varies by team.
- before/05-rate-limit.yaml → after/category-b-envoy-gateway/05-rate-limit.yaml / after/category-b-istio/05-rate-limit.yaml
Category C: no equivalent
configuration-snippet
Probably the most useful annotation ingress-nginx had — and the most problematic.
nginx config has server blocks and location blocks. configuration-snippet lets you inject nginx directives directly into the location block, overriding or extending what ingress-nginx generates. It was effectively an escape hatch for anything the standard annotations couldn't express.
# before/06-configuration-snippet.yaml (excerpt)
annotations:
nginx.ingress.kubernetes.io/configuration-snippet: |
more_set_headers "X-Custom-Header: my-value";
more_set_headers "X-Request-ID: $request_id";
more_set_headers is a directive from the headers-more module bundled with ingress-nginx, used to add or override response headers. "X-Custom-Header: my-value" adds a static header. "X-Request-ID: $request_id" attaches nginx's built-in variable $request_id — a unique ID auto-generated per request — as a response header.
Gateway API has no direct equivalent. But saying "no equivalent" oversimplifies it. Break down the annotation line by line, and the picture splits.
Adding a static header like X-Custom-Header migrates to HTTPRoute's ResponseHeaderModifier filter. That's standard spec — Category A.
# after/category-a/06-response-header.yaml (excerpt)
filters:
- type: ResponseHeaderModifier
responseHeaderModifier:
add:
- name: X-Custom-Header
value: my-value
Dynamic values like $request_id are a different story. Nothing in the Gateway API standard covers referencing nginx built-in variables. With Envoy Gateway, you can reproduce it by injecting a Lua script via the EnvoyPatchPolicy CRD — Category B.
# after/category-b-envoy-gateway/06-request-id.yaml (excerpt)
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyPatchPolicy
metadata:
name: request-id-lua
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: eg
type: JSONPatch
jsonPatches:
- type: "type.googleapis.com/envoy.config.listener.v3.Listener"
name: "default/eg/http"
operation:
op: add
path: "/default_filter_chain/filters/0/typed_config/http_filters/0"
value:
name: envoy.filters.http.lua
typed_config:
"@type": "type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua"
default_source_code:
inline_string: |
function envoy_on_request(request_handle)
local req_id = request_handle:headers():get("x-request-id")
if req_id then
request_handle:streamInfo():dynamicMetadata():set("lua_ns", "req_id", req_id)
end
end
function envoy_on_response(response_handle)
local meta = response_handle:streamInfo():dynamicMetadata():get("lua_ns")
if meta and meta["req_id"] then
response_handle:headers():add("x-request-id", meta["req_id"])
end
end
Envoy generates an x-request-id header automatically on incoming requests. The Lua script reads it during request processing, stores it in metadata, then attaches it as a response header during response processing. nginx's $request_id becomes Envoy's request ID, but the intent — a unique per-request ID on the response header — is the same.
The same Lua logic works in Istio via EnvoyFilter, but the apply context and metadata namespace differ.
# after/category-b-istio/06-request-id.yaml (excerpt)
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: request-id-response-header
spec:
configPatches:
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
subFilter:
name: envoy.filters.http.router
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
default_source_code:
inline_string: |
function envoy_on_request(request_handle)
local req_id = request_handle:headers():get("x-request-id")
if req_id then
request_handle:streamInfo():dynamicMetadata():set("lua_metadata", "req_id", req_id)
end
end
function envoy_on_response(response_handle)
local meta = response_handle:streamInfo():dynamicMetadata():get("lua_metadata")
if meta and meta["req_id"] then
response_handle:headers():add("x-request-id", meta["req_id"])
end
end
The Envoy Gateway version uses EnvoyPatchPolicy with JSONPatch to insert the Lua filter at the Listener level, with state stored under metadata namespace lua_ns. The Istio version uses EnvoyFilter with applyTo: HTTP_FILTER and match.context: GATEWAY, with state stored under lua_metadata. The Lua logic is the same, but the configuration layer differs.
What truly has no equivalent in Category C is nginx-specific functionality that EnvoyPatchPolicy can't reach — directives that depend deeply on nginx internals, or snippets that assume concepts Envoy doesn't have. In practice, most configuration-snippet contents, when broken down line by line, land in Category A or Category B. The annotation as a whole isn't Category C — each line has to be read and evaluated individually.
- before/06-configuration-snippet.yaml → after/category-a/06-response-header.yaml (X-Custom-Header) / after/category-b-envoy-gateway/06-request-id.yaml (X-Request-ID, Envoy Gateway) / after/category-b-istio/06-request-id.yaml (X-Request-ID, Istio)
Standardization didn't make migration easier
Gateway API gave us a unified spec, so "the interface is now shared." But more implementations means more decisions. The Core tier is consistent across implementations. But operational features drift into vendor extensions, so locking in to an implementation comes after, not before.
The frustration with ingress-nginx — "settings are scattered across annotations, hard to see the structure" — was a real problem. Gateway API was designed to address it. But from the migration side, new decisions piled on: which software to pick, which annotations map to the standard and which need vendor extensions and which get dropped.
"Migrate to Gateway API" wasn't a work order. It compressed two separate jobs into one phrase: choose an implementation, then classify each annotation into one of three categories and handle each accordingly.
Top comments (0)