Nginx Location Priority: Matching Order Explained
Nginx does not read your location blocks from top to bottom and stop at the first one that fits. That single misunderstanding is behind most “my location block isn’t working” bug reports. For prefix locations, where a block sits in the file has no effect at all: nginx compares all of them and keeps the longest match.
Nginx location priority follows a fixed sequence of four steps:
- Exact match. If a
location = /pathequals the URI, nginx uses it and stops there, without comparing prefixes or running a single regex. - Longest prefix. Nginx compares every prefix location (
location /pathandlocation ^~ /path) that the URI starts with, then remembers the longest one instead of using it. - The
^~short-circuit. If the remembered prefix carries^~, nginx skips the regex phase and uses that block. - Regex, in file order. Nginx tries
~and~*locations in the order they appear in the configuration, and the first match wins. If none matches, the prefix remembered in step 2 handles the request.
Two of those rules pull in opposite directions: prefixes are picked by length regardless of order, regexes by order regardless of length. Reading a config top to bottom will never surface that conflict. For your own file rather than the examples below, paste it into the free nginx location tester, which replays this sequence and reports the stage at which each losing block dropped out. The tester runs in your browser, so a production config never leaves the page. Every matching rule described here was checked against a running nginx 1.27.5 rather than lifted from secondary write-ups.
Nginx Location Priority at a Glance
Five modifiers take part in URI matching, plus one that does not.
| Modifier | Syntax | Matches by | Stops the regex phase | Typical use |
|---|---|---|---|---|
= | location = /path | Equality | Yes | Hot paths like / or /favicon.ico |
^~ | location ^~ /path | Starts with | Yes, if it is the longest prefix | Directories that must never reach a regex |
~ | location ~ regex | PCRE, case-sensitive | No | Extension routing where case matters |
~* | location ~* regex | PCRE, case-insensitive | No | Extension routing where case does not |
| (none) | location /path | Starts with | No | General routing by path |
@ | location @name | Never matched by URI | — | error_page and try_files targets |
The rule of thumb: an exact match beats everything, a regex beats a prefix, and prefixes beat each other on length. Read the table as a ranking only in the loosest sense, though. ^~ sits above ~ in it, yet a ^~ block regularly loses to a regex, because the modifier is only consulted on whichever prefix already won on length.
The Four-Step Selection Algorithm
The nginx location matching order is easiest to learn from a configuration that exercises every rule at once. The example in the nginx documentation does exactly that:
server {
location = / { return 200 "A\n"; }
location / { return 200 "B\n"; }
location /documents/ { return 200 "C\n"; }
location ^~ /images/ { return 200 "D\n"; }
location ~* \.(gif|jpg|jpeg)$ { return 200 "E\n"; }
}
Five requests, five different answers:
| Request | Winner | Why |
|---|---|---|
/ | A | Exact match. The search ends immediately. |
/index.html | B | No regex matched, so the remembered prefix is used. |
/documents/document.html | C | Longer prefix than /. |
/images/1.gif | D | ^~ won the prefix stage, so the regex never ran. |
/documents/1.jpg | E | The regex beat a longer prefix that lacked ^~. |
Compare the last two rows. /images/ and /documents/ are both prefixes, both match, both are the longest match for their request. One request goes to the prefix block and the other goes to the regex. The only difference is two characters.
Step 2 is the part people skip: nginx does not use the longest prefix, it remembers it. The block is a candidate, and the regex phase can still take the request away. Only steps 1, 3 and 4 end the search.
Why “longest prefix” is measured in characters, not path segments
Prefix comparison is a plain string comparison. It does not know that / separates path segments, and it does not stop at a boundary. Given this config:
server {
location /static { }
location /static/ { }
}
a request for /staticfoo is served by location /static. The URI starts with those seven characters, so it matches. /static/ does not match at all, because there is no slash in that position. A request for /static/x goes the other way and takes /static/, the longer of the two.
The consequence is that location /static also owns /static-backup, /staticfiles and anything else that happens to begin with the same letters. If you meant a directory, write the trailing slash and add an exact location for the bare path:
server {
location /static/ { root /var/www; }
location = /static { return 301 /static/; }
}
Most tutorials use tidy path examples that never expose this, which is why it survives code review so often. The nginx location tester lists every block that matched along with how many characters it matched on, so a prefix quietly swallowing its siblings shows up in the table.
What ^~ Actually Means (and What It Doesn’t)
The common description of the nginx location ^~ modifier is “it makes this block take priority over regexes.” That is close enough to be dangerous.
What ^~ really does: nothing at all during the prefix comparison. It does not lengthen the match and it does not raise the block above other prefixes, so the prefix nginx remembers is the same either way. It is checked afterwards, on the single prefix that already won on length. If that winner carries ^~, the regex phase is skipped. If it does not, the regex phase runs as normal.
Which means a longer plain prefix silently disables it:
server {
location ^~ /a/ { }
location /a/b/ { }
location ~ \.php$ { }
}
A request for /a/b/x.php is handled by ~ \.php$. /a/b/ is the longest matching prefix, so that is what nginx remembers; its plain modifier permits the regex phase; the regex matches first and takes the request. The ^~ block is still sitting in the file looking protective, and it has no bearing on this request at all.
Change the URI to /a/x.php and the same config behaves differently: now ^~ /a/ is the longest match, the regex phase is skipped, and the ^~ block wins.
This is not an academic distinction. The canonical use of ^~ is keeping a writable directory away from an interpreter:
server {
location ^~ /uploads/ { }
location ~ \.php$ { fastcgi_pass unix:/run/php-fpm.sock; }
}
Drop the ^~ and a request for /uploads/evil.php goes straight to PHP-FPM. That is the shape behind a long line of upload-to-RCE reports. It is also why the defeated ^~ case matters: adding a longer plain prefix such as location /uploads/thumbs/ reopens the hole for everything underneath it, and the diff that does so looks like a routine addition.
The scope matters too. ^~ only suppresses regexes declared at its own level. It never suppresses regexes nested inside its own block, and a ^~ on a nested location cannot protect against a regex declared at the server level. Toggle the modifier in the nginx location tester to watch the winner change; the regexes it skipped stay in the table with a label on them.
Regex Locations: Order Beats Specificity
An nginx location regex uses ~ for case-sensitive matching and ~* for case-insensitive. Prefix matching, by contrast, is always case-sensitive on Linux. (On case-insensitive filesystems such as macOS, nginx compares prefixes case-insensitively and forces every regex location to behave like ~*. If you develop on a Mac and deploy to Linux, that difference can hide a broken rule until it ships.)
Regexes are evaluated in the order they appear in the configuration file, and the first match ends the search. Specificity, length and anchoring count for nothing.
server {
location ~ ^/a { }
location ~ ^/a/b/c$ { }
}
A request for /a/b/c is taken by ~ ^/a. The second block matches the URI exactly, is far more precise, and will never execute for any request at all. It is dead configuration that nginx -t accepts without a word.
Order regexes from most specific to least, and keep the list short. A broad pattern near the top makes everything below it unreachable, and since a diff that only reorders lines reads as harmless, the regression tends to arrive during a tidy-up rather than during a feature.
Anchoring deserves the same care. location ~ /admin is anchored at neither end, so it searches anywhere in the URI and happily matches /public/admin/x. Write ~ ^/admin when you mean the start. Anchoring only at the end, as in ~ \.php$, is normal and correct for extension routing.
A few PCRE details that JavaScript-shaped intuitions get wrong:
- nginx compiles location patterns with PCRE, without UTF or multiline mode, so patterns operate on bytes and
^anchors to the start of the URI only. - PCRE’s
$also matches just before a trailing newline. A URI ending in%0Astill satisfies\.php$, which is a known way to slip past rules keyed on a file extension. - Constructs with no JavaScript equivalent are common in PCRE: atomic groups
(?>…), possessive quantifiersa*+, inline modifiers like(?i), POSIX classes like[[:alpha:]], and escapes such as\A,\z,\Kand\Q…\E. - A pattern containing
{or}must be quoted.location ~ ^/a{2}$fails to load withunknown directive "2}$", because the brace ended the token. Writelocation ~ "^/a{2}$".
Capture groups work the way you would hope, and $1 onwards are available inside the block:
upstream backend {
server 127.0.0.1:8080;
}
server {
location ~ ^/user/(\d+)/profile$ {
proxy_pass http://backend/profiles/$1;
}
}
If you are still debugging the pattern itself rather than its position in the file, test it in the regex tester first; the regex cheat sheet covers the syntax in depth.
URI Normalisation Happens Before Matching
Before any location is consulted, nginx rewrites the request target. Your patterns are compared against the normalised path, not against the bytes that arrived on the wire, and that rewrite decides a surprising number of “my location doesn’t match” cases.
Normalisation does four things: it splits off the query string, percent-decodes the path, resolves . and .. segments, and collapses repeated slashes.
| Request target | Normalised $uri | Note |
|---|---|---|
//a//x | /a/x | Repeated slashes merged |
/a/../b/x | /b/x | .. resolved before matching |
/a/b%2F..%2Fzz | /a/zz | %2F decodes to a real separator and joins the resolution |
/a/%2e%2e/b/x | /b/x | %2E decodes to a dot that participates too |
/a%20b/x | /a b/x | %20 becomes a real space |
/a+b/x | /a+b/x | + is not a space in a path |
/a?x=/b | /a | Query string split off first |
/a%3Fx=1 | /a?x=1 | %3F stays literal; the query string is empty |
Three decoded characters are exceptions: %25, %23 and %3F get written back out verbatim instead of being re-interpreted. That is why /a%3Fx=1 ends up with a question mark inside the path and nothing in $args.
Two targets never reach location selection at all. .. segments that climb above the root and an invalid escape such as %00 are both rejected with 400 before matching starts.
That matters most when a location block is your access-control boundary, because the path you wrote is compared against the resolved path:
server {
location /a/ { }
location /b/ { }
}
A request for /a/b%2F..%2Fzz does not stay under /a/b/. It normalises to /a/zz and is handled by location /a/. Reasoning about the raw target rather than $uri gives the wrong answer here, and “wrong answer” in an access-control context has a specific name. Before you rely on location /admin to protect anything, confirm what the normalised path actually is. The nginx location tester prints the raw target, the normalised $uri and the split-off query string as three separate rows. For the encoding on its own, the URL decoder is enough.
The query string never participates in matching. location /search?q= cannot match a request for /search?q=1, because selection only ever sees /search. To branch on a parameter, read $arg_name inside the block.
Five Configs That Don’t Do What You Think
A ^~ block losing to a longer plain prefix
Symptom: a ^~ directory looks protected, and a regex handles requests inside it anyway.
Cause: ^~ is only consulted on the prefix that already won on length. A longer plain prefix is remembered instead, and it does not suppress anything.
Fix: put ^~ on the longer prefix as well, or remove the longer prefix.
# Broken: /a/b/x.php goes to the regex
location ^~ /a/ { }
location /a/b/ { }
location ~ \.php$ { }
# Fixed: /a/b/x.php goes to ^~ /a/b/
location ^~ /a/ { }
location ^~ /a/b/ { }
location ~ \.php$ { }
A prefix without a trailing slash catching its siblings
Symptom: a block intended for one directory also serves paths that merely start with the same letters.
Cause: prefix matching compares characters, not path segments, so location /app also matches /application.
Fix: write the trailing slash, and add location = /app if the bare path needs handling too.
The specific regex placed below the broad one
Symptom: a precise rule never fires, and no error appears anywhere. Cause: regexes are tried in file order and the first match ends the search, so everything below a broad pattern is unreachable. Fix: move the specific pattern above the broad one, or tighten the broad one with an anchor.
A regex written after ^~
Symptom: a deny rule loads cleanly and blocks nothing.
Cause: ^~ takes a literal prefix, not a pattern. nginx never complains; the block simply never matches a URI.
Fix: use the regex modifier.
# Broken: matches nothing, loads without error
location ^~ "\.php$" { deny all; }
# Fixed
location ~ \.php$ { deny all; }
Assuming the query string takes part
Symptom: a location containing ? never matches.
Cause: the query string is split off during normalisation and location selection runs against the path alone.
Fix: match on the path and inspect $arg_name inside the block.
location /search {
if ($arg_q = "") { return 400; }
}
Debugging: Find Out Which Block Actually Won
The debug log gives the authoritative answer, and it also costs the most to set up. It needs a binary built with debug support, so check for that first:
nginx -V 2>&1 | grep -o with-debug
Then enable it and grep for the line that names the selected block:
error_log /var/log/nginx/debug.log debug;
grep "using configuration" /var/log/nginx/debug.log
Response-header probes are quicker and need no debug build. Tag each candidate and read the headers back:
location ^~ /uploads/ {
add_header X-Debug-Location "uploads-caret" always;
return 204;
}
location ~ \.php$ {
add_header X-Debug-Location "php-regex" always;
return 204;
}
curl -sI --path-as-is 'http://localhost/uploads/evil.php' | grep -i x-debug-location
--path-as-is matters: without it curl helpfully resolves .. on your behalf and you end up testing a different URI than the one you meant. If you are assembling something more elaborate, the curl builder writes the flags for you, and the curl cheat sheet covers the rest. When the probe comes back as a redirect or a 404 rather than your header, the HTTP status codes reference will usually tell you which module produced it.
nginx -T prints the fully merged configuration, include files and all. It is the only way to see what order your regexes are really in once six files have been assembled, which is rarely the order they appear in the file you were editing.
nginx -T | grep -n "location"
Reproduce the failure in the nginx location tester before you edit the server. Iterating against a config you have not deployed beats waiting on a reload cycle, and the decision table already names the stage each block was eliminated at.
Nested Locations, try_files and What They Don’t Change
Nested locations run the same algorithm one level down. Once a prefix location wins, nginx descends into its children and repeats the search there, which means a nested regex is tried before the parent level’s regexes:
server {
location ~ \.php$ { }
location /a/ {
location ~ \.php$ { return 200 "nested\n"; }
}
}
/a/x.php is handled by the nested block. Nesting also has a less obvious effect: it can make a globally longer prefix unreachable, because only the winner at each level is descended into. If location /a/bb/ is nested inside location /a/, and a sibling location /a/b sits at the outer level, a request for /a/bb/x goes to /a/b. The outer comparison happens first, and /a/b wins it. A descent that finds nothing does not backtrack, either; the parent keeps the request.
try_files and rewrite are not part of selection. They execute inside whichever block already won, and they cannot reach back and change that. If a request never arrives at the block containing your try_files, the directive is irrelevant, and the usual culprit is a ~ \.php$ regex taking the request before the prefix block ever gets a turn. One exception: an internal redirect (rewrite … last, or an error_page jump) restarts matching, so the rewritten URI is resolved against the location list again from the top.
Finally, the 301 you get when requesting a directory without a trailing slash is not a matching failure. Two separate mechanisms produce it. If a location whose name ends in / carries proxy_pass or another *_pass directive, a request for the same path without the slash is answered with a 301 during selection, before any regex is evaluated, and with the query string preserved:
server {
location /user/ { proxy_pass http://backend/; }
}
# GET /user?x=1 -> 301 to /user/?x=1
Adding location = /user suppresses that redirect. Separately, the static file module issues its own 301 when a path resolves to a real directory on disk, which depends on your filesystem rather than your config.
FAQ
What are the five nginx location modifiers?
The five nginx location modifiers are = for exact matching, ^~ for a prefix that skips the regex phase, no modifier for an ordinary prefix, ~ for a case-sensitive regex and ~* for a case-insensitive one. A sixth form, location @name, never takes part in URI matching and exists only as a target for error_page and try_files.
Does an exact = match make nginx faster?
An exact match location ends the search immediately, skipping the prefix scan and every regex evaluation. The saving is real but too small to notice. It is worth writing for endpoints hit thousands of times a second, such as health checks or /favicon.ico. A pile of = blocks for ordinary pages costs more in config complexity than it buys.
Can I write a location modifier without a space, like ~*^/api?
Yes. location ~*^/api/ and location ~* ^/api/ mean exactly the same thing, because nginx strips the modifier off the front of the name and matches the longest modifier first, so ~* is recognised before ~. Keep the space anyway. A glued modifier reads like part of the pattern and gets misparsed by humans during review.
What is the difference between root and alias inside a location block?
root appends the whole URI to the directory, while alias replaces the matched prefix with it. With location /static/ { root /var/www; } a request for /static/x.css looks for /var/www/static/x.css; switch to alias /var/www/assets/; and it looks for /var/www/assets/x.css. With alias, either give both the location and the path a trailing slash or give neither one.
Can one request match more than one location block?
Several blocks can match, but exactly one handles the request. nginx compares every prefix location and, when needed, every regex location, then hands the request to the single winner. Directives are not inherited from the losing blocks, so anything you need everywhere has to live at server or http level, or be repeated.
Does nginx -t tell me which location will match?
No. nginx -t checks syntax and configuration validity; it never simulates a request, so it reports nothing about matching order. To find out which block owns a URI, read the debug log, add a temporary response header, or paste the config into the nginx location tester and read why each block won or lost.