4.1 proxy_pass Fundamentals: The URI Rules
One directive, four behaviors, one character that changes everything. This lesson settles the trailing-slash confusion permanently — with an echo backend as referee.
Intermediate
What you will learn
What
proxy_passdoes mechanically (connect, forward, relay).The four URI-transformation rules — memorized via a live table.
Why variables in
proxy_passdisable transformation entirely.DNS, resolver behavior, and the "immutable at config load" rule.
The
upstreampreview (Module 5 takes it from here).
1. What proxy_pass does
location /api/ {
proxy_pass http://127.0.0.1:9001;
}Mechanically, on each request entering the location, the proxy module:
Resolves the target (IP now, or via
resolverat runtime — §4).Opens (or reuses) an upstream connection — plain HTTP/1.0 by default; you'll upgrade that in §3 of the next lesson.
Builds the upstream request: the client's method and body, the URI transformed per the rules below, plus headers (yours + defaults + the client's, minus hop-by-hop ones).
Relays the response back through the same buffers and timeouts you configure (Lesson 4.6).
Everything interesting about "what the backend sees" is step 3 — and the single most consequential decision is whether your
proxy_pass value has a
URI part after the host.
2. The four URI rules — in practice
The example gateway fronting a Node echo backend (which reports the path it received) demonstrates all four forms in one server. This is the actual output, one line per form:
$ for p in /plain/abc /strip/abc /map/abc /varpass/abc; do
printf '%-12s -> %s\n' "$p" "$(curl -s -H 'Host: gw.example.test' \
http://127.0.0.1:8400$p | jq -r .path)"
done
/plain/abc -> /plain/abc # rule 1: NO URI part
/strip/abc -> /abc # rule 2: URI part "/"
/map/abc -> /api/abc # rule 3: URI part "/api/"
/varpass/abc -> /varpass/abc # rule 4: variablesThe four configurations that produced it (condensed from the working config):
# Rule 1 — NO URI part (nothing after host:port):
location /plain/ {
proxy_pass http://127.0.0.1:9001; # /plain/abc -> /plain/abc
}
# Rule 2 — URI part "/" (the famous trailing slash):
location /strip/ {
proxy_pass http://127.0.0.1:9001/; # /strip/abc -> /abc
}
# Rule 3 — URI part "/api/":
location /map/ {
proxy_pass http://127.0.0.1:9001/api/; # /map/abc -> /api/abc
}
# Rule 4 — variables anywhere in the value:
location /varpass/ {
set $backend_port 9002;
proxy_pass http://127.0.0.1:$backend_port; # no transformation at all
}The precise semantics:
Rule 1 (no URI part). The request URI is forwarded unchanged — path, and separately, the original query string. Rules 2–3 (URI part present). The part of the request URI matching the location prefix is cut off and replaced by the URI part.
/map/+/abc−/map/=/abc, glued to/api/→/api/abc. Rule 4 (variables). When the value contains variables, NGINX cannot know at config load how to transform URIs — so it does none. The raw request URI goes to the backend as-is (and, unlike rules 1–3, the full$request_uriincluding query string if you pass none explicitly).
The query string footnote
Under rules 2–3 the query string survives untouched:
/map/abc?x=1 →
/api/abc?x=1. Under rule 4, if you want to modify the query you must interpolate it yourself (
proxy_pass http://$backend$uri$is_args$args;) — the subject of one of NGINX's most-copied StackOverflow snippets, now demystified.
3. What else the backend receives
Step 3 also includes a small set of default headers NGINX sends upstream when you configure nothing:
Default | Value |
|---|---|
| the
|
|
|
Everything else of the client's headers passes through by default —
User-Agent,
Accept, cookies,
Authorization — minus hop-by-hop headers (
Transfer-Encoding handling,
Keep-Alive, etc.).
Those two defaults are both
wrong for modern apps — the backend sees
Host: 127.0.0.1:9001 and new connections per request — and fixing them (
proxy_set_header Host $host;,
proxy_http_version 1.1 + upstream
keepalive) is precisely Lesson 4.2's and Module 5's business. The header-cancellation law from Lesson 2.5 governs every line you add.
4. DNS,
resolver, and when the target is frozen
For a static
proxy_pass http://api.internal:8080;, NGINX resolves
api.internal
once at config load — the master resolves it while parsing, workers reuse the address list forever (until reload). Add these facts to your mental model:
A name that resolves to N addresses becomes an implicit upstream pool — NGINX load-balances across them round-robin (Module 5 relies on exactly this).
No re-resolution on TTL. If the DNS record changes (blue/green flip, autoscale event), a running NGINX keeps using the old addresses until you reload. This is the classic "kubernetes pod moved, NGINX still hits the grave" failure — and the reason the
resolver+ variables pattern exists: with a variable target (set $up http://api.internal:8080; proxy_pass $up;) plus aresolver 127.0.0.1 valid=10s;, workers re-resolve pervalidwindow at request time. Module 13 turns this into a full dynamic-upstream setup.
5. The
upstream preview
One more form exists — naming a pool instead of an address:
upstream node-pool {
server 127.0.0.1:9001;
server 127.0.0.1:9002;
server 127.0.0.1:9003;
}
…
location /pool/ {
proxy_pass http://node-pool/; # same URI rules apply to the part after the name
}$ curl -s -H 'Host: gw.example.test' http://127.0.0.1:8400/pool/x | jq -r .server
b-node-3The URI rules are unchanged (the pool name is just the host part); what you gain is the entire Module 5 curriculum: scheduling algorithms, failure detection, connection reuse, and zones. For today: know that the form exists and follows today's rules exactly.
Try it yourself
Reproduce the four-rule table (§2) — then extend it with query strings: does
/map/abc?x=1keep?x=1? Does/varpass/abc?x=1?The capture-forwarding idiom. Add
location ~ ^/u/(?<rest>.*)$ { proxy_pass http://127.0.0.1:9001/$rest$is_args$args; }and test/u/hello?x=1. You have just written the regex-location forwarding pattern from first principles.Watch Host change live. Through the echo:
curl -s -H 'Host: gw.example.test' http://127.0.0.1:8400/plain/ | jq .headers.hostThen compare with
/own-headers/ — the next lesson's subject.
Break rule 3 on purpose. Remove the trailing slash from the
/map/form (proxy_pass http://127.0.0.1:9001/api;) and predict what/map/abcbecomes before curling. (It's not/apabc— but why?) Restore.
Q&A
Q1.
location /shop/ { proxy_pass http://backend; } — what URI does the backend see for
GET /shop/cart?item=7?
Answer
/shop/cart?item=7 — no URI part, everything forwarded verbatim (the query string travels with the request either way).
Q2. Same request under
proxy_pass http://backend/store/;?
Answer
/store/cart?item=7 — the
/shop/ prefix is replaced by
/store/, query string untouched.
Q3. Why does a variable in
proxy_pass disable URI transformation?
Answer
Transformation is a config-time rewrite based on the fixed URI part; with variables NGINX can't compute the pattern until request time, so it declines to transform at all and forwards the URI unchanged. (This also enables the resolver-based dynamic upstreams of Module 13 — the "bug" is a feature.)
Q4. Your service discovery updates DNS, but NGINX keeps sending traffic to dead pods for hours. Diagnose in one sentence.
Answer
Static
proxy_pass names are resolved once at config load and never re-resolved — switch to a variable target plus
resolver … valid=10s; (Module 13's full treatment) or reload on discovery events.
Takeaways
The URI part of
proxy_passis a replacement for the location prefix: absent = forward verbatim;/= strip the prefix;/api/= swap it; variables = no transformation (ever).Defaults sent upstream are minimal and deliberately backwards-compatible (
Host= proxy host,Connection: close) — Lesson 4.2 fixes both.Static targets are resolved at config load; dynamic resolution needs variables +
resolver— the foundation of Module 13.Regex locations may not carry a URI part; use captures when you must reshape there.
upstreampools follow all the same URI rules — they're Module 5's subject wearing today's clothes.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.