Reverse proxy

nginx proxy_pass and the trailing slash

The rule that decides the upstream URI. proxy_pass with a URI part replaces the matched location prefix; without one it forwards the request URI as sent.

· 13 min read · How we verify this

Key points

  • If proxy_pass contains a URI part (anything after host and optional port, including a bare /), nginx replaces the portion of the normalised request URI that matched the location prefix with that URI.
  • If proxy_pass has no URI part, the request URI is forwarded unchanged, byte for byte, including %2F and duplicate slashes.
  • A variable anywhere in proxy_pass disables the prefix replacement and moves name resolution to request time, which requires a resolver.
  • nginx defaults Host to $proxy_host (the name in proxy_pass), not the client's Host. Almost every deployment needs proxy_set_header Host $host;.

The rule is this: if proxy_pass contains a URI part, meaning anything after the host and optional port including a bare /, nginx replaces the portion of the normalised request URI that matched the location prefix with that URI. If proxy_pass contains no URI part, the request URI is passed to the upstream unchanged, in the form the client sent it. The trailing slash is not a style choice: proxy_pass http://app; and proxy_pass http://app/; are two different features, and the second one rewrites paths.

Everything else on this page follows from those two sentences. To try a configuration against the rule without restarting anything, use the nginx proxy_pass URI simulator, which implements the same replacement logic and shows the exact request line nginx would emit.

The truth table#

Every row assumes upstream u { server 10.0.0.5:8080; } and a plain GET. The last column is the path in the request line nginx writes to the upstream socket.

locationproxy_passrequest URIupstream request URI
/app/http://u/app/x/y?q=1/app/x/y?q=1
/app/http://u//app/x/y?q=1/x/y?q=1
/app/http://u/api//app/x/api/x
/app/http://u/api/app/x/apix
/apphttp://u/api/app/x/api/x
/ahttp://u/b/abc/bbc
/ahttp://u//abc/bc
/a/http://u/a/c/a/c
/a/http://u//a//
/a/http://u//ano upstream request: 301 to /a/
~ ^/a/http://u/a/x/a/x
~ ^/a/(.*)http://u/b/$1/a/x/b/x
@fallbackhttp://u/anything/anything
/a/ plus rewrite ^/a/(.*) /z/$1 break;http://u/ignored//a/x/z/x
/a/http://u//a/x%2Fy/x/y
/a/http://u$request_uri/a/x%2Fy/a/x%2Fy

Four of those rows are where production incidents come from.

Row 4 (/apix) is the classic. The location prefix /app/ includes its trailing slash, so the whole of /app/ is what gets replaced, and replacing it with /api concatenates directly onto x. nginx is doing exactly what it documents; the mental model that "proxy_pass appends the rest of the path" is what is wrong.

Rows 6 and 7 (/abc becomes /bbc and /bc) show that location prefixes are string prefixes, not path segment prefixes. location /a matches /abc, /api, /admin and /a. Combined with a URI part, the replacement operates on characters, not segments: the matched three characters /a are swapped for the two characters /b, leaving bc welded on to the end of it. If you meant path segments, write location /a/ and add location = /a for the bare form.

Row 10 (the 301) is a special case worth memorising: when a prefix location ends in a slash and its content is handled by proxy_pass (or fastcgi_pass, uwsgi_pass, scgi_pass, memcached_pass, grpc_pass), a request for the same string without the trailing slash gets a permanent redirect adding the slash. If your client follows redirects silently you will never notice; if it is an API client that does not, you get a mystifying 301. Suppress it with an exact-match location:

nginx
location = /a  { proxy_pass http://u/; }
location /a/   { proxy_pass http://u/; }

Row 15 (%2F becomes /) is the one that breaks S3-style keys and Git-over-HTTP paths. See the normalisation section below.

Why normalisation decides which form you need#

nginx normalises the request URI before location matching: it merges adjacent slashes (because merge_slashes defaults to on), resolves . and .. segments, and percent-decodes escaped octets. The result is $uri. The raw request target from the request line, query string included and nothing decoded, stays available as $request_uri.

The two proxy_pass forms consume different things:

  • With a URI part, nginx builds the upstream URI from the normalised $uri, re-escaping only the characters that must be escaped. /a//b has already become /a/b, and %2F has already become a real slash, so both changes are visible to the upstream.
  • Without a URI part, nginx forwards the unparsed request target verbatim while the URI is unchanged. Duplicate slashes, %2F, %2E%2E and unusual encodings survive.

Turning merge_slashes off is occasionally required (some object stores use significant double slashes) but it re-arms the bypass, so it should be paired with explicit location rules that account for it.

When proxy_pass may not have a URI part#

nginx cannot compute "the part that matched the location" in these contexts, so it refuses the URI part at configuration time:

  • a location defined by a regular expression (location ~ or ~*)
  • a named location (location @name)
  • inside an if block
  • inside a limit_except block

The configuration test fails with:

text
nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression,
or inside named location, or inside "if" statement, or inside "limit_except" block
in /etc/nginx/conf.d/site.conf:14

There are two legal escapes. The first is a variable, which changes the rules entirely (next section). The second is rewrite ... break, which changes $uri before the proxy module runs; the URI part of proxy_pass is then ignored and the full rewritten URI is sent. That is row 14 of the table, and it is the idiomatic way to do a non-trivial path transform:

nginx
location /a/ {
    rewrite ^/a/(.*)$ /z/$1 break;
    proxy_pass http://u;
}

Use break, not last. last restarts location matching with the new URI, which either loops or lands somewhere unintended; break stops rewrite processing and hands the modified URI to the content phase in the same location.

Variables in proxy_pass change three things at once#

Putting any variable in proxy_pass ($backend, $request_uri, a regex capture) has three consequences, and people usually intend only the first:

  1. The prefix replacement is disabled. Whatever URI you write is sent as-is, so you must construct the complete upstream path yourself. proxy_pass http://u$request_uri; is the canonical "pass everything through raw" idiom.
  2. Name resolution moves to request time. With a literal name, nginx resolves it once at configuration load and caches the addresses for the lifetime of the worker. With a variable, the name is looked up per request: first against the names of defined upstream blocks, and only if there is no match, through resolver. If no resolver is configured, every request fails with a 502 and an error log line of the form no resolver defined to resolve <name>.
  3. proxy_redirect default becomes illegal, because nginx can no longer know the static replacement string. nginx rejects the configuration and you must write an explicit proxy_redirect or off.

Point 2 is the reason the variable form is popular for upstreams behind a DNS name that changes, such as a Kubernetes Service or an AWS load balancer. The literal form pins the address for as long as the worker lives, and a rolling upstream replacement then produces 502s until someone reloads nginx.

nginx
resolver 10.96.0.10 valid=10s ipv6=off;

location /api/ {
    set $api http://api.svc.cluster.local:8080;
    proxy_pass $api$request_uri;
}

Note what you give up: valid= overrides the record TTL, health-check-aware upstream failover from an upstream block no longer applies, and you inherit whatever ordering the resolver returns.

upstream blocks, sockets and the Host header#

An upstream block groups servers, sets the balancing method and enables the keepalive connection cache. A URI part is perfectly legal alongside one (proxy_pass http://backend/api/;); the group name simply occupies the host position. What you cannot do is combine an upstream group with a variable and still get the group's health tracking if the name does not match a defined group at request time.

Unix sockets use a distinct syntax where the socket path is terminated by a colon:

nginx
proxy_pass http://unix:/run/app.sock:/api/;

Here /api/ is the URI part and the trailing-slash rule applies to it normally.

nginx's default request headers to the upstream are minimal and two of them surprise people:

Headernginx defaultWhy it matters
Host$proxy_host (the host and port literal from proxy_pass)With an upstream group the upstream receives Host: backend. Name-based vhosts miss, frameworks generate absolute URLs pointing at backend, and cookies get the wrong domain.
Connectionclose before 1.29.7, not sent by default sinceOn builds before 1.29.7, upstream keepalive does nothing until you also set proxy_http_version 1.1; and proxy_set_header Connection "";.
X-Forwarded-Fornot sentnginx adds nothing unless you configure it.
X-Forwarded-Protonot sentThe upstream cannot tell HTTP from HTTPS, so it emits http:// redirects.
X-Real-IPnot sentAn nginx convention, not a standard.
HTTP version1.1 since 1.29.7, 1.0 before itOn builds before 1.29.7, chunked request bodies and upgrades need proxy_http_version 1.1;.

The pragmatic baseline is:

nginx
proxy_set_header Host              $host;
proxy_set_header X-Real-IP         $remote_addr;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";

One inheritance trap: proxy_set_header directives are inherited from the enclosing level only if no proxy_set_header is defined at the current level. Adding a single proxy_set_header X-Request-Id $request_id; inside a location silently discards every header set in the parent server block, including Host. Whether the resulting headers are trusted downstream is a separate decision, covered in configuring trusted proxies and the X-Forwarded-For header.

Worked example: an SPA and an API on one host#

Requirements: / serves a static bundle, /api/... goes to a backend that expects paths without the /api prefix, /api/health must not be exposed, WebSocket upgrades on /api/ws must work, and object keys containing encoded slashes must survive.

nginx
upstream app {
    server 10.0.0.5:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.6:8080 max_fails=3 fail_timeout=10s;
    keepalive 32;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Connection        $connection_upgrade;

    location = /api/health { return 404; }

    # Strip /api. Trailing slashes on BOTH sides, and an exact-match
    # sibling so that /api does not 301 to /api/.
    location = /api { proxy_pass http://app/; }
    location /api/  { proxy_pass http://app/; }

    # Object keys may contain %2F, which the rule above would decode.
    # No URI part means the raw request target is forwarded verbatim.
    location /api/objects/ {
        proxy_pass http://app;
    }

    location / {
        root /srv/www;
        try_files $uri /index.html;
    }
}

Observable behaviour: GET /api/users/7 arrives upstream as GET /users/7 HTTP/1.1 with Host: app.example.com. GET /api/objects/a%2Fb arrives as GET /api/objects/a%2Fb, prefix intact, because that location deliberately gives up the rewrite to keep the encoding. That asymmetry is the honest trade: you cannot have both prefix stripping and byte-exact path preservation in a single location without a rewrite.

The map plus $connection_upgrade idiom is required because a static Connection: upgrade breaks ordinary requests; see WebSockets through a reverse proxy for the full handshake path.

Failure modes#

SymptomRoot causeFix
nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" blockURI part in a regex or named locationDrop the URI part, add a variable, or use rewrite ... break
nginx: [emerg] host not found in upstream "api.internal" in /etc/nginx/conf.d/site.conf:20Literal name unresolvable at config load, so nginx will not start or reloadFix DNS, or switch to the variable form with a resolver
502 plus no resolver defined to resolve api.internal in the error logVariable proxy_pass with no resolverAdd resolver, with valid= tuned to how fast the upstream moves
Upstream 404 on every path, e.g. /apix in the upstream access logMissing trailing slash on the proxy_pass URI partMake the slashes symmetric on location and proxy_pass
Unexpected 301 to the same path with a slash appendedPrefix location ending in / handled by proxy_pass, requested without the slashAdd location = /path
Upstream serves the wrong virtual host, or Location: headers point at the upstream group nameHost defaulting to $proxy_hostproxy_set_header Host $host;
404 only for paths containing %2FURI part caused the normalised, decoded URI to be forwardedUse a URI-less proxy_pass for those paths
502 plus upstream sent too big header while reading response header from upstreamResponse headers exceed proxy_buffer_size (default one memory page, 4k or 8k)Raise proxy_buffer_size and proxy_buffers
502 plus SSL_do_handshake() failed ... tlsv1 unrecognized nameproxy_ssl_server_name defaults to off, so nginx sends no SNI to an HTTPS upstreamproxy_ssl_server_name on; and set proxy_ssl_name if it differs
502 plus upstream prematurely closed connection while reading response headerUpstream closed a pooled keepalive connection nginx believed was live, or the app crashed mid-responseSet the upstream idle timeout above nginx's, see keep-alive and connection pooling

The distinction between these 502s and a 504 is diagnostic, not cosmetic; 502 vs 503 vs 504 maps each to the layer that produced it.

Frequently asked questions#

Does a trailing slash in proxy_pass matter?#

Yes, and it is the single most consequential character in an nginx reverse proxy configuration. A trailing slash makes proxy_pass a URI part, which means nginx replaces the matched location prefix with it; without it, the original request URI is forwarded unchanged.

How do I strip a path prefix with proxy_pass?#

Give the location a trailing slash and give proxy_pass a bare trailing slash: location /api/ { proxy_pass http://backend/; }. A request for /api/users reaches the upstream as /users. Add location = /api { proxy_pass http://backend/; } so the bare prefix does not return a 301.

Why does nginx send Host: backend to my upstream?#

Because nginx's default is proxy_set_header Host $proxy_host;, and $proxy_host is the literal host from the proxy_pass URL, which for an upstream group is the group name. Set proxy_set_header Host $host; to forward the client's Host instead.

Why does proxy_pass with a variable return 502?#

Because a variable moves DNS resolution to request time, and without a resolver directive nginx cannot perform that lookup. The error log shows no resolver defined to resolve <name>. Add a resolver pointing at your DNS server, with a valid= interval short enough for how often the upstream address changes.

Can proxy_pass have a URI in a regex location?#

No. nginx cannot determine which part of the request URI matched a regular expression, so it rejects the configuration at load time. Either use a URI-less proxy_pass, build the whole target with a variable and capture groups, or rewrite the URI with rewrite ... break first.

What is the difference between $uri and $request_uri?#

$uri is the normalised path: slashes merged, dot segments resolved, percent-encoding decoded, and no query string. $request_uri is the raw request target exactly as the client sent it, query string included. Use $request_uri when the upstream must see the original bytes, and $uri when you are matching or rewriting.

Does proxy_pass forward the query string?#

Yes, in both of the literal forms the original query string is appended to the upstream URI. The exception is a proxy_pass containing variables: the URI you build is sent as is, so append $is_args$args or use $request_uri yourself. To change or drop the query string, use rewrite in the location, where a trailing ? on the replacement discards the original arguments.

Should I use rewrite ... break or a proxy_pass URI part?#

Use the URI part when the transformation is "replace this literal prefix with that literal prefix", which covers most cases and is cheaper to read. Use rewrite ... break when the transformation needs capture groups, conditional logic, or must run in a regex location where a URI part is not allowed.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. nginx ngx_http_proxy_module
  2. nginx ngx_http_core_module (location, merge_slashes)
  3. nginx ngx_http_rewrite_module
  4. nginx ngx_http_upstream_module
  5. nginx alphabetical index of variables
  6. RFC 3986: Uniform Resource Identifier (URI) Generic Syntax
  7. RFC 9110: HTTP Semantics

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in reverse proxy configuration#