nginx does not assign a general specificity score to every location expression. Ordinary prefixes are selected by matching length, exact locations can finish the search, and competing regex locations are evaluated in declaration order. A regex that looks narrower to a human can lose to a broader expression placed earlier. Mixing these selection rules is a common reason apparently careful routing changes affect unexpected paths.
When it breaks
A general suffix regex for .css can win before a later regex restricted to /assets/. The later expression may look more specific because it names a directory, but nginx does not compare the apparent specificity of those two patterns. If they both match, their order decides which configuration handles the request in the flat structure used here.
Reordering ordinary prefix blocks is a different operation. Moving /assets/ above / does not make it win more strongly; its longer matching prefix already gives it priority when the prefix result applies. A review that treats all location declarations as one first-match list can therefore make ineffective changes while missing the regex order that actually controls the failing route.
A shorter ^~ prefix is also not a permanent shield around an entire subtree. If a longer ordinary prefix is selected, the shorter marked prefix is no longer the candidate that suppresses the regex phase. Adding one seemingly harmless more-detailed prefix can therefore allow a suffix regex to take requests that previously stayed in the marked location.
Check it quickly
Run the script with host Python, Docker, and the pinned nginx image. The recorded environment used nginx 1.28.0 on Linux arm64 with Docker 29.3.1. It builds two temporary configurations, checks each with nginx -t, and issues real requests through host-loopback ports. Each configuration uses only flat locations, and its containers, image tags, and private network are cleaned up:
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'
prefixes=[
'location / { return 200 "fallback"; }',
'location /assets/ { return 200 "asset-prefix"; }',
'location = /assets/exact.css { return 200 "exact"; }',
'location ^~ /static/ { return 200 "static-prefix"; }',
'location /static/dynamic/ { return 200 "dynamic-prefix"; }']
regexes=[r'location ~ \.css$ { return 200 "generic-css"; }',r'location ~ ^/assets/ { return 200 "asset-regex"; }']
for reverse,expected in [(False,'generic-css'),(True,'asset-regex')]:
ordered_prefixes=list(reversed(prefixes)) if reverse else prefixes
ordered_regexes=list(reversed(regexes)) if reverse else regexes
locations='\n'.join(ordered_prefixes+ordered_regexes)+'\n'+r'location ~* \.txt$ { return 200 "case-folded-text"; }'
with lab(config(locations)) as (container,request):
cases={'/assets/file.css':expected,'/assets/exact.css':'exact','/static/file.css':'static-prefix','/static/dynamic/file.css':'generic-css','/static/dynamic/plain':'dynamic-prefix','/README.TXT':'case-folded-text','/elsewhere.CSS':'fallback','/elsewhere':'fallback'}
for path,wanted in cases.items():
status,headers,body=request(path);assert status==200 and body.decode()==wanted,(path,status,body)
print('reversing the matching regex declarations changes the winner; the first matching regex is not necessarily the most specific')
print('exact locations and longest-prefix selection ignore declaration order; a longer ordinary prefix can make a shorter ^~ prefix irrelevant')
The first configuration places the .css regex before the regex beginning with /assets/. Requesting /assets/file.css returns generic-css. The second configuration reverses those regexes, and the identical request returns asset-regex. Nothing in the request became more or less specific; only the order of two simultaneously matching patterns changed.
The script also reverses the ordinary prefix declarations between configurations. Their relevant results remain stable. /static/dynamic/plain continues to use the longer ordinary prefix, and the broad fallback handles an unrelated path. This comparison separates textual rearrangement of prefixes from textual rearrangement of regexes instead of assuming both edits have the same routing effect.
An exact /assets/exact.css location wins in both configurations despite matching both regex patterns. It returns exact. The test therefore includes a route deliberately isolated from regex ordering, rather than presenting the first matching regex as an unconditional rule that overrides every other location type.
The path /static/file.css stays in the ^~ /static/ prefix and returns static-prefix. But /static/dynamic/file.css has a longer ordinary prefix, so the .css regex is considered and returns generic-css. This verifies the selected-prefix qualification on ^~; the modifier belongs to that location, not to an invisible inherited protection on every deeper prefix.
Case modifiers are tested separately. The ~* text-file pattern matches /README.TXT, while the case-sensitive .css pattern does not match /elsewhere.CSS, which reaches the fallback. These are observations for the explicit regexes and Linux environment in the fixture, not a claim that all filesystem paths or every platform use identical case rules.
Make overlaps visible in review
For each regex, identify paths that also match earlier expressions and record which handler is intended to win. Include boundary cases where two patterns overlap, as well as cases where neither matches. A test that exercises only one regex in isolation cannot reveal the priority conflict introduced when another expression is added before it.
Use exact or prefix locations when they naturally describe the route, and keep intentional regex overlaps understandable. A regex is not inherently wrong, but its ordering becomes part of the service contract. Changes to included configuration can alter the effective sequence, so inspect the complete loaded configuration rather than reviewing a file fragment without its surrounding context.
When adding a longer prefix below an existing marked prefix, retest suffix routes through that new path. Do not assume the earlier ^~ still governs the request. Finally, verify downstream behavior such as cache headers, proxying, and access control in the winning block; selecting the expected marker is a routing test, not proof that every policy inside that block is correct.
Sources
- nginx core location directive, first matching regex, prefix priority, and case modifiers, checked 2026-09-12.
- How nginx processes a request, configuration selection inside the matched server, checked 2026-09-12.
- nginx 1.28.0 core implementation, version-specific prefix and regex location-search behavior, checked 2026-09-12.