nginx location matching uses a normalized URI path rather than the untouched request-target text. Percent-encoded characters are decoded, relative path components are resolved, and adjacent slashes can be merged according to configuration. Later processing can change the current URI again and trigger another location search. Understanding both transformations is necessary when a request appears to enter a different handler than its original spelling suggests.
When it breaks
A path filter that reasons only about the raw request text can disagree with nginx’s routing view. An encoded slash can become a path separator, and a dot segment can remove an earlier component. A backend might apply its own decoding rules as well. Security and routing decisions need an agreed interpretation at each boundary, rather than assuming that every component sees an identical path string.
Logging only $uri can also obscure the incoming request. That variable represents the current normalized URI and may change during request processing. $request_uri retains the full original URI, including its original query. Neither is a universal replacement for the other: one explains current routing, while the other helps reconstruct what the client actually requested.
Index handling and try_files introduce another transition. A request for a directory can internally become a request for its index file. A failed file lookup can redirect internally to a fallback URI, causing a different location and different query arguments to apply. The first prefix match therefore does not necessarily identify the configuration that produces the final response.
Check it quickly
Run the script with host Python, Docker, and the pinned nginx image. It was executed with nginx 1.28.0 on Linux arm64 and Docker 29.3.1. It builds and syntax-checks a temporary configuration, publishes only a host-loopback port, sends request targets without client-side path normalization, and removes its own image, containers, 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 "$uri|$request_uri|$args"; }
location /protected/ { return 403 "blocked"; }
location /docs/ { try_files $uri $uri/ /fallback?reason=missing; }
location = /docs/index.html { return 200 "$uri|$request_uri|$args"; }
location = /fallback { return 200 "$uri|$request_uri|$args"; }
'''
with lab(config(locations),{'docs/index.html':'stored-index'}) as (container,request):
cases={
'/a//b/../c?x=1':'/a/c|/a//b/../c?x=1|x=1',
'/a%2Fb/%2e%2e/c?x=1':'/a/c|/a%2Fb/%2e%2e/c?x=1|x=1',
'/Protected/item':'/Protected/item|/Protected/item|',
'/docs/':'/docs/index.html|/docs/|',
'/docs/missing?x=1':'/fallback|/docs/missing?x=1|reason=missing'}
for path,expected in cases.items():
status,headers,body=request(path);assert status==200 and body.decode()==expected,(path,status,body)
for path in ['/protected/item','/protected%2Fitem','/a/../protected/item']:assert request(path)[0]==403,path
assert request('/bad%ZZ')[0]==400
print('matching uses normalized paths: percent-decoding, dot segments, and merged slashes differ from the original request URI')
print('index and try_files internal redirects choose another location; request_uri remains original while uri and args can change')
The request /a//b/../c?x=1 produces current URI /a/c, while the original request URI remains /a//b/../c?x=1 and the arguments remain x=1. This demonstrates slash merging and dot-segment resolution in the tested default configuration. The response includes all three fields so their meanings can be compared directly.
The encoded request /a%2Fb/%2e%2e/c?x=1 reaches the same current path /a/c. Its original encoded spelling is still preserved in request_uri. This case shows that percent-decoding participates in normalization before routing. The test does not infer behavior from a browser address bar, which may already have rewritten a path before sending it.
The protected prefix returns 403 for /protected/item, /protected%2Fitem, and /a/../protected/item. All normalize into the protected path family. In this Linux environment, /Protected/item instead reaches the ordinary fallback because the case differs. An invalid percent escape such as %ZZ is rejected with HTTP 400 rather than treated as a successful route to arbitrary content.
The fixture includes a real /srv/docs/index.html. Requesting /docs/ reaches the exact /docs/index.html location after index processing, which returns a diagnostic marker instead of the stored file’s body. The original request_uri remains /docs/. This makes the internal location change visible even though the client receives no redirect response.
For /docs/missing?x=1, try_files cannot find the requested file or directory and internally selects /fallback?reason=missing. The response reports current URI /fallback, original request URI /docs/missing?x=1, and current arguments reason=missing. The fallback’s explicit query replaces the earlier arguments in this example; preserving arguments would require an intentional configuration choice.
Verify the path contract across the full request chain
Test representative encoded forms, repeated slashes, dot segments, missing files, and directory requests when changing routing. Include the actual client, proxy, and application in integration tests if they normalize differently. The small fixture establishes nginx behavior for its chosen configuration; it does not prove every upstream framework or security filter interprets the same bytes identically.
Use original and current URI fields deliberately in diagnostics, while recognizing that query strings can contain sensitive data. Log only the request information the operational task needs. An internal redirect can change the final handler and logging context, so verify where the event is ultimately recorded as well as which initial location matched.
Keep fallback behavior explicit. A missing asset should not accidentally become a successful application page if that masks deployment errors, and protected routes should not escape into an unrelated fallback after rewriting. Syntax validation catches malformed configuration; requests with asserted statuses, fields, and bodies establish the behavior users actually receive.
Sources
- nginx core location, try_files, and variables, URI normalization, internal redirects, and original versus current URI, checked 2026-09-12.
- How nginx processes a request, index processing and subsequent location selection, checked 2026-09-12.
- nginx 1.28.0 core implementation, the tested version’s location search and internal-redirection behavior, checked 2026-09-12.