Skip to content
Terug naar blog
Tutorials

Nginx location-prioriteit: volgorde van matching uitgelegd

Hoe nginx echt een location-blok kiest: de exacte volgorde voor =, ^~, ~ en prefixen, plus de valkuilen die configuraties breken — met gratis online tester.

12 min leestijd

Nginx location-prioriteit: volgorde van matching uitgelegd

Nginx leest je location-blokken niet van boven naar beneden om te stoppen bij het eerste dat past. Dat ene misverstand zit achter de meeste bugmeldingen in de trant van “mijn location-blok werkt niet”. Bij prefix-locations maakt het geen enkel verschil waar een blok in het bestand staat. Nginx vergelijkt ze allemaal en houdt de langste overeenkomst over.

De nginx location-prioriteit ligt vast in vier stappen:

  1. Exacte overeenkomst. Als een location = /path gelijk is aan de URI, gebruikt nginx dat blok en stopt het zoeken. Geen prefix-vergelijking, geen regex.
  2. Langste prefix. Elke prefix-location (location /path en location ^~ /path) waarmee de URI begint, doet mee in de vergelijking. De langste wordt onthouden, nog niet gebruikt.
  3. De ^~-kortsluiting. Draagt de onthouden prefix een ^~, dan slaat nginx de regex-fase helemaal over en gebruikt het dat blok.
  4. Regex, op volgorde van het bestand. Anders komen ~- en ~*-locations aan de beurt in de volgorde waarin ze in de configuratie staan, en wint de eerste die past. Past er geen enkele, dan gaat het request naar de prefix uit stap 2.

Twee van die regels trekken aan tegenovergestelde kanten: prefixen worden gekozen op lengte ongeacht de volgorde, regexes op volgorde ongeacht de lengte. Een configuratie van boven naar beneden lezen brengt dat conflict nooit aan het licht. Wil je het antwoord voor je eigen bestand in plaats van voor de voorbeelden hieronder, plak het dan in de gratis nginx location tester. Die speelt deze reeks na en laat per verliezend blok zien in welke fase het afviel. De tool draait in je browser, dus een geplakte productieconfiguratie verlaat de pagina nooit. Elke matchingregel die hier staat, is gecontroleerd tegen een draaiende nginx 1.27.5 en niet overgenomen uit secundaire artikelen.

Nginx location-prioriteit in één oogopslag

Vijf modifiers doen mee aan URI-matching, plus één die dat niet doet.

ModifierSyntaxisVergelijkt opStopt de regex-faseTypisch gebruik
=location = /pathGelijkheidJaDrukke paden zoals / of /favicon.ico
^~location ^~ /pathBegint metJa, als het de langste prefix isDirectory’s die nooit bij een regex mogen komen
~location ~ regexPCRE, hoofdlettergevoeligNeeRouteren op extensie waar hoofdletters uitmaken
~*location ~* regexPCRE, hoofdletterongevoeligNeeRouteren op extensie waar hoofdletters niet uitmaken
(geen)location /pathBegint metNeeAlgemeen routeren op pad
@location @nameNooit via de URIDoelen voor error_page en try_files

De vuistregel: een exacte overeenkomst wint van alles, een regex wint van een prefix, en prefixen winnen onderling op lengte. De enige uitzondering is ^~, en die is smaller dan hij lijkt.

Die tabel is hooguit losjes een rangorde. ^~ staat er boven ~, en toch verliest een ^~-blok geregeld van een regex, omdat de modifier alleen wordt bekeken op de prefix die al op lengte had gewonnen.

Het selectiealgoritme in vier stappen

De nginx location-volgorde leer je het snelst van een configuratie die alle regels tegelijk aanspreekt. Dit is het voorbeeld uit de nginx-documentatie, en het is de moeite waard om het uit je hoofd te kennen:

server {
    location = /                   { return 200 "A\n"; }
    location /                     { return 200 "B\n"; }
    location /documents/           { return 200 "C\n"; }
    location ^~ /images/           { return 200 "D\n"; }
    location ~* \.(gif|jpg|jpeg)$  { return 200 "E\n"; }
}

Vijf requests, vijf verschillende antwoorden:

RequestWinnaarWaarom
/AExacte overeenkomst. Het zoeken stopt meteen.
/index.htmlBGeen enkele regex paste, dus doet de onthouden prefix het werk.
/documents/document.htmlCLangere prefix dan /.
/images/1.gifD^~ won de prefix-fase, waardoor de regex niet meer aan bod kwam.
/documents/1.jpgEDe regex won van een langere prefix zonder ^~.

Vergelijk de laatste twee rijen. /images/ en /documents/ zijn allebei prefixen en allebei de langste overeenkomst voor hun eigen request. Het ene request gaat naar het prefix-blok, het andere naar de regex. Het enige verschil is twee tekens.

Stap 2 is het stuk dat mensen overslaan: nginx gebruikt de langste prefix niet, het onthoudt hem. Het blok is een kandidaat, en de regex-fase kan het request alsnog wegkapen. Alleen stap 1, 3 en 4 beëindigen het zoeken.

Waarom “langste prefix” in tekens wordt gemeten en niet in padsegmenten

Prefix-vergelijking is een gewone string-vergelijking. Die weet niet dat / padsegmenten scheidt en stopt niet bij een grens. Met deze configuratie:

server {
    location /static  { }
    location /static/ { }
}

wordt een request voor /staticfoo afgehandeld door location /static. De URI begint met die zeven tekens, dus dat past. /static/ past helemaal niet, omdat er op die positie geen slash staat. Een request voor /static/x gaat de andere kant op en belandt bij /static/, de langste van de twee.

Het gevolg is dat location /static ook /static-backup, /staticfiles en alles wat verder toevallig met dezelfde letters begint in bezit neemt. Bedoelde je een directory, schrijf dan de afsluitende slash en zet er een exacte location bij voor het kale pad:

server {
    location /static/ { root /var/www; }
    location = /static { return 301 /static/; }
}

De meeste tutorials gebruiken nette padvoorbeelden waarin dit nooit opvalt, en daarom glipt het zo vaak door een code review heen. De nginx location tester laat elk blok zien dat paste, met het aantal tekens waarop het paste erbij, zodat een prefix die z’n buren opslokt meteen zichtbaar wordt.

Wat ^~ echt betekent (en wat niet)

De gangbare omschrijving van de nginx location-modifier ^~ is “hiermee krijgt dit blok voorrang op regexes”. Dat zit er net dicht genoeg naast om gevaarlijk te zijn.

Wat ^~ echt doet: tijdens de prefix-vergelijking helemaal niets. Het maakt de overeenkomst niet langer en het verandert niet welke prefix wordt onthouden. Het wordt achteraf bekeken, op die ene prefix die al op lengte had gewonnen. Draagt die winnaar een ^~, dan wordt de regex-fase overgeslagen. Zo niet, dan komt de regex-fase alsnog aan de beurt.

Dat betekent dat een langere gewone prefix hem stilletjes uitschakelt:

server {
    location ^~ /a/    { }
    location /a/b/     { }
    location ~ \.php$  { }
}

Een request voor /a/b/x.php wordt afgehandeld door ~ \.php$. /a/b/ is de langste passende prefix, dus dat is wat nginx onthoudt. Daar staat geen ^~ bij, dus mag de regex-fase doorgaan, en de regex past als eerste en neemt het request over. Het ^~-blok staat er nog, ziet er nog steeds beschermend uit en doet op dit request niets.

Verander de URI in /a/x.php en dezelfde configuratie gedraagt zich totaal anders: nu is ^~ /a/ de langste overeenkomst, wordt de regex-fase overgeslagen en wint het ^~-blok. Zelfde bestand, en toch de omgekeerde uitkomst.

Dit is geen academisch onderscheid. Het klassieke gebruik van ^~ is een beschrijfbare directory uit de buurt van een interpreter houden:

server {
    location ^~ /uploads/ { }
    location ~ \.php$     { fastcgi_pass unix:/run/php-fpm.sock; }
}

Laat de ^~ weg en een request voor /uploads/evil.php gaat regelrecht naar PHP-FPM. Dat is het patroon achter een lange reeks meldingen waarin een upload tot RCE leidt, en die twee tekens zijn het enige wat ertussen zit. Daarom doet het geval van de “verslagen ^~” er zo toe: een langere gewone prefix zoals location /uploads/thumbs/ toevoegen zet het gat weer open voor alles daaronder, en de diff die dat doet ziet er volstrekt onschuldig uit.

Let ook op het bereik. ^~ onderdrukt alleen regexes die op het eigen niveau staan; het onderdrukt nooit regexes die binnen het eigen blok genest zijn, en een ^~ op een geneste location beschermt niet tegen een regex op serverniveau. Zet de modifier aan en uit in de nginx location tester en kijk hoe de winnaar verandert. Overgeslagen regexes blijven daar in de tabel staan, met een markering erbij.

Regex-locations: volgorde wint van specificiteit

Een nginx location-regex gebruikt ~ voor hoofdlettergevoelige matching en ~* voor hoofdletterongevoelige. Prefix-matching daarentegen is op Linux altijd hoofdlettergevoelig. (Op bestandssystemen die geen onderscheid maken tussen hoofdletters en kleine letters, zoals macOS, vergelijkt nginx prefixen hoofdletterongevoelig en dwingt het elke regex-location zich als ~* te gedragen. Ontwikkel je op een Mac en rol je uit naar Linux, dan kan dat verschil een kapotte regel verbergen tot hij live gaat.)

De regel waar mensen over struikelen is deze: regexes worden geëvalueerd in de volgorde waarin ze in het configuratiebestand staan, en de eerste die past beëindigt het zoeken. Specificiteit en lengte hebben geen invloed op die volgorde.

server {
    location ~ ^/a       { }
    location ~ ^/a/b/c$  { }
}

Een request voor /a/b/c wordt opgepikt door ~ ^/a. Het tweede blok past exact op de URI, is veel preciezer, en zal voor geen enkel request ooit worden uitgevoerd. Het is dode configuratie die nginx -t zonder één woord accepteert.

De gewoonte is dus: zet regexes van meest specifiek naar minst specifiek, en houd de lijst kort. Een breed patroon bovenaan maakt alles eronder onbereikbaar, en een diff die alleen regels verplaatst leest als niets bijzonders. Zo’n regressie komt dan ook meestal binnen tijdens een opruimactie en niet tijdens een feature.

Ankers verdienen dezelfde zorg. location ~ /admin is aan geen van beide kanten verankerd, dus zoekt het overal in de URI en past het vrolijk op /public/admin/x. Schrijf ~ ^/admin als je het begin bedoelt. Alleen aan het eind verankeren, zoals in ~ \.php$, is normaal en juist bij routeren op extensie.

Een paar PCRE-details waar intuïties uit JavaScript de mist in gaan:

  • nginx compileert location-patronen met PCRE, zonder UTF- of multiline-modus, waardoor patronen op bytes werken en ^ alleen aan het begin van de URI verankert.
  • De $ van PCRE past ook net vóór een afsluitende newline. Een URI die op %0A eindigt voldoet nog steeds aan \.php$, een bekende manier om langs regels te glippen die op een bestandsextensie zijn gebaseerd.
  • Constructies zonder JavaScript-equivalent komen in PCRE veel voor: atomaire groepen (?>…), possessieve quantifiers a*+, inline modifiers zoals (?i), POSIX-klassen zoals [[:alpha:]], en escapes als \A, \z, \K en \Q…\E.
  • Een patroon met { of } moet tussen aanhalingstekens. location ~ ^/a{2}$ laadt niet en geeft unknown directive "2}$", omdat de accolade het token afsloot. Schrijf location ~ "^/a{2}$".

Capture-groepen werken zoals je hoopt, en $1 en verder zijn binnen het blok beschikbaar:

upstream backend {
    server 127.0.0.1:8080;
}

server {
    location ~ ^/user/(\d+)/profile$ {
        proxy_pass http://backend/profiles/$1;
    }
}

Ben je nog aan het debuggen op het patroon zelf en niet op de positie ervan in het bestand, test het dan eerst in de Regex-tester; de Regex Cheat Sheet behandelt de syntaxis in detail.

De stap die iedereen overslaat: URI-normalisatie

Voordat er ook maar één location wordt bekeken, herschrijft nginx de request target. Je patronen worden vergeleken met het genormaliseerde pad, niet met de bytes die over de lijn binnenkwamen. Vrijwel geen enkele tutorial noemt dit, en het beslist een verrassend aantal gevallen van “mijn location past niet”.

Normalisatie doet vier dingen: ze haalt de query string eraf, decodeert de percent-escapes in het pad, lost .- en ..-segmenten op en voegt herhaalde slashes samen.

Request targetGenormaliseerde $uriToelichting
//a//x/a/xHerhaalde slashes samengevoegd
/a/../b/x/b/x.. opgelost vóór het matchen
/a/b%2F..%2Fzz/a/zz%2F decodeert naar een echt scheidingsteken en doet mee in de oplossing
/a/%2e%2e/b/x/b/x%2E decodeert naar een punt die ook meetelt
/a%20b/x/a b/x%20 wordt een echte spatie
/a+b/x/a+b/x+ is in een pad geen spatie
/a?x=/b/aQuery string wordt er eerst afgehaald
/a%3Fx=1/a?x=1%3F blijft letterlijk; de query string is leeg

Drie gedecodeerde tekens zijn een uitzondering en worden letterlijk teruggeschreven zonder opnieuw geïnterpreteerd te worden: %25, %23 en %3F. Daarom eindigt /a%3Fx=1 met een vraagteken in het pad en niets in $args.

Twee soorten targets bereiken de location-selectie nooit. ..-segmenten die boven de root uit klimmen en een ongeldige escape zoals %00 worden allebei met 400 afgewezen voordat het matchen begint.

Gebruik je een location-blok als grens voor toegangscontrole, dan wordt het pad dat je schreef vergeleken met het opgeloste pad:

server {
    location /a/ { }
    location /b/ { }
}

Een request voor /a/b%2F..%2Fzz blijft niet onder /a/b/. Het normaliseert naar /a/zz en wordt afgehandeld door location /a/. Redeneren vanuit de ruwe target in plaats van vanuit $uri geeft hier het verkeerde antwoord, en “het verkeerde antwoord” heeft in de context van toegangscontrole een eigen naam. Vertrouw je op location /admin om iets af te schermen, ga dan eerst na wat het genormaliseerde pad werkelijk is. De nginx location tester zet de ruwe target, de genormaliseerde $uri en de afgesplitste query string als drie aparte rijen onder elkaar. Wil je alleen over de codering zelf nadenken, dan doet de URL Decoder & Encoder precies dat op zichzelf.

Nog één gevolg: de query string doet nooit mee aan de matching. location /search?q= kan niet passen op een request voor /search?q=1, omdat de selectie alleen /search te zien krijgt. Wil je op een parameter vertakken, lees dan $arg_name binnen het blok.

Vijf configuraties die niet doen wat je denkt

Een ^~-blok dat verliest van een langere gewone prefix

Symptoom: een ^~-directory ziet er beschermd uit, en toch handelt een regex requests daarbinnen af. Oorzaak: ^~ wordt alleen bekeken op de prefix die al op lengte had gewonnen. In plaats daarvan wordt een langere gewone prefix onthouden, en die onderdrukt niets. Oplossing: zet ^~ ook op de langere prefix, of haal de langere prefix weg.

# Broken: /a/b/x.php goes to the regex
location ^~ /a/    { }
location /a/b/     { }
location ~ \.php$  { }

# Fixed: /a/b/x.php goes to ^~ /a/b/
location ^~ /a/    { }
location ^~ /a/b/  { }
location ~ \.php$  { }

Een prefix zonder afsluitende slash die z’n buren opslokt

Symptoom: een blok dat voor één directory bedoeld is, bedient ook paden die alleen maar met dezelfde letters beginnen. Oorzaak: prefix-matching vergelijkt tekens en geen padsegmenten, waardoor location /app ook op /application past. Oplossing: schrijf de afsluitende slash, en voeg location = /app toe als het kale pad ook afgehandeld moet worden.

De specifieke regex onder de brede regex

Symptoom: een precieze regel gaat nooit af, en nergens verschijnt een foutmelding. Oorzaak: regexes komen op volgorde van het bestand aan de beurt en de eerste die past beëindigt het zoeken. Alles onder een breed patroon is daarmee onbereikbaar. Oplossing: zet het specifieke patroon boven het brede, of maak het brede strakker met een anker.

Een regex achter ^~

Symptoom: een deny-regel laadt zonder klachten en blokkeert niets. Oorzaak: ^~ neemt een letterlijke prefix, geen patroon. nginx klaagt nooit, en het blok past op geen enkele URI. Oplossing: gebruik de regex-modifier.

# Broken: matches nothing, loads without error
location ^~ "\.php$" { deny all; }

# Fixed
location ~ \.php$ { deny all; }

Aannemen dat de query string meetelt

Symptoom: een location met een ? erin past nooit. Oorzaak: de query string wordt tijdens de normalisatie afgesplitst en de location-selectie werkt alleen op het pad. Oplossing: vergelijk op het pad en bekijk $arg_name binnen het blok.

location /search {
    if ($arg_q = "") { return 400; }
}

Debuggen: uitzoeken welk blok echt won

Drie technieken, van veel naar weinig moeite.

De debug-log is het gezaghebbende antwoord. Die vereist een binary die met debug-ondersteuning is gebouwd, dus controleer dat eerst:

nginx -V 2>&1 | grep -o with-debug

Zet hem daarna aan en grep naar de regel die het gekozen blok noemt:

error_log /var/log/nginx/debug.log debug;
grep "using configuration" /var/log/nginx/debug.log

Probes via response-headers zijn sneller en vragen geen debug-build. Label elke kandidaat en lees de headers terug:

location ^~ /uploads/ {
    add_header X-Debug-Location "uploads-caret" always;
    return 204;
}
location ~ \.php$ {
    add_header X-Debug-Location "php-regex" always;
    return 204;
}
curl -sI --path-as-is 'http://localhost/uploads/evil.php' | grep -i x-debug-location

--path-as-is doet ertoe: zonder die vlag lost curl behulpzaam de .. voor je op en test je uiteindelijk een andere URI dan je bedoelde. Zet je iets ingewikkelders in elkaar, dan schrijft de cURL Command Generator de vlaggen voor je, en het curl Spiekbriefje behandelt de rest. Komt de probe terug als een redirect of een 404 in plaats van als jouw header, dan vertelt het HTTP-statuscodes spiekbriefje je meestal welke module hem heeft geproduceerd.

nginx -T drukt de configuratie af zoals nginx hem uiteindelijk ziet, inclusief alle include-bestanden. Zo kom je erachter in welke volgorde je regexes echt staan nadat zes bestanden zijn samengevoegd, en dat is zelden de volgorde uit het bestand waarin je zat te werken.

nginx -T | grep -n "location"

Reproduceer het probleem in de nginx location tester voordat je de server aanpast. Itereren op een configuratie die je nog niet hebt uitgerold is sneller dan een reload-cyclus, en de beslissingstabel noemt bij elk blok de fase waarin het afviel.

Geneste locations, try_files en wat ze niet veranderen

Geneste locations doorlopen hetzelfde algoritme één niveau dieper. Zodra een prefix-location wint, daalt nginx af in de kinderen en herhaalt het de zoektocht daar, waardoor een geneste regex vóór de regexes van het bovenliggende niveau aan de beurt komt:

server {
    location ~ \.php$ { }
    location /a/ {
        location ~ \.php$ { return 200 "nested\n"; }
    }
}

/a/x.php wordt afgehandeld door het geneste blok. Nesten heeft ook een minder voor de hand liggend effect: het kan een globaal langere prefix onbereikbaar maken, omdat er per niveau alleen in de winnaar wordt afgedaald. Staat location /a/bb/ genest in location /a/, en ligt er op het buitenste niveau een location /a/b naast, dan gaat een request voor /a/bb/x naar /a/b. De buitenste vergelijking gebeurt eerst, en /a/b wint die. Een afdaling die niets vindt gaat bovendien niet terug; het bovenliggende blok houdt het request.

try_files en rewrite maken geen deel uit van de selectie. Ze worden uitgevoerd binnen het blok dat al gewonnen heeft, en kunnen daar met terugwerkende kracht niets meer aan veranderen. Komt een request nooit aan bij het blok met jouw try_files, dan is die directive irrelevant, en de gebruikelijke boosdoener is een ~ \.php$-regex die het request opeist voordat het prefix-blok überhaupt aan de beurt komt. De ene uitzondering die je moet kennen: een interne redirect (rewrite … last, of een sprong via error_page) start het matchen opnieuw, waardoor de herschreven URI weer van bovenaf tegen de lijst met locations wordt afgezet.

Nog een geval dat vaak voor een matching-bug wordt aangezien: de 301 die je krijgt als je een directory zonder afsluitende slash opvraagt, is geen mislukte match. Twee losse mechanismen veroorzaken hem. Draagt een location waarvan de naam op / eindigt een proxy_pass of een andere *_pass-directive, dan wordt een request voor hetzelfde pad zonder die slash tijdens de selectie beantwoord met een 301, nog vóór er ook maar één regex is geëvalueerd, en met behoud van de query string:

server {
    location /user/ { proxy_pass http://backend/; }
}
# GET /user?x=1  ->  301 to /user/?x=1

location = /user toevoegen onderdrukt die redirect. Los daarvan geeft de module voor statische bestanden een eigen 301 wanneer een pad naar een echte directory op schijf verwijst, en dat hangt af van je bestandssysteem in plaats van van je configuratie.

FAQ

Wat zijn de vijf nginx location-modifiers?

De vijf nginx location-modifiers zijn = voor exacte matching, ^~ voor een prefix die de regex-fase overslaat, geen modifier voor een gewone prefix, ~ voor een hoofdlettergevoelige regex en ~* voor een hoofdletterongevoelige. Een zesde vorm, location @name, doet nooit mee aan URI-matching en bestaat alleen als doel voor error_page en try_files.

Maakt een exacte =-match nginx sneller?

Een location met een exacte match beëindigt het zoeken meteen en slaat de prefix-scan en elke regex-evaluatie over. De winst is echt, maar te klein om te merken. Voor endpoints die duizenden keren per seconde worden geraakt, zoals health checks of /favicon.ico, is hij het schrijven waard. Een stapel =-blokken voor gewone pagina’s kost meer aan complexiteit in je configuratie dan hij oplevert.

Mag ik een location-modifier zonder spatie schrijven, zoals ~*^/api?

Ja. location ~*^/api/ en location ~* ^/api/ betekenen precies hetzelfde, omdat nginx de modifier van de voorkant van de naam afhaalt en de langste modifier als eerste herkent, waardoor ~* vóór ~ wordt opgepikt. Zet de spatie er toch. Een vastgeplakte modifier leest als onderdeel van het patroon en zorgt bij een review door mensen voor verwarring.

Wat is het verschil tussen root en alias in een location-blok?

root plakt de hele URI achter de directory, terwijl alias de passende prefix erdoor vervangt. Met location /static/ { root /var/www; } zoekt een request voor /static/x.css naar /var/www/static/x.css; stap je over op alias /var/www/assets/; dan zoekt hij naar /var/www/assets/x.css. Geef bij alias de location en het pad allebei een afsluitende slash, of allebei geen.

Kan één request op meer dan één location-blok passen?

Meerdere blokken kunnen passen, maar precies één handelt het request af. nginx vergelijkt elke prefix-location en, waar nodig, elke regex-location, en geeft het request daarna aan die ene winnaar. Directives worden niet overgeërfd van de verliezende blokken. Wat je overal nodig hebt, zet je op server- of http-niveau, of je herhaalt het per blok.

Vertelt nginx -t me welke location gaat passen?

Nee. nginx -t controleert de syntaxis en de geldigheid van de configuratie; het simuleert nooit een request, waardoor het niets over de volgorde van matching meldt. Wil je weten welk blok een URI in bezit heeft, lees dan de debug-log, voeg tijdelijk een response-header toe, of plak de configuratie in de nginx location tester en kijk waarom elk blok won of verloor.

Tags: nginx web-server devops regex configuration

Gerelateerde artikelen

Alle artikelen bekijken