backend / protocols / nginx / 05_proxy_pass_trailing_slash.md

proxy_pass and the Trailing Slash Trap

6 interview angles 5 min read source

proxy_pass and the Trailing Slash Trap

The single most-asked nginx gotcha: proxy_pass http://backend; and proxy_pass http://backend/; route the same request to different upstream URIs. The trailing slash in proxy_pass decides whether nginx strips the location prefix or not.

The rule in one sentence

If proxy_pass URL has a path (even just /), nginx replaces the matched location prefix with that path. If the URL has no path, nginx passes the request URI unchanged.

The four cases

location /api/ {
    # Case 1: no path — pass URI as-is
    proxy_pass http://backend;
    # GET /api/users → http://backend/api/users
}

location /api/ {
    # Case 2: just a slash — replaces /api/ with /
    proxy_pass http://backend/;
    # GET /api/users → http://backend/users
}

location /api/ {
    # Case 3: a path — replaces /api/ with /v2/
    proxy_pass http://backend/v2/;
    # GET /api/users → http://backend/v2/users
}

location /api/ {
    # Case 4: a path without trailing slash
    proxy_pass http://backend/v2;
    # GET /api/users → http://backend/v2users   no slash inserted
}

Case 4 is the “why is my upstream getting /v2users?!” bug.

Memorization trick

Imagine nginx doing string replacement: it strips the location prefix from the URI and prepends whatever path is in proxy_pass. If proxy_pass has no path, no string surgery happens.

location proxy_pass request /api/users/42 becomes
/api/ http://backend /api/users/42 (untouched)
/api/ http://backend/ /users/42
/api/ http://backend/v2/ /v2/users/42
/api/ http://backend/v2 /v2users/42
/api (no slash) http://backend /api/users/42
/api (no slash) http://backend/ /users/42 (the /api prefix is gone, slash from URI remains)

The exception: regex location

If the location is a regex, proxy_pass cannot have a path:

location ~ ^/api/(.+)$ {
    proxy_pass http://backend/$1;       # ERROR on nginx -t
    proxy_pass http://backend;          # OK
}

Workaround for regex with rewriting:

location ~ ^/api/(.+)$ {
    rewrite ^/api/(.+)$ /v2/$1 break;
    proxy_pass http://backend;
}

rewrite ... break modifies the URI and stops further rewrite processing.

Variables in proxy_pass

Using a $variable in proxy_pass also disables the URI-replacement behavior — the URI is passed unchanged unless you use rewrite:

set $upstream "backend";
location /api/ {
    proxy_pass http://$upstream;        # /api/users → /api/users
}

This trips people up when they try to dynamically pick an upstream and suddenly the path passes differently.

Standard headers to set

proxy_pass doesn’t automatically forward useful client info. Always set:

location / {
    proxy_pass http://backend;
    proxy_set_header Host $host;                                  # original Host header
    proxy_set_header X-Real-IP $remote_addr;                       # client IP
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;   # chain of proxies
    proxy_set_header X-Forwarded-Proto $scheme;                    # http or https
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
}

Without Host, nginx sends the upstream proxy_pass’s hostname (e.g. backend), which breaks anything that does virtual hosting (Django’s ALLOWED_HOSTS, multi-tenant routing).

The proxy_set_header inheritance trap (see 03_directives_and_contexts.md): if you set ANY proxy_set_header in a child block, all the inherited ones are dropped. Re-add them or factor into an include snippet.

Headers nginx adds automatically (or removes)

By default proxy_pass forwards most request headers as-is. Notable changes:

  • Connection is set to close (or Upgrade for WebSockets — see 10_websocket_proxying.md).
  • Host is set to the upstream’s hostname unless you proxy_set_header Host $host.
  • Hop-by-hop headers (Connection, Keep-Alive, Proxy-Authenticate, Transfer-Encoding, Upgrade) are not forwarded.

Buffering

By default nginx buffers the upstream response in memory (and to disk if large) before sending to the client:

proxy_buffering on;                  # default; the slow-client protection
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;

When this matters:

  • Slow clients: nginx reads the upstream response fast (freeing the upstream worker), then drips it to the slow client. Critical for Python apps where a worker handling a slow client blocks 1 of N workers.
  • Large responses: nginx may write to proxy_temp_path (disk) if memory buffers fill. Watch disk I/O and free space.

When to disable:

  • Streaming responses (SSE, long-lived JSON streams): proxy_buffering off; so chunks flow through immediately.
  • gRPC / WebSocket: nginx provides separate grpc_pass and Upgrade handling.

Timeouts

proxy_connect_timeout 5s;       # how long to wait to establish TCP to upstream (default 60s — too long)
proxy_send_timeout 60s;          # how long between writes when sending request to upstream
proxy_read_timeout 60s;          # how long between reads from upstream — most relevant

proxy_read_timeout is the one that matters for slow upstreams. If your Django view takes 90 seconds, you’ll get 504 unless you bump this.

For SSE / long-polling endpoints, set proxy_read_timeout to whatever your max stream duration is (or longer; e.g. 3600s for an hour).

Common interview confusions

  • proxy_pass always strips the location prefix.” — only if proxy_pass has a path. proxy_pass http://backend; passes URI unchanged.
  • proxy_pass http://backend/” and proxy_pass http://backend; are equivalent.“ — completely different. The slash strips the location prefix; without it, the URI passes whole.
  • “You can use $variables in proxy_pass like in any other directive.” — you can, but it disables the URI-replacement and forces you to use rewrite for path manipulation.
  • proxy_pass forwards every header.” — most yes, but Host defaults to the upstream’s hostname unless you override; hop-by-hop headers are stripped.

Interview angle

  • “What’s the difference between proxy_pass http://backend and proxy_pass http://backend/?” — without the slash, the request URI passes unchanged. With the slash, nginx strips the matched location prefix and replaces it with / (or whatever path is given).
  • “Why does my upstream see /v2users instead of /v2/users?”proxy_pass http://backend/v2; (no trailing slash) — nginx replaces the location prefix with /v2 and concatenates the rest. Use /v2/.
  • “Why does proxy_pass http://$upstream; route differently?” — variables in proxy_pass disable the URI rewriting; the path is passed unchanged. Use rewrite ... break; to alter the URI.
  • “Why is Host important to forward?” — frameworks use it for virtual hosting and ALLOWED_HOSTS checks. Without proxy_set_header Host $host, the upstream sees the proxy’s name.
  • “Why is proxy_buffering on by default and when do you turn it off?” — buffering protects upstream workers from slow clients. Turn off for streaming responses (SSE, chunked APIs) where you need data to flow through in real time.
  • “What’s the proxy_set_header inheritance trap?” — setting any proxy_set_header in a child location drops all inherited ones. Re-add them or factor into an include snippet.