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:
Connectionis set toclose(orUpgradefor WebSockets — see 10_websocket_proxying.md).Hostis set to the upstream’s hostname unless youproxy_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_passand 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_passalways strips the location prefix.” — only ifproxy_passhas a path.proxy_pass http://backend;passes URI unchanged. - “
proxy_pass http://backend/” andproxy_pass http://backend;are equivalent.“ — completely different. The slash strips the location prefix; without it, the URI passes whole. - “You can use
$variablesinproxy_passlike in any other directive.” — you can, but it disables the URI-replacement and forces you to userewritefor path manipulation. - “
proxy_passforwards every header.” — most yes, butHostdefaults to the upstream’s hostname unless you override; hop-by-hop headers are stripped.
Interview angle
- “What’s the difference between
proxy_pass http://backendandproxy_pass http://backend/?” — without the slash, the request URI passes unchanged. With the slash, nginx strips the matchedlocationprefix and replaces it with/(or whatever path is given). - “Why does my upstream see
/v2usersinstead of/v2/users?” —proxy_pass http://backend/v2;(no trailing slash) — nginx replaces the location prefix with/v2and concatenates the rest. Use/v2/. - “Why does
proxy_pass http://$upstream;route differently?” — variables inproxy_passdisable the URI rewriting; the path is passed unchanged. Userewrite ... break;to alter the URI. - “Why is
Hostimportant to forward?” — frameworks use it for virtual hosting andALLOWED_HOSTSchecks. Withoutproxy_set_header Host $host, the upstream sees the proxy’s name. - “Why is
proxy_bufferingon 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_headerin a child location drops all inherited ones. Re-add them or factor into anincludesnippet.