Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

2.3 The Location-Matching Algorithm

The most quizzed, most misquoted mechanism in NGINX. After this lesson you will never guess again — you will trace.

Levels: Beginner ~45 min read

Basic

What you will learn

  1. What a location is (and the three jobs it can hold).

  2. The five modifiers and their exact meanings.

  3. The algorithm — memorized as four moves — with the priority pitfalls.

  4. The decoded-URI rule (and the security consequence of ~ vs ~*).

  5. Nested locations, @named locations, and when to use each.


1. What a location is

Inside a server, a location answers "for URIs shaped like this, do something different than the default." That "something" is exactly one of:

  1. Serve static content differently — different root/ alias, different headers, different try_files (Modules 3–4).

  2. Proxy differently — different proxy_pass, buffers, timeouts.

  3. Short-circuit entirely — return/ rewrite before any content handling (this lesson's toys, Module 11's profession).

The alpha vhost in our example config is a deliberate algorithm playground — fifteen locations, each announcing itself with an X-Match header. Here is the live capture, loop included:

bash
$ for u in /exact /docs/handbook.pdf /docs/payload.bin /images/hero.png \
           /logo.png /logo.PNG /secret.txt /secret.TXT /about.html; do
    printf '%-20s %-4s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' \
      -H 'Host: alpha.example.test' http://127.0.0.1:8200$u)" \
      "$(curl -sD - -o /dev/null -H 'Host: alpha.example.test' \
        http://127.0.0.1:8200$u | awk 'tolower($1)=="x-match:"{sub(/\r/,""); print $2, $3}')"
  done
/exact               200  exact (=)
/docs/handbook.pdf   200  ~ (regex,
/docs/payload.bin    200  prefix /docs/
/images/hero.png     200  ^~ /images/
/logo.png            200  ~* (regex,
/logo.PNG            200  ~* (regex,
/secret.txt          200  ~ (regex,
/secret.TXT          404  (fell to prefix / — file doesn't exist)
/about.html          200  (prefix / — no X-Match of its own)

One table, five modifiers, every rule visible. Let's formalize it.

2. The five modifiers

nginx
location  =  /exact     { … }   # EXACT: this URI and nothing else
location     /docs/     { … }   # PREFIX: URI starts with /docs/ (longest wins)
location  ^~ /images/   { … }   # PREFIX+, "preferential": if this prefix is the
                                #   longest, DON'T even try regexes
location  ~  \.(txt|pdf)$ { … } # REGEX, case-SENSITIVE, first declared wins
location  ~* \.(png|jpg)$ { … } # REGEX, case-INSENSITIVE, first declared wins

What each is for, in one breath:

  • = — the fastest possible match (one hash lookup, no walking) and a promise that /exact and only /exact lands here. Use it for the high-QPS hot paths: location = /healthz, location = /.

  • plain prefix — the workhorse. Directories, sections, "everything under /api/". Longest prefix beats shorter ones, regardless of order in the file.

  • ^~ — "prefix, and I mean it": when the longest matching prefix is ^~, regexes are skipped. The escape hatch for regex interference.

  • ~ / ~* — pattern matching on the URI: extensions, IDs, version schemes. Case matters on Linux filesystems — hence two flavors.

3. The algorithm, as four moves

When a request enters the chosen server, NGINX:

text
MOVE 1 — find all prefixes that match the (decoded) URI.
         Remember the LONGEST one. If it is ^~-marked, jump to MOVE 4 with it.

MOVE 2 — run the regexes IN CONFIG ORDER (top of file downward,
         across all locations in that server).
         FIRST match wins → jump to MOVE 4 with it.

MOVE 3 — no regex matched: the longest prefix from MOVE 1 wins.

MOVE 4 — enter the winning location. Done. Exactly one location ever runs.

Trace the table below through these moves:

URI

Move 1 longest prefix

Move 2 regexes

Winner

Why

/exact

/ (only — = /exact isn't a prefix)

none apply

= /exact

exact check actually happens before Move 1

/docs/handbook.pdf

/docs/

~ \.(txt|pdf)$ ✓

regex ~

regexes outrank plain prefixes

/docs/payload.bin

/docs/

none match .bin

prefix /docs/

longest prefix stands

/images/hero.png

^~ /images/ (marked!)

~* \.png$ would match

^~ /images/

preferential prefix slams the door

/logo.png

/

~* \.png$ ✓

regex ~*

/logo.PNG

/

~* still matches (insensitive)

regex ~*

case-insensitive in action

/secret.txt

/

~ \.txt$ ✓

regex ~

/secret.TXT

/

~ declines (case!)

prefix /

then file lookup fails → 404

The = modifier technically participates before Move 1 — an exact match is a hash hit and ends the game immediately. (You can see it as "Move 0".)

4. The decoded-URI rule (and why ~ is a security feature)

Moves 1–2 operate on the decoded, normalized URI — the value of $uri, not the raw bytes the client sent. Percent-escapes are decoded before matching:

text
client sends:   GET /images%2Fhero.png        (an escaped slash!)
NGINX matches:  /images/hero.png              → location ^~ /images/

The regex modifiers then interact with decoding in a way that matters for security:

  • ~ (case-sensitive) matches against the decoded URI verbatim.

  • ~* lowercases the URI before matching (that's what "insensitive" means mechanically).

The classic vulnerability this enables — "upload shell.PHP because the admin regex was ~ \.php$ but the handler checked case-insensitively" — is a mismatch between two layers. Inside NGINX itself the rule is simpler: the location that matches is the location that handles, case and all. Keep case-sensitive ~ for everything executable ( .php, .py, .cgi), and you inherit a small but real safety property: /script.PHP simply won't route to your PHP upstream unless you told it to.

5. Nested and named locations

nginx
location /admin/ {
    # auth, headers, logging for the admin tree…
    location ~ \.php$ {      # regexes INSIDE only compete after the outer
        # …php-specific handling    prefix matched
    }
}

Inner locations are consulted only among themselves, after the outer one matched — a private mini-tournament. Two honest uses: (a) the outer block holds shared directives (auth, headers) and the inner ones only refine the handler; (b) you need ^~-style insulation inside a subtree. The trap: nesting multiplies paths through the config; readers can no longer apply the four moves in one pass. Depth ≤ 1, and only with a comment saying why.

@named locations — internal jump targets

nginx
location / {
    try_files $uri $uri/ @app;      # Lesson 3.2's deep end
}
location @app {
    proxy_pass http://app-pool;     # never matched by any external URI
}

A @name location is invisible to the matching algorithm. It exists to be jumped to — by try_files, error_page, rewrite … last (Module 11) — giving you a named fallback bundle of directives. Think "function for request handling". Lesson 3.2 puts the first one to work.

Try it yourself

The playground has an open invitation:

  1. Reproduce the table in §1 — run the exact loop and diff against the lesson. Every row should match.

  2. Break the priority on purpose. Add location /images/extra/ { … } (plain prefix) below the ^~ /images/ block, then request /images/extra/hero.png — wait, does hero.png exist there? (No — use the X-Match header to see WHO matched, regardless of 404.) Now add location ~ /extra/ { … } above everything and re-test: which wins? Restore afterwards.

  3. Prove prefixes are character prefixes. Request /docsfoo with the X-Match readout — /docs/ would match /docs… if written without the slash; with the slash, /docsfoo falls to /. Verify, then try /docs/../secret.txt (NGINX normalizes .. before matching — you get /secret.txt and its regex location).

  4. Health-check idiom. Add location = /healthz { return 200 "ok\n"; } and hammer it:

bash
   curl -s -H 'Host: alpha.example.test' http://127.0.0.1:8200/healthz
  1. Full sweep when you're done experimenting: re-run the §1 loop and diff — every row must still match.

Q&A

Q1. location /api/v1/ and location ~ ^/api/ — which serves /api/v1/users? Which serves /api/v2/users?

Answer

/api/v1/users: the regex wins (regexes outrank plain prefixes — Move 2 before Move 3). /api/v2/users: also the regex (it matches the whole /api/ tree). The prefix only ever serves URIs the regexes decline — here, none. That's the surprise the table's handbook.pdf row teaches.

Q2. How do you make /api/ bypass all regex locations without deleting them?

Answer

location ^~ /api/ { … } — the preferential prefix. (Assuming no longer prefix outruns it; prefixes still compete by length first.)

Q3. A request for /IMG/logo.PNG is served by your ~* \.(png)$ location even though the files live under /images/. Name two distinct reasons.

Answer

(1) ~* is case-insensitive — .PNG matches. (2) There is no case-insensitive prefix match: /IMG/ won't hit location /images/ or ^~ /images/ (prefix matching is case-sensitive), so the URI fell through to the regex. Fix: normalize case at the edge ( rewrite in Module 11) or cover both spellings.

Q4. Why does location = / exist in so many high-performance configs when location / already matches everything?

Answer

= / answers the single hottest URI (the homepage) with one hash lookup — no prefix-walk, no regex scan — and lets location / stay a general catch-all. It's the same hot-path thinking as CPU caches: optimize the one entry everyone hits.

Takeaways

  • Four moves: exact hash-hit → longest prefix (if ^~: stop) → first regex in file order → fall back to longest prefix.

  • Prefixes compete by length, regexes by position, = by hash — three different currencies; never confuse them.

  • Matching happens on the decoded URI; $request_uri is the untouched original — the distinction explains percent-escape surprises and powers Module 11's rewrite work.

  • ^~ is the sanctioned way to shield a subtree from regexes; nested locations are the unsanctioned way to multiply confusion.

  • @named locations are internal jump targets — the "functions" you'll call from try_files and error_page from the next module onward.

Was this page helpful?

Mark this learning resource complete to track your learning progress.