Web

How nginx Chooses a location Block: Prefixes, Regexes and ^~

By Nawawi JamiliPublished 7 min read

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:

  1. If a location = /exact block matches the URI exactly, use it and stop.
  2. Find the longest matching prefix location (plain location /path or location ^~ /path) and remember it.
  3. If that longest prefix has the ^~ modifier, use it and stop. Regexes are never tested.
  4. Otherwise test the regex locations (~ case-sensitive, ~* case-insensitive) in the order they appear in the config. The first one that matches wins.
  5. 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:

nginx
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";
    }
}
terminal
$ 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 regex

The same results as a table, with the step of the algorithm that decided each one:

Request URIAnswered byWhy
/= /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 regexfirst regex that matches, beats the longer prefix
/images/LOGO.PNG~* image regex~* ignores case
/images/x.php~ php regexregex takes it from /images/
/static/x.php^~ /static/^~ stops before regexes, step 3
/apiary/apiprefix 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 regexregex 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:

nginx
    location ~ ^/images/.*\.png$ {
        return 200 "matched: ~ /images/ png regex\n";
    }
    location ~* \.(jpg|png|gif)$ {
        return 200 "matched: ~* image regex\n";
    }
output
/images/logo.png     matched: ~ /images/ png regex
/images/logo.jpg     matched: ~* image regex

A 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/; }:

terminal
$ 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:

nginx
    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";
    }
output
/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 regex

The 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:

terminal
$ 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 failed

Strings 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:

nginx
    location /images/ {
        add_header X-Location "images prefix" always;
    }
    location ~ \.php$ {
        add_header X-Location "php regex" always;
        return 404;
    }
terminal
$ 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 prefix

For 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:

terminal
$ 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-debug

Running 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:

terminal
$ 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.