skip to content

In nginx, a `location /search?q=` block never matches anything, and the value logged as `$request_uri` often differs from the path a location matched. What string does nginx actually compare `location` prefixes and regexes against?

level: seniorimportance: nice to knowfreq 33%

answer

  1. locations never see the query string
  2. decoded before anything is compared
  3. dot segments resolved, slashes merged
  4. $uri normalized, $request_uri raw
  5. route on $arg_ with map instead

basics

~20 s

Nginx matches locations against the normalized request path: percent-decoded, with dot and dot-dot segments resolved and repeated slashes merged, and with the query string removed. That value is $uri; $request_uri holds the raw original line including the query.

solid answer

~50 s

Location matching runs against the **normalized path**, not the raw request line. Before any location is tested, nginx percent-decodes `%XX` escapes, resolves `.` and `..` as whole path segments, and (with `merge_slashes` on, the default) collapses runs of slashes. The query string is stripped entirely. That normalized value is what `$uri` holds; `$request_uri` keeps the original bytes, query string included. Two consequences follow. First, a query string can never appear in a `location` — `location /search?q=` compares that literal text against a path that has no `?` in it, so it matches nothing; route on `$arg_q` or `$args` with a `map` instead. Second, `/a%64min/` and `/./admin/` both normalize to `/admin/`, so a prefix location catches encoded and dotted spellings — which is good for a prefix rule and exactly why an `=` exact match is a brittle basis for one.

code

nginx · 13 lines
nginx
log_format detail '$remote_addr "$request" '
                  'req=$request_uri uri=$uri args=$args status=$status';

server {
    listen 80;
    access_log /var/log/nginx/detail.log detail;

    # matches /admin/, /a%64min/, /./admin/ and //admin//
    location /admin/ { return 403; }

    # matches only the literal string /health
    location = /health { return 200 "ok\n"; }
}

go deeper

for a junior

Know that a location matches on the path only, and that anything after the question mark is not part of it. Recognise $args and $arg_name as where query parameters live.

for a middle

Explain the normalization steps — percent-decoding, dot-segment resolution, slash merging — and state the difference between $uri and $request_uri clearly enough to pick the right one for a log format.

for a senior

Show the debugging habit: log both variables so a rewritten request is visible in one line, and explain why prefix rules survive encoded spellings while an exact match does not.

for a principal

Own the consequence for the estate: routing rules should be written against the normalized path, and any decision to flip merge_slashes off must be paired with a review of every location that assumed collapsed slashes.

## Two variables, two different strings Every nginx request carries both: - **`$request_uri`** — the URI exactly as it appeared on the request line, escapes intact, query string attached: `/a%64min/list?page=2`. - **`$uri`** — the normalized path nginx works with internally: `/admin/list`. Decoded, dot segments resolved, slashes merged, no query. Location matching, `try_files`, `root` and `alias` resolution all operate on the second. Logging only `$request_uri` and then reasoning about which location matched is a reliable way to confuse yourself. ## What normalization does, precisely 1. **Percent-decoding.** `%64` becomes `d`, `%2F` becomes `/`. So `/a%64min/` and `/admin/` are the same path by the time locations are tested. 2. **Dot-segment resolution.** `/x/./y` becomes `/x/y`; `/x/../y` becomes `/y`. This applies to **whole segments only** — `x..y` and `static..` are ordinary names and survive untouched, which is why the `alias` traversal misconfiguration exists at all. 3. **Slash merging.** With `merge_slashes on` (the default), `//app///x` becomes `/app/x`. Turning it off is occasionally required when a path segment legitimately contains an encoded slash, and it is a deliberate trade — it re-exposes the duplicate-slash spellings the merge was collapsing. 4. **Query removal.** Everything from `?` onward is split off into `$args` and the per-argument `$arg_<name>` variables. ## Why `location /search?q=` cannot work A prefix location is compared against a string that, by construction, contains no `?`. The block is not an error and nginx starts happily — it simply never matches. The same is true of a regex: `location ~ ^/search\?q=` tests against `/search` and fails. To route on a query argument you use the variables, not a location: ```nginx map $arg_format $backend_root { default /srv/html; json /srv/json; } server { location /search { root $backend_root; } } ``` `map` is evaluated lazily and costs nothing when unused, which is why it is preferred over `if` inside a location for this kind of branch. ## Why the normalization is usually what you want Because nginx decodes before matching, a prefix rule cannot be slipped past with clever encoding: `/a%64min/`, `/admin//`, and `/./admin/` all reach `location /admin/`. A rule written as a prefix therefore covers the spellings an attacker would try. An **exact** location is the opposite. `location = /admin` matches that one string and nothing else — not `/admin/`, and on a case-sensitive filesystem not `/Admin`. Exact locations are excellent for hot single URIs such as `= /health` or `= /favicon.ico`, where you want the cheapest possible match and the URI is fixed. They are a poor foundation for a rule that is supposed to cover a tree. ## The version that bites during debugging A rewritten request changes `$uri` but not `$request_uri`. After an internal redirect, `$uri` is the new path while `$request_uri` still shows what the client sent. That is genuinely useful — it is how you tell, from one log line, that a request was rewritten: ```nginx log_format detail '$remote_addr "$request" ' 'req=$request_uri uri=$uri args=$args status=$status'; ``` When `req` and `uri` disagree, something rewrote the path. When a location "should have matched but didn't", compare the two: nine times out of ten the location was written against the raw form the client sent, and nginx was matching the normalized one. ## What to say in an interview State the three-part answer: nginx matches the **decoded, dot-resolved, slash-merged path**; the **query string is not part of it**; `$uri` is that value and `$request_uri` is the original. Then give the consequence that shows you have debugged it — a query string in a `location` silently matches nothing, and prefix locations, unlike exact ones, already cover the encoded spellings.

  • How would you route requests based on a query parameter, given that a location cannot see one?
    Use the per-argument variables. `$arg_format` exposes `?format=json`, and a `map` turns it into a variable you consume inside the location — `map $arg_format $backend_root { default /srv/html; json /srv/json; }`. `map` is evaluated lazily and avoids `if` inside a location, whose behaviour with other directives is notoriously surprising.
  • What does `merge_slashes off;` change, and when would you need it?
    By default nginx collapses runs of slashes before matching, so `//app///x` becomes `/app/x`. Turning it off preserves them — needed when a path segment legitimately carries an encoded slash that must survive to the backend. The trade is real: duplicate-slash spellings of a path stop collapsing onto the same normalized form your location rules were written against.
  • Why is `location = /admin` a weak basis for a rule meant to cover an admin area?
    An exact location matches that one string and nothing else — not `/admin/`, not `/admin/users`. It is ideal for a single hot URI such as `= /health`, where the cheapness of the match is the point. For a tree you want a prefix location, which after normalization already covers the encoded and dotted spellings of the same path.

saying these in an interview costs you the question

  • Thinks a location can match on the query string
  • Says nginx matches the raw, undecoded request line
  • Uses $request_uri and $uri interchangeably
  • Believes an exact = location also covers subpaths
  • Assumes duplicate slashes reach location matching intact

context