Nginx

Nginx proxy_pass Stripping URL Path Prefix Unexpectedly

Whether proxy_pass strips the matched location prefix from the URL before forwarding to the upstream depends on a small, easy-to-miss detail in how the proxy_pass URL itself is written — specifically, whether it includes a URI component after the upstream address at all.

The Problem

Requests to a proxied path either lose their expected prefix before reaching the backend (causing 404s on a backend expecting the full path) or unexpectedly keep a prefix the backend doesn't expect and also returns a 404 for — the behavior seems inconsistent or backwards from what was intended.

proxy_pass URI presence changes everything WITH trailing slash + no path proxy_pass http://backend/; /api/users -> /users (prefix stripped) WITHOUT any path proxy_pass http://backend; /api/users -> /api/users (kept as-is)

Why It Happens

Nginx's behavior here is governed by a specific, consistent rule that's easy to overlook: if the proxy_pass directive's URL includes a URI component (anything after the host/port, even just a bare trailing slash), Nginx replaces the matched location portion of the original request URI with that component. If proxy_pass has no URI component at all, Nginx forwards the full original URI unchanged, appending it to whatever's in proxy_pass exactly as received. This single detail — presence or absence of a path/slash after the upstream address — is the entire mechanism, but it's genuinely easy to get backwards when writing or reading configuration quickly.

The Fix

To strip the matched location prefix before forwarding (common when your backend doesn't know about, or care about, the prefix used to route to it):

location /api/ {
    proxy_pass http://backend/;
}
# Request to /api/users is forwarded to backend as: /users

The trailing slash after backend is what triggers stripping — this is a URI component (even though it's minimal, just /), so Nginx replaces the matched /api/ prefix with it, leaving /users.

To keep the full original path unchanged, including the matched prefix (common when your backend genuinely expects and routes based on that same prefix):

location /api/ {
    proxy_pass http://backend;
}
# Request to /api/users is forwarded to backend as: /api/users

Here, proxy_pass has no trailing slash or path at all after the upstream address, so Nginx passes the complete original URI through unmodified.

If you need to strip the prefix but replace it with something different rather than nothing, include a specific path after the upstream address:

location /api/ {
    proxy_pass http://backend/v2/;
}
# Request to /api/users is forwarded to backend as: /v2/users

To verify exactly what's actually being forwarded rather than reasoning about it purely from the config, add temporary logging on the backend itself to print the received path, or use a tool like a local echo server to directly observe incoming requests during testing:

# Quick way to test what Nginx actually forwards
python3 -m http.server 8000 --bind 127.0.0.1
# Point proxy_pass at this temporarily and check its access log for the actual path received

Still Not Working?

If the URI stripping behavior seems correct based on this rule but requests still fail, check whether your location block uses a regex pattern (location ~ /api/(.*)) rather than a plain prefix match, since regex locations follow different rules entirely — with a regex location, Nginx does NOT automatically strip anything, and you need to explicitly reference a captured group in the proxy_pass directive to control the forwarded path precisely:

location ~ ^/api/(.*)$ {
    proxy_pass http://backend/$1;
}

This explicit capture-group approach gives full manual control over the forwarded path, which is necessary specifically because the trailing-slash stripping rule described above only applies to plain prefix-match location blocks, not regex-based ones.

It's also worth double-checking whether query string parameters are being preserved as expected through the rewrite, since these follow their own separate handling. By default, Nginx automatically passes through the original query string unless you explicitly override it, but a configuration that manually manipulates the URI can sometimes accidentally drop query parameters as a side effect of how the path itself is being rewritten:

location /api/ {
    proxy_pass http://backend/;
    # Query string ?page=2 on /api/users is preserved automatically:
    # forwarded as /users?page=2
}

Finally, if you're chaining multiple location blocks together (an outer block matching a broader prefix, with a nested or separate block handling a more specific sub-path), trace through each block's own proxy_pass rule individually, since the stripping behavior described here applies independently at whichever location block actually ends up matching and handling a given request — a request matched by an inner, more specific block follows that block's own proxy_pass rule, not the outer block's, even if both are present in the same configuration file.