How nginx Chooses a location Block: Prefixes, Regexes and ^~
Every request nginx handles ends up in exactly one location block, and which one it picks decides everything else: the root, the headers, whether PHP runs. The rules are short, but they are not "first match wins" and they are not "longest match wins" either. They are a mix of both, and the surprises come from that mix.
Instead of reasoning about it on paper, I built a config where every location answers with its own name, then sent many URIs to it with curl. All of this ran on nginx 1.30.6 from the official nginx:stable-alpine image, in a temporary container published on 127.0.0.1.
The algorithm in five steps
For a given request URI, nginx does this:
- If a
location = /exactblock matches the URI exactly, use it and stop. - Find the longest matching prefix location (plain
location /pathorlocation ^~ /path) and remember it. - If that longest prefix has the
^~modifier, use it and stop. Regexes are never tested. - Otherwise test the regex locations (
~case-sensitive,~*case-insensitive) in the order they appear in the config. The first one that matches wins. - If no regex matches, use the longest prefix remembered in step 2.
So prefixes compete on length, and regexes compete on position in the file. Where a prefix location sits in the file does not matter at all. Where a regex sits matters a lot.
A config that tells you what matched
Each location returns a plain-text label, so the response body says which block answered:
server {
listen 80;
server_name _;
default_type text/plain;
location = / {
return 200 "matched: = /\n";
}
location / {
return 200 "matched: / (prefix)\n";
}
location /images/ {
return 200 "matched: /images/ (prefix)\n";
}
location ^~ /static/ {
return 200 "matched: ^~ /static/\n";
}
location /api {
return 200 "matched: /api (prefix, no slash)\n";
}
location /api/v2/ {
return 200 "matched: /api/v2/ (prefix)\n";
}
location ~ \.php$ {
return 200 "matched: ~ php regex\n";
}
location ~* \.(jpg|png|gif)$ {
return 200 "matched: ~* image regex\n";
}
location ~ ^/images/.*\.png$ {
return 200 "matched: ~ /images/ png regex\n";
}
}$ docker run -d --name rg-loc -p 127.0.0.1:18083:80 \
-v "$PWD/match.conf:/etc/nginx/conf.d/default.conf:ro" nginx:stable-alpine
$ docker exec rg-loc nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ for u in / /index.html /images/ /images/logo.png /images/LOGO.PNG \
/images/x.php /static/logo.png /static/x.php /api /api/ /apiary \
/api/v2 /api/v2/users /api/v2/x.php /about/photo.JPG; do
printf '%-20s %s\n' "$u" "$(curl -s "http://127.0.0.1:18083$u")"
done
/ matched: = /
/index.html matched: / (prefix)
/images/ matched: /images/ (prefix)
/images/logo.png matched: ~* image regex
/images/LOGO.PNG matched: ~* image regex
/images/x.php matched: ~ php regex
/static/logo.png matched: ^~ /static/
/static/x.php matched: ^~ /static/
/api matched: /api (prefix, no slash)
/api/ matched: /api (prefix, no slash)
/apiary matched: /api (prefix, no slash)
/api/v2 matched: /api (prefix, no slash)
/api/v2/users matched: /api/v2/ (prefix)
/api/v2/x.php matched: ~ php regex
/about/photo.JPG matched: ~* image regexThe same results as a table, with the step of the algorithm that decided each one:
| Request URI | Answered by | Why |
|---|---|---|
/ | = / | exact match, step 1 |
/index.html | / | exact match needs the whole URI, so only the prefix fits |
/images/ | /images/ | longest prefix, no regex matches |
/images/logo.png | ~* image regex | first regex that matches, beats the longer prefix |
/images/LOGO.PNG | ~* image regex | ~* ignores case |
/images/x.php | ~ php regex | regex takes it from /images/ |
/static/x.php | ^~ /static/ | ^~ stops before regexes, step 3 |
/apiary | /api | prefix match is plain string comparison |
/api/v2 | /api | /api/v2/ needs the trailing slash |
/api/v2/users | /api/v2/ | longest prefix |
/api/v2/x.php | ~ php regex | regex beats even the longest prefix |
Surprise 1: a regex takes /images/x.php
This is the one that causes real security bugs. You write location /images/ { ... } with some careful settings, and assume everything under /images/ uses them. It does not. Any URI that a regex location also matches goes to the regex, because step 4 runs after the prefix is found and overrides it. In a typical PHP config that means /images/anything.php is passed to PHP even though you thought /images/ was static only.
The fix is ^~, as /static/ shows. /static/x.php stayed in the static location because ^~ tells nginx not to look at regexes once that prefix wins. I use ^~ on almost every prefix location that serves files, unless I specifically want regexes to apply inside it.
Surprise 2: regex order decides
The ~ ^/images/.*\.png$ location in the config above never answered anything, even for /images/logo.png, which it clearly matches. The image regex comes first in the file and matched first. I moved the specific regex above the general one in a second config:
location ~ ^/images/.*\.png$ {
return 200 "matched: ~ /images/ png regex\n";
}
location ~* \.(jpg|png|gif)$ {
return 200 "matched: ~* image regex\n";
}/images/logo.png matched: ~ /images/ png regex
/images/logo.jpg matched: ~* image regexA simple rule: put specific regexes before general ones. Also be careful with include files that add regex locations. Their position in the final config is where the include line sits.
Surprise 3: trailing slashes are just characters
Prefix matching is a plain string comparison. location /api matches /api, /api/, and also /apiary and /api-docs. If you mean the directory, write location /api/. In the other direction, location /api/v2/ did not match /api/v2, which fell back to /api.
There is one place where nginx helps. Take a prefix location that ends in a slash and uses proxy_pass (or fastcgi_pass and similar directives). A request for the same path without the slash gets a 301 to the slashed version. I tested it with location /app/ { proxy_pass http://127.0.0.1:8080/; }:
$ curl -sI http://127.0.0.1:18083/app | grep -iE '^(HTTP|Location)'
HTTP/1.1 301 Moved Permanently
Location: http://127.0.0.1/app/That redirect comes from the *_pass directives. With return, as /api/v2 showed above, nginx simply falls back to a shorter prefix.
Surprise 4: nested locations
Locations can be nested, and a nested regex is tested before the regexes at server level. The second test config had this:
location /uploads/ {
location ~ \.php$ {
return 403 "matched: nested php regex in /uploads/\n";
}
return 200 "matched: /uploads/ (prefix)\n";
}
location ~ \.php$ {
return 200 "matched: ~ php regex\n";
}/uploads/a.txt matched: /uploads/ (prefix)
/uploads/shell.php matched: nested php regex in /uploads/
/uploads/x.png matched: ~* image regex
/x.php matched: ~ php regexThe nested PHP regex won for /uploads/shell.php, which is a simple way to block scripts in an upload directory. But /uploads/x.png still went to the server-level image regex, because the nested block only contains a PHP regex. Nesting does not protect the prefix from other regexes. ^~ does.
Surprise 5: $ inside a return string
My first attempt at the test config had labels like "matched: ~ \.php$\n". nginx refused to start, and nginx -t said why:
$ docker run --rm --network none \
-v "$PWD/match-broken.conf:/etc/nginx/conf.d/default.conf:ro" \
--entrypoint nginx nginx:stable-alpine -t
2026/10/17 19:05:59 [emerg] 1#1: invalid variable name in /etc/nginx/conf.d/default.conf:31
nginx: [emerg] invalid variable name in /etc/nginx/conf.d/default.conf:31
nginx: configuration file /etc/nginx/nginx.conf test failedStrings in return are parsed for variables, so $\n looked like a variable with no name. It is a good reminder to run nginx -t before every reload.
Debugging which location matched
The return 200 "matched: ..." trick is the quickest method: copy the real location lines into a scratch server block, replace their bodies with labelled returns, and test URIs against it. On a server that has to keep serving real responses, a temporary header in each suspect block gives the same information without changing the body. The always parameter makes it appear on error responses too:
location /images/ {
add_header X-Location "images prefix" always;
}
location ~ \.php$ {
add_header X-Location "php regex" always;
return 404;
}$ curl -sI http://127.0.0.1:18083/images/x.php | grep -iE '^(HTTP|X-Location)'
HTTP/1.1 404 Not Found
X-Location: php regex
$ curl -sI http://127.0.0.1:18083/images/a.gif | grep -iE '^(HTTP|X-Location)'
HTTP/1.1 404 Not Found
X-Location: images prefixFor full details, nginx can log each location test, but only when built with --with-debug. The official image ships two binaries, and only one of them has it:
$ docker exec rg-loc sh -c 'ls /usr/sbin/nginx*'
/usr/sbin/nginx
/usr/sbin/nginx-debug
$ docker exec rg-loc nginx-debug -V 2>&1 | grep -o -- '--with-debug'
--with-debugRunning plain nginx -V showed no --with-debug, and with error_log ... debug it wrote no debug lines at all. So I added error_log /var/log/nginx/error.log debug; to the server block and started the container with nginx-debug -g 'daemon off;' as the command instead:
$ curl -s http://127.0.0.1:18083/images/x.php
matched: ~ php regex
$ docker logs rg-loc 2>&1 | grep -E 'http uri|test location|using conf'
2026/10/17 19:06:10 [debug] 21#21: *1 http uri: "/images/x.php"
2026/10/17 19:06:10 [debug] 21#21: *1 test location: "/"
2026/10/17 19:06:10 [debug] 21#21: *1 test location: "images/"
2026/10/17 19:06:10 [debug] 21#21: *1 test location: ~ "\.php$"
2026/10/17 19:06:10 [debug] 21#21: *1 using configuration "\.php$"You can see the prefix tree walk, which finds /images/, then the regex test that overrides it. The same request for /static/x.php ends with using configuration "/static/" and no regex test at all. That is ^~ working as intended. Debug logging is very noisy, so use it on a test container, not on production.
The official description of the matching order is in the location directive docs. For what happens inside the location once it is chosen, the try_files article continues from here.