| Tested on | RHEL 10.2 (Coughlan) workstation |
|---|---|
| Package | kubectl 1.37.0 (server 1.33.1)ingress-nginx controller v1.15.1 (chart 4.15.1)Envoy Gateway v1.9.1Gateway API CRDs v1.6.1 (experimental channel)ingress2gateway 1.0.0 |
| Applies to | Any host with kubectl configured; any Kubernetes cluster that already runs ingress-nginx and Envoy Gateway |
| Lab environment | Both controllers already installed, with a GatewayClass named eg |
| Privilege | Normal user |
| Scope | Why rewrite-target and regex paths do not always send the same path to the backend Service on Envoy Gateway, how a match differs from a rewrite, and how to replace a prefix or expand a capture group. Does not cover installing the controllers, authentication, or header and redirect annotations. |
| Related guides | Migrate ingress-nginx to Envoy Gateway Kubernetes Gateway API Kubernetes Ingress rules Kubernetes Services |
In ingress-nginx, nginx.ingress.kubernetes.io/rewrite-target changes the URL path that nginx sends to the backend Service. It does not decide which backend should receive the request; the Ingress path does that.
Path matching and path rewriting solve different problems. A match decides whether /files/hostname belongs to this route. A rewrite decides whether the backend receives /files/hostname, /hostname, /, or something else.
That distinction matters during migration because a route can match successfully and still send the wrong path to the application. An HTTP 200 from Envoy is not enough to prove the rewrite is correct. The backend may successfully answer the wrong path.
The examples reuse the setup from the ingress-nginx to Envoy Gateway migration. ingress-nginx is reachable on 127.0.0.1:18080, and Envoy Gateway is reachable on 127.0.0.1:19080.
The web Service returns the request path it receives as its response body. That makes a bad rewrite easy to see: if the backend receives /$2, the response body is literally /$2.
The generated Gateway is named nginx, but its gatewayClassName must be changed to eg before Envoy Gateway will manage it.
Client requests /files/hostname
↓
HTTPRoute match accepts or rejects the path
↓
Rewrite sets the path the backend receives
↓
Service web returns that pathWhat rewrite-target sends to the backend
The two common rewrite patterns are a fixed target and a regex rewrite that keeps part of the original path. One sends /shop/... to /. The other sends /files/... to /.... The Ingress path decides whether the request matches this rule. rewrite-target decides which path is sent to the backend after that match.
| Ingress rule | Request | Backend receives |
|---|---|---|
path: /shop and rewrite-target: / |
/shop/hostname |
/ |
regex and rewrite-target: /$2 |
/files/hostname |
/hostname |
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: prefix-rewrite
namespace: shop
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: shop.example.test
http:
paths:
- path: /shop
pathType: Prefix
backend:
service:
name: web
port:
number: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: regex-rewrite
namespace: shop
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/use-regex: "true"
spec:
ingressClassName: nginx
rules:
- host: files.example.test
http:
paths:
- path: /files(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: web
port:
number: 8080Request /shop/hostname on the prefix Ingress. The body is the path web received:
curl -sS --resolve shop.example.test:18080:127.0.0.1 http://shop.example.test:18080/shop/hostname/rewrite-target: / replaced the whole path. The suffix hostname is gone. From ingress-nginx 0.22 onward that is the normal behavior unless the target uses a capture group.
The regex Ingress keeps the part after /files/. /$2 puts that remainder back on a leading slash.
A capture group is the part of a regular expression enclosed in parentheses. nginx can reuse the text matched by those groups in rewrite-target as $1, $2, and so on:
- Group 1 is the slash, or the end of the path
- Group 2 is the remainder
curl -sS --resolve files.example.test:18080:127.0.0.1 http://files.example.test:18080/files/hostname/hostnameNormally, a Kubernetes Prefix path /shop does not match /shopping. Prefix matching is element by element. /shop matches these paths:
/shop/shop//shop/hostname
It does not match /shopping, because shopping is not a child of shop. In this ingress-nginx configuration, rewrite-target causes the controller to render that host's paths as case-insensitive regular-expression locations. /shopping reaches this route for that reason, and the rewrite still replaces the entire path:
curl -sS --resolve shop.example.test:18080:127.0.0.1 http://shop.example.test:18080/shopping/The body is /, so /shopping was accepted by this route and then rewritten away.
These paths are also case-insensitive in this ingress-nginx configuration. If rewrite-target or use-regex is used for a host, ingress-nginx applies case-insensitive regex matching to all paths for that host:
/Shop/hostnamematches the same route as/shop/hostname/Files/hostnamematches the same route as/files/hostname/other/hostnamematches neither rule, so ingress-nginx returns its default-backend 404
A match is not a rewrite
Keep the two steps separate when you read a generated HTTPRoute.
- The route match decides which requests enter the rule. That includes the path type, the path value, and, for regular expressions, details such as case-insensitive matching with
(?i). - The rewrite runs only after a match. It sets the path the backend Service sees.
- A route can match and still send the wrong path. That is what a literal
/$2looks like.
Core Gateway API gives you two path rewrites on an HTTPRoute URLRewrite filter. For a request to /shop/hostname:
| Rewrite | Replacement | Backend receives |
|---|---|---|
ReplaceFullPath |
/ |
/ |
ReplacePrefixMatch |
/ |
/hostname |
What each field does:
ReplaceFullPathsets one fixed path. It does not expand$1or$2.ReplacePrefixMatchreplaces only the matched prefix and preserves the remaining path.- Neither performs regex capture-group substitution. Capture groups need an Envoy Gateway
HTTPRouteFilter.
HTTPRouteFilter is an Envoy Gateway-specific extension, not a core Gateway API resource. An HTTPRoute attaches it through an ExtensionRef filter.
ReplacePrefixMatch / on /shop/hostname works like this:
/shop/hostname
↓
ReplacePrefixMatch: /
↓
/hostnameWhat ingress2gateway prints for these paths
Confirm the binary before you read the YAML. The 1.0.0 release I used still prints dev:
ingress2gateway versioningress2gateway version: dev
Built with Go version: go1.25.8Print the Gateway API objects for the shop namespace. The important part is the generated rewrite for the regex Ingress. Instead of expanding $2, ingress2gateway 1.0.0 writes it as a fixed ReplaceFullPath value. Warnings go to stderr:
ingress2gateway print --providers=ingress-nginx --namespace=shop --no-color┌─ WARN ────────────────────────────────────────
│ Ingress NGINX serves TLS traffic for host "files.example.test" with a self-signed certificate. This behavior will not be translated and the host will not be accessible via HTTPS.
│ source: INGRESS-NGINX
└─
┌─ WARN ────────────────────────────────────────
│ Ingress NGINX serves TLS traffic for host "shop.example.test" with a self-signed certificate. This behavior will not be translated and the host will not be accessible via HTTPS.
│ source: INGRESS-NGINX
└─
┌─ INFO ────────────────────────────────────────
│ Using case-insensitive regex path matches. You may want to change this.
│ source: INGRESS-NGINX
│ object: HTTPRoute: shop/regex-rewrite-files-example-test
└─
┌─ INFO ────────────────────────────────────────
│ Using case-insensitive regex path matches. You may want to change this.
│ source: INGRESS-NGINX
│ object: HTTPRoute: shop/prefix-rewrite-shop-example-test
└─
┌─ WARN ────────────────────────────────────────
│ Gateway API does not support configuring URL normalization (RFC 3986, Section 6). Please check if this matters for your use case and consult implementation-specific details.
│ source: STANDARD_EMITTER
└─Do not ignore these messages. They reveal behavior that may change during the migration.
- The TLS warning appears even though these Ingresses have no
tlsblock. ingress-nginx can still serve its default certificate. This page stays on HTTP. - Both routes, including the prefix that never set
use-regex, are case-insensitive regular expressions. The INFO line is telling you to review that. - Nothing in this report mentions
$2.
There is an important difference between the documented behavior and the 1.0.0 release binary:
- The provider documentation says capture-group references such as
$2are unsupported and should be reported. - In this test, 1.0.0 generated
replaceFullPath: /$2without a capture-group warning.
Verify the generated route itself, not only the warning report.
This exact warning behavior is specific to the 1.0.0 binary I tested. ingress2gateway 1.2.0 includes a fix for regex-rewrite warnings, so newer versions may report this case differently. The provider documentation still says capture-group references should be flagged. Review the warnings from the version you run.
I also ran the same print with --emitter=envoy-gateway. The HTTPRoutes were identical. The Envoy emitter did not add an HTTPRouteFilter.
The generated Gateway uses gatewayClassName: nginx. Change that to eg or Envoy will not program the routes. These are the two HTTPRoutes, with the empty status blocks removed:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
annotations:
gateway.networking.k8s.io/generator: ingress2gateway-dev
name: prefix-rewrite-shop-example-test
namespace: shop
spec:
hostnames:
- shop.example.test
parentRefs:
- name: nginx
rules:
- backendRefs:
- name: web
port: 8080
filters:
- type: URLRewrite
urlRewrite:
path:
replaceFullPath: /
type: ReplaceFullPath
matches:
- path:
type: RegularExpression
value: (?i)/shop.*
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
annotations:
gateway.networking.k8s.io/generator: ingress2gateway-dev
name: regex-rewrite-files-example-test
namespace: shop
spec:
hostnames:
- files.example.test
parentRefs:
- name: nginx
rules:
- backendRefs:
- name: web
port: 8080
filters:
- type: URLRewrite
urlRewrite:
path:
replaceFullPath: /$2
type: ReplaceFullPath
matches:
- path:
type: RegularExpression
value: (?i)/files(/|$)(.*).*The fixed rewrite preserves the original nginx behavior. /shop/hostname still arrives as /. The capture-group rewrite does not: ingress2gateway generated ReplaceFullPath: /$2, so Envoy sends the literal string /$2 upstream. Request that URL through Envoy while the generated route is in place:
curl -sS --resolve files.example.test:19080:127.0.0.1 http://files.example.test:19080/files/hostname/$2The status is 200. The backend received the characters $2, not the captured suffix. ReplaceFullPath is a fixed string. Envoy does not treat $2 as an nginx capture.
The generated shop expression (?i)/shop.* also matches /shopping and replaces the whole path, so Envoy returns / for that URL, the same body ingress-nginx returned.
Keep the path suffix with ReplacePrefixMatch
Do not use ReplacePrefixMatch merely because you are migrating rewrite-target: /. It changes the behavior.
The difference is easiest to see with the same request, /shop/hostname:
- Original
rewrite-target: /sends/ ReplacePrefixMatch: /sends/hostname
Use ReplacePrefixMatch when the application expects that suffix. Use ReplaceFullPath / when you want the original rewrite-target: / result.
Gateway API PathPrefix matches element by element. /shop matches /shop and /shop/hostname. It does not match /shopping.
Replace the generated shop route with this HTTPRoute. Apply it in the next section, then run the shop checks. That file contains:
- this prefix HTTPRoute
- the capture-group
HTTPRouteFilter - the files HTTPRoute
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: prefix-rewrite-shop-example-test
namespace: shop
spec:
hostnames:
- shop.example.test
parentRefs:
- name: nginx
rules:
- matches:
- path:
type: PathPrefix
value: /shop
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: web
port: 8080Expand capture groups with an Envoy filter
Core Gateway API URLRewrite supports ReplaceFullPath and ReplacePrefixMatch, but it does not support regex capture-group substitution. Envoy Gateway adds that capability through HTTPRouteFilter and ReplaceRegexMatch:
- Core fields:
ReplaceFullPathandReplacePrefixMatch - Envoy extension:
ReplaceRegexMatchonHTTPRouteFilter(gateway.envoyproxy.io/v1alpha1) - Pattern language: RE2
ingress-nginx rewrite-target: /$2
↓
HTTPRouteFilter ReplaceRegexMatch, substitution /\1The two regex patterns use different capture groups, so the group number changes:
- In the original nginx regex, group 1 is
(/|$), the slash or the end of the path - In the original nginx regex, group 2 is the remainder, so the target is
$2 - In the new Envoy regex, only the remainder is captured, so it is group 1 and the substitution is
\1 - That is not a rule that nginx
$2always becomes Envoy\1
(?i) keeps the case-insensitive match from the INFO line above.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: HTTPRouteFilter
metadata:
name: files-capture-rewrite
namespace: shop
spec:
urlRewrite:
path:
type: ReplaceRegexMatch
replaceRegexMatch:
pattern: '(?i)^/files(?:/|$)(.*)$'
substitution: '/\1'
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: regex-rewrite-files-example-test
namespace: shop
spec:
hostnames:
- files.example.test
parentRefs:
- name: nginx
rules:
- matches:
- path:
type: RegularExpression
value: (?i)/files(/|$)(.*)
filters:
- type: ExtensionRef
extensionRef:
group: gateway.envoyproxy.io
kind: HTTPRouteFilter
name: files-capture-rewrite
backendRefs:
- name: web
port: 8080ExtensionRef tells the HTTPRoute to run an implementation-specific filter. Here it points to the Envoy Gateway HTTPRouteFilter named files-capture-rewrite.
ReplaceRegexMatch rewrites the part of the path matched by the regex. Anchoring the pattern with ^ and $ makes this example operate on the whole request path.
Apply the prefix route from the previous section together with this filter and route. The HTTPRoutes already exist, so apply reports configured. The filter is new:
kubectl apply -f shop-rewrite-fix.yamlhttproute.gateway.networking.k8s.io/prefix-rewrite-shop-example-test configured
httproutefilter.gateway.envoyproxy.io/files-capture-rewrite created
httproute.gateway.networking.k8s.io/regex-rewrite-files-example-test configuredBoth routes should be accepted, and the references on each route should resolve. Select the conditions by type so the columns do not depend on list order:
kubectl -n shop get httproute -o \
custom-columns='NAME:.metadata.name,ACCEPTED:.status.parents[*].conditions[?(@.type=="Accepted")].status,RESOLVED:.status.parents[*].conditions[?(@.type=="ResolvedRefs")].status'NAME ACCEPTED RESOLVED
prefix-rewrite-shop-example-test True True
regex-rewrite-files-example-test True TrueRead the two columns separately:
Accepted=Truemeans Envoy accepted the HTTPRouteResolvedRefs=Truemeans the references on the route resolved, including thewebService and, on the regex route, theHTTPRouteFilter
/shop/hostname now keeps the suffix. This is ReplacePrefixMatch, not the original rewrite-target: / behavior:
curl -sS --resolve shop.example.test:19080:127.0.0.1 http://shop.example.test:19080/shop/hostname/hostname/shopping no longer matches, so Envoy returns an empty 404 instead of the / that ingress-nginx sent:
curl -sS -D - -o /dev/null --resolve shop.example.test:19080:127.0.0.1 http://shop.example.test:19080/shoppingHTTP/1.1 404 Not Found
content-length: 0/Shop/hostname is a separate miss. PathPrefix is case-sensitive, and this route no longer has the (?i) expression the converter printed. The response is again 404 with content-length: 0.
/files/hostname now matches the nginx result:
curl -sS --resolve files.example.test:19080:127.0.0.1 http://files.example.test:19080/files/hostname/hostname/files with nothing after it leaves the capture empty, so the upstream path is /. That is the same body ingress-nginx returned for rewrite-target: /$2 on this URL:
curl -sS --resolve files.example.test:19080:127.0.0.1 http://files.example.test:19080/files//Files/hostname still works because both the match and the filter pattern use (?i):
curl -sS --resolve files.example.test:19080:127.0.0.1 http://files.example.test:19080/Files/hostname/hostnameCase and path boundaries
The converter kept ingress-nginx's case-insensitive locations by printing (?i). Remove (?i) from the files match, leaving /files(/|$)(.*), and the capital path misses. The body was empty:
curl -sS -w '\n%{http_code}\n' \
--resolve files.example.test:19080:127.0.0.1 \
http://files.example.test:19080/Files/hostname404The blank line is the empty body, and 404 is the status. Put (?i) back on that match so /Files/hostname returns /hostname again.
Once you replace those ingress-nginx regex locations, these matching differences become important:
PathPrefix/shopdoes not match/shopping. The generated expression(?i)/shop.*does. ingress-nginx also matched/shoppingin this example becauserewrite-targetcaused the host's paths to be rendered as case-insensitive regex locations.PathPrefixand a regular expression without(?i)are case-sensitive./Shop/hostnamethen misses.- A body of
/or/$2with HTTP 200 means the route matched and the rewrite sent that path. The proxy did not miss the request.
The URL-normalization warning is separate. Gateway API does not copy nginx's RFC 3986 normalization. If the app depends on encoded dots or slashes being merged before the rewrite, check those URLs on both controllers. These examples use plain paths only.
Compare ingress-nginx and Envoy rewrite behavior
The edited routes solve two different problems:
- Shop rows use
ReplacePrefixMatch, which keeps the suffix. That is not a copy ofrewrite-target: /. - Files rows use
ReplaceRegexMatch. That is the copy ofrewrite-target: /$2.
| Request | ingress-nginx | Generated Envoy route | Edited Envoy route |
|---|---|---|---|
/shop/hostname |
/ |
/ |
/hostname |
/Shop/hostname |
/ |
/ |
404, empty body |
/shopping |
/ |
/ |
404, empty body |
/shop |
/ |
/ |
/ |
/files/hostname |
/hostname |
/$2 |
/hostname |
/Files/hostname |
/hostname |
/$2 |
/hostname |
/files |
/ |
/$2 |
/ |
/other/hostname |
404 HTML | 404, empty body | 404, empty body |
An unmatched path on ingress-nginx is the default backend page:
curl -sS -D - --resolve files.example.test:18080:127.0.0.1 http://files.example.test:18080/other/hostnameHTTP/1.1 404 Not Found
Content-Type: text/html
Content-Length: 146ingress-nginx returns its default HTML 404 page. The same URL on Envoy is an empty 404, because no route matched:
curl -sS -D - -o /dev/null \
--resolve files.example.test:19080:127.0.0.1 \
http://files.example.test:19080/other/hostnameHTTP/1.1 404 Not Found
content-length: 0Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Backend receives /$2 and returns 200 |
ReplaceFullPath copied the capture token as a fixed path |
Use HTTPRouteFilter ReplaceRegexMatch and a \1 substitution |
/shop/hostname hits the app at / |
rewrite-target: / and the generated ReplaceFullPath replace the whole path |
Keep that rewrite if / is what nginx sent. Use ReplacePrefixMatch only when the suffix must survive |
/shopping works on ingress-nginx and 404s on Envoy |
PathPrefix /shop does not match /shopping. The generated (?i)/shop.* expression does. ingress-nginx matched it here because rewrite-target rendered that host's paths as case-insensitive regex locations |
Keep the regular expression if that URL must stay on this route |
/Shop or /Files works on ingress-nginx and 404s on Envoy |
The new match is case-sensitive | Keep the (?i) match the converter printed, on both the HTTPRoute and the filter pattern |
| Empty 404, where nginx returned an HTML page | No HTTPRoute matched the host and path | Compare the match, including case and /shopping versus /shop |
HTTP 200, but the body is / or /$2 |
The route matched and the rewrite sent that path | Read the body before you treat the status as success. ReplacePrefixMatch or ReplaceRegexMatch is the change if the app expected the suffix |
Summary
rewrite-target: / replaces the entire upstream path. For that behavior, ReplaceFullPath: / is the direct Gateway API equivalent. Use ReplacePrefixMatch only when you intentionally want to preserve the suffix.
A target such as /$2 is different because it depends on a regex capture group. Core Gateway API cannot expand that capture. On Envoy Gateway, use an HTTPRouteFilter with ReplaceRegexMatch and a substitution that matches the capture groups in your new regex.
Compare the path the backend actually receives. A 200 response only proves that the route matched and the backend answered. It does not prove the rewrite is correct. Case sensitivity and path-boundary behavior can also change when you replace ingress-nginx regex locations with PathPrefix.
References
- ingress-nginx rewrite-target: community ingress-nginx annotation
- Gateway API HTTP redirects and rewrites:
ReplaceFullPathandReplacePrefixMatch - Envoy Gateway HTTP URL rewrite (v1.9):
HTTPRouteFilterReplaceRegexMatch - ingress2gateway ingress-nginx provider (v1.0.0): which annotations the converter translates
Frequently Asked Questions
Does ReplacePrefixMatch do the same thing as rewrite-target /?
No. They send /shop/hostname to different paths:
rewrite-target: /replaces the entire path, so the backend receives/ReplacePrefixMatchreplaces only the matched prefix with/, preserving the remaining path, so the backend receives/hostname
Why does the backend receive the path /$2?
ReplaceFullPath stores one fixed string. It does not expand $1 or $2. ingress2gateway 1.0.0 copied /$2 into that field, and Envoy forwarded that literal path.
Does use-regex rewrite the path?
No. The annotation and the rewrite are separate:
use-regex: "true"changes the match into a regular expression- The rewrite is still
rewrite-target - Expanding a capture group on Envoy is an
HTTPRouteFilterwithReplaceRegexMatch, not a coreURLRewritefield
Why does /shopping return 404 after the prefix change?
Three different matchers are involved:
- A normal Kubernetes Prefix
/shopdoes not match/shopping - In this ingress-nginx example,
rewrite-targetmakes the controller use a regular-expression location for the host, which is why/shoppingmatched before the migration - The Envoy
PathPrefixroute restores normal element-wise prefix behavior
Why does /Shop work on ingress-nginx and fail on the new route?
The capital path matched on ingress-nginx because that host's locations were case-insensitive regular expressions. It misses on the new route for a different reason:
- The converter printed
(?i)to keep that behavior PathPrefixis case-sensitive- A regular expression without
(?i)is case-sensitive too
The request then misses, and Envoy returns an empty 404.

