An nginx location selects configuration for a request within the chosen server. For the flat prefix and regex locations tested here, an exact match finishes the search; otherwise nginx remembers the longest matching prefix and can then choose the first matching regex. If no applicable regex wins, the remembered prefix supplies the configuration. This is a selection process, not simply the first block encountered in the file.

When it breaks

A prefix is a string prefix, not automatically a complete path segment. location /api also matches /apix. If the intended family is the endpoint /api plus descendants under /api/, express those boundaries deliberately. Depending on an unstated segment boundary can apply the wrong handler, cache policy, or access rule to paths that merely share the same initial letters.

The longest ordinary prefix is not always the final choice. A regex matching .php can take a request that also matched a more detailed API prefix. Moving that prefix above the regex does not change the basic priority rule. Configuration order matters for competing regexes, while ordinary prefixes are compared by matching length rather than declaration position.

The ^~ modifier suppresses regex searching when that prefix is the selected longest prefix at the relevant level. It is not a regex operator, and it is not an unconditional protection for every path that begins with a shorter marked prefix. Nested location structures and internal redirects require additional reasoning; the following fixture intentionally uses a flat server to make each selection unambiguous.

Check it quickly

Run the script with host Python, Docker, and the pinned nginx image available. The recorded environment used nginx 1.28.0 on Linux arm64 with Docker 29.3.1. It builds a temporary configuration image, validates it with nginx -t, sends real HTTP requests through a port published only on host loopback, and removes its own containers, image tags, and network:

from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid

NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def wait(predicate):
    deadline=time.monotonic()+15
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except OSError:pass
        time.sleep(.1)
    raise AssertionError('fixture did not become ready')

@contextmanager
def lab(configuration,files=None,backend=None):
    name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
    try:
        with TemporaryDirectory(prefix=name+'-') as directory:
            root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
            for path,content in (files or {}).items():
                target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content)
            docker('network','create',name)
            if backend is not None:
                (root/'backend.py').write_text(backend)
                (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
                docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
                docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
                wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
            (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
            docker('build','--network','none','-t',tags[0],str(root))
            docker('run','--rm','--network',name,tags[0],'nginx','-t')
            docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
            details=json.loads(docker('inspect',containers[0]).stdout)[0]
            binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
            def ready():
                connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
            wait(ready)
            def request(path,method='GET',headers=None,body=None):
                connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
                try:
                    connection.request(method,path,body=body,headers=headers or {})
                    response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
                finally:connection.close()
            yield containers[0],request
    finally:
        docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)

def config(locations,extra=''):
    return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'

locations=r'''
location / { return 200 "fallback"; }
location /api/ { return 200 "api-slash"; }
location /api/admin/ { return 200 "admin"; }
location = /api { return 200 "exact"; }
location /api { return 200 "api-string"; }
location ^~ /static/ { return 200 "static"; }
location ~ \.php$ { return 200 "php-regex"; }
'''
with lab(config(locations)) as (container,request):
    cases={'/api':'exact','/api?x=1':'exact','/apix':'api-string','/api/users':'api-slash','/api/admin/log':'admin','/api/admin/test.php':'php-regex','/static/test.php':'static','/unmatched':'fallback'}
    for path,expected in cases.items():
        status,headers,body=request(path);assert status==200 and body.decode()==expected,(path,status,body)
    print('exact matching ignores the query; ordinary prefixes use longest string match, including /api matching /apix')
    print('a regex overrides the ordinary longest prefix, while the selected ^~ prefix skips that regex')

Both /api and /api?x=1 return exact. The query string does not participate in location matching, so adding an argument does not make the exact path stop matching. The selected block explicitly returns that marker. Exact matching selects configuration; it does not inherently mean a filesystem root is read or a particular application is invoked.

The request /apix returns api-string, demonstrating that the plain /api prefix reaches beyond a complete API segment. /api/users instead returns api-slash, because /api/ is the longer matching prefix. /api/admin/log returns admin, showing the next level of ordinary longest-prefix selection without relying on the order those blocks appear.

Requesting /api/admin/test.php returns php-regex. The long /api/admin/ prefix was a candidate, but the regex phase selected another block. This is the important failure shape for a route whose author assumed a longer prefix automatically defeated a general script handler. A successful status alone would conceal the mistake, so each handler returns a distinguishable body.

Requesting /static/test.php returns static, because the selected /static/ prefix has ^~. The same suffix regex therefore does not take that request. An unmatched path returns the broad fallback marker. Together, these cases demonstrate the actual selection boundaries rather than merely proving that nginx accepts the configuration syntax.

The client sends the paths explicitly and checks response bodies as well as statuses. The fixture contains no real authentication policy or script execution, so its markers validate routing decisions only. When applying the pattern to a protected endpoint, test the actual handler and access behavior that will run after selection.

Review the selected handler and its later transitions

Start with the intended URL families, then list exact endpoints, descendant prefixes, and any overlapping regexes. Include near-miss paths such as /apix, case variants where relevant, and encoded or normalized forms. nginx matches a normalized URI, so a security review based only on the raw request text can miss which configuration ultimately applies.

Inspect the effective configuration, including included files and the selected virtual server. A correct location in a server block that the request never enters cannot fix routing in another block. Use controlled response markers or logs during verification rather than inferring selection from a familiar page returned by several handlers.

Finally, account for rewrites, index handling, and other internal redirects that can trigger a new location search. The first selected block may not be the final handler. Test the full request path and preserve enough evidence to explain both the initial match and the response actually produced.

Sources