The hard part of leaving ingress-nginx is not the routing rules. It is the annotations. Dozens of them, added over years, each solving a real problem for someone who has since left.
This is the translation table I wish I had started with. It covers ingress-nginx, since that is what most clusters run, mapping to Gateway API core where possible and Envoy Gateway policies where not.
Start with the generated draft
Before translating by hand, run the upstream converter:
ingress2gateway print --providers=ingress-nginx --namespace=team-a > draft.yaml
It handles paths, hosts, TLS, and a handful of annotations. Everything it silently drops is what the rest of this post is about, so diff its output against your Ingress annotations and work the gap.
Routing and rewrites
rewrite-target becomes a URLRewrite filter. The capture-group syntax goes away, which is a mercy:
# nginx.ingress.kubernetes.io/rewrite-target: /$2
# path: /api(/|$)(.*)
rules:
- matches:
- path:
type: PathPrefix
value: /api
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: api
port: 8080
ReplacePrefixMatch replaces exactly the matched prefix. No regex, no $2, no off-by-one on the leading slash.
app-root and permanent-redirect become a RequestRedirect filter:
filters:
- type: RequestRedirect
requestRedirect:
path:
type: ReplaceFullPath
replaceFullPath: /app
statusCode: 301
use-regex with a regex path has no core equivalent: Gateway API supports Exact, PathPrefix, and implementation-specific RegularExpression. Envoy Gateway supports the last one, but check whether you actually needed regex or just a prefix.
ssl-redirect is usually not a route concern at all. Put an HTTP listener on the Gateway whose only route issues a redirect, and keep your real routes on the HTTPS listener.
Canary annotations (canary, canary-weight) become weighted backendRefs: see the previous post. This is the clearest win in the whole migration: one route object instead of two Ingress objects that had to be kept in sync.
Timeouts, retries, buffers
These move to BackendTrafficPolicy and ClientTrafficPolicy.
| Annotation | Where it goes |
|---|---|
proxy-read-timeout, proxy-send-timeout |
BackendTrafficPolicy.timeout.http.requestTimeout |
proxy-connect-timeout |
BackendTrafficPolicy.timeout.tcp.connectTimeout |
proxy-next-upstream* |
BackendTrafficPolicy.retry |
proxy-body-size |
ClientTrafficPolicy connection limits |
load-balance |
BackendTrafficPolicy.loadBalancer |
upstream-hash-by |
BackendTrafficPolicy.loadBalancer.type: ConsistentHash |
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
name: uploads
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: uploads
timeout:
http:
requestTimeout: 300s
loadBalancer:
type: ConsistentHash
consistentHash:
type: SourceIP
One behavioural difference worth catching: ingress-nginx proxy-next-upstream retries on connection errors by default. Envoy retries only what you configure. If you relied on that default, you must now write it down, which is better, but it is a silent change if you do not.
Security: CORS, auth, IP restrictions
All SecurityPolicy.
CORS (enable-cors, cors-allow-origin, cors-allow-methods):
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: api-cors
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: api
cors:
allowOrigins:
- "https://app.example.com"
allowMethods: ["GET", "POST", "OPTIONS"]
allowHeaders: ["authorization", "content-type"]
maxAge: 1h
Note ingress-nginx defaults cors-allow-origin to *. If your annotation only enabled CORS without specifying origins, you were allowing every origin. Do not port that faithfully. Fix it.
External auth (auth-url, auth-signin, auth-response-headers) becomes extAuth:
extAuth:
http:
backendRefs:
- name: auth-service
port: 8080
path: /verify
headersToBackend: ["x-user-id", "x-user-email"]
auth-signin (the redirect-to-login behaviour) has no direct equivalent. If you were using it for browser flows, the right answer is usually Envoy Gateway’s native oidc block rather than an external auth service.
IP allow lists (whitelist-source-range) become authorization:
authorization:
defaultAction: Deny
rules:
- action: Allow
principal:
clientCIDRs: ["10.0.0.0/8", "203.0.113.0/24"]
Check how client IP is being determined. Behind a cloud load balancer you need ClientTrafficPolicy.clientIPDetection with the right numTrustedHops or xForwardedFor config, or you will be matching the load balancer’s IP and wondering why the allow list lets everyone in.
Rate limiting: read this one carefully
limit-rps / limit-connections map to BackendTrafficPolicy.rateLimit, but with a real semantic difference.
ingress-nginx rate limits are per-pod, backed by a shared memory zone per controller replica. Envoy Gateway’s type: Local is also per-pod. So far so equivalent.
But if you want a genuine cluster-wide limit, you need type: Global, which requires the rate limit service and Redis:
rateLimit:
type: Global
global:
rules:
- clientSelectors:
- headers:
- name: x-api-key
type: Distinct
limit:
requests: 1000
unit: Hour
That Distinct selector gives every API key its own bucket. Something ingress-nginx could not express at all. It is worth the Redis dependency if you are rate limiting per tenant.
The ones with no equivalent
Some annotations do not translate because they are NGINX implementation details:
configuration-snippet/server-snippet: raw NGINX config. There is no port. Work out what each snippet does and find the policy equivalent, or useEnvoyPatchPolicyas a last resort. These are also the annotations most likely to contain something load-bearing that nobody documented.backend-protocol: GRPC. Use aGRPCRouteinstead of an HTTPRoute.backend-protocol: HTTPS. Upstream TLS is configured on aBackendresource or viaBackendTLSPolicy.session-cookie-*: cookie-based session affinity; use consistent hashing on a header or cookie viaBackendTrafficPolicy.enable-modsecurity: no WAF built in. Either a Wasm extension viaEnvoyExtensionPolicy, or a WAF in front of the gateway.
Do this before you cut over
Dump every annotation actually in use, so you are working from evidence rather than memory:
kubectl get ingress -A -o json \
| jq -r '.items[].metadata.annotations // {} | keys[]' \
| grep '^nginx.ingress' | sort | uniq -c | sort -rn
The counts tell you what to prioritise. In most clusters the long tail is a handful of annotations on one service each, and roughly a third of them turn out to be doing nothing: copied from a template, or protecting against a problem that no longer exists.
Delete those rather than translating them. A migration is a rare licence to remove config nobody can justify.