Skip to content

Fix: Nginx location Block Not Matching (Wrong Route Served)

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix Nginx location blocks not matching — caused by prefix vs regex priority, trailing slash issues, root vs alias confusion, and try_files misconfiguration.

The Error

You add or modify a location block in your Nginx config but it never matches — requests go to a different block or return 404. There is no error message in the Nginx logs; the wrong block simply handles the request silently.

Common symptoms:

  • A location /api/ block is configured but requests to /api/users are handled by location / instead.
  • A regex location like location ~ \.php$ does not trigger for .php files.
  • Static files are served correctly but SPA routes return 404.
  • Adding a new location block has no effect after reloading Nginx.
  • location /admin serves the wrong root directory.
  • A redirect inside a location block loops infinitely.

Why This Happens

Nginx evaluates location blocks using a specific precedence order that does not match intuition. The most common mistakes:

  • Misunderstanding location priority — exact match (=), prefix match (^~), regex match (~, ~*), and standard prefix match (/) have a specific evaluation order.
  • Missing trailing slashlocation /api and location /api/ behave differently.
  • root vs aliasroot appends the location path; alias replaces it. Mixing them up serves files from the wrong directory.
  • Config not reloaded — changes are made but nginx -s reload was not run.
  • Regex syntax errors — a malformed regex silently falls through to the next matching block.
  • try_files misconfiguration — SPA routing requires try_files $uri $uri/ /index.html but a wrong fallback causes 404.

Platform and Environment Differences

Nginx behaves differently depending on the distribution, build flags, and which Nginx variant you are running. A location block that works in one environment can silently fail in another.

Debian and Ubuntu vs RHEL, CentOS, and Fedora. Debian-family distributions split config under /etc/nginx/sites-available/ with symlinks in /etc/nginx/sites-enabled/, and the main nginx.conf includes sites-enabled/*. RHEL-family distributions skip that pattern entirely and only include /etc/nginx/conf.d/*.conf. If you copy a Debian guide and drop a file into /etc/nginx/sites-available/ on a CentOS box, Nginx never reads it. Run nginx -T | grep "include " to see the include rules your build actually uses.

Compiled-in modules differ by build. The stock nginx package on Alpine, Debian, and Amazon Linux 2 each ship with different --with-* flags. nginx -V prints the full build configuration. A location using gzip_static, auth_request, image_filter, or geoip2 matches the URI fine but fails with an “unknown directive” error if that module was not compiled in. The official Docker nginx:alpine image, for example, omits modules that the nginx-full Debian package includes.

OpenResty and Nginx Plus. OpenResty bundles LuaJIT and adds directives like access_by_lua_block and content_by_lua_block that can hijack a location before its normal handlers run. If you inherit an OpenResty config, check for Lua blocks before assuming a location is misconfigured. Nginx Plus adds keyval, api, and advanced upstream health checks; these directives are silently ignored by open-source Nginx, which can make a location look like it does nothing.

PCRE vs PCRE2 regex engines. Nginx 1.21.5 added support for linking against PCRE2 instead of the original PCRE. Most binaries built since 2022 use PCRE2. The two engines behave the same for common patterns, but PCRE2 is stricter about backslash escapes inside character classes and about possessive quantifiers. A regex location ~ \.(jpg|png)$ works identically on both, but location ~ ^/api/(?<ver>v\d++)/ (note the possessive ++) parses on PCRE2 and rejects on older PCRE-only builds.

FreeBSD vs Linux. On FreeBSD, the default nginx package installs to /usr/local/etc/nginx/ and runs as the www user. On most Linux distributions, the path is /etc/nginx/ and the user is nginx or www-data. A location that calls try_files $uri ... on a filesystem the worker process cannot read returns 404 even though the location matches. Check ps aux | grep nginx to see which user the workers run as, then compare against the file owner with ls -l.

Docker official images. The nginx Docker image runs the master as root and workers as nginx, and logs to /dev/stdout and /dev/stderr via symlinks. If you docker exec in and tail /var/log/nginx/error.log, you see nothing — the file is a symlink to stdout, so you need docker logs <container> instead. The image also auto-runs /docker-entrypoint.d/*.sh scripts before starting Nginx; these can rewrite /etc/nginx/conf.d/default.conf and overwrite your location blocks on every container restart.

How Nginx Location Matching Works

Understanding priority is essential. Nginx evaluates locations in this order:

  1. Exact match (=): location = /favicon.ico — matches only that exact URI.
  2. Preferential prefix (^~): location ^~ /images/ — if matched, stops evaluating regex blocks.
  3. Regex match (~ case-sensitive, ~* case-insensitive): evaluated in the order they appear in the config.
  4. Longest prefix match (no modifier): the longest matching prefix wins, but only if no regex matches.
server {
    location = /exact {
        # Matches only GET /exact — highest priority
    }

    location ^~ /api/ {
        # Matches /api/... — prevents regex from running if this matches
    }

    location ~ \.php$ {
        # Regex — runs if no exact or ^~ match
    }

    location / {
        # Catch-all prefix — lowest priority
    }
}

Why this matters: Many developers assume Nginx reads locations top-to-bottom like a list of if-else statements. It does not. The longest prefix match wins among prefix locations, and regex locations are tried in order but only after all prefix locations are evaluated. A location earlier in the file can lose to a longer prefix location later in the file.

Fix 1: Use = for Exact URI Matches

If you need a specific URI handled differently, use exact match to guarantee it:

# Without =, /health might match a longer prefix location first
location /health {
    return 200 "ok";
}

# With =, this always wins for exactly /health
location = /health {
    return 200 "ok";
    add_header Content-Type text/plain;
}

Exact match is also the most efficient — once Nginx finds an exact match, it stops searching immediately.

Fix 2: Fix Prefix Location Priority with ^~

If you have a prefix location that should take priority over regex locations, add ^~:

Broken — regex catches static files before the prefix location:

location /static/ {
    root /var/www;
    expires 1y;
}

location ~ \.(jpg|png|css|js)$ {
    # This matches /static/logo.png too, and may override the /static/ block
    add_header Cache-Control "public";
}

Fixed — use ^~ to block regex evaluation:

location ^~ /static/ {
    root /var/www;
    expires 1y;
    # Regex locations are NOT checked for anything starting with /static/
}

location ~ \.(jpg|png|css|js)$ {
    add_header Cache-Control "public";
    # Only applies to files NOT under /static/
}

Fix 3: Fix root vs alias Confusion

root and alias serve files from the filesystem, but they work differently:

  • root: appends the location URI to the root path. location /images/ + root /var/www → serves from /var/www/images/.
  • alias: replaces the location URI with the alias path. location /images/ + alias /var/www/static/ → serves from /var/www/static/.

Broken — using root when alias is needed:

location /assets/ {
    root /var/www/static;
    # Request: /assets/logo.png
    # Nginx looks for: /var/www/static/assets/logo.png  ← WRONG
    # The path is doubled!
}

Fixed — use alias:

location /assets/ {
    alias /var/www/static/;
    # Request: /assets/logo.png
    # Nginx looks for: /var/www/static/logo.png  ← CORRECT
}

Rule of thumb: Use root when the location URI and the directory name match. Use alias when they differ.

Common Mistake: When using alias, always end both the location path and the alias path with a trailing slash, or neither. Mismatched trailing slashes cause subtle path errors. location /assets/ with alias /var/www/static/ is correct. location /assets with alias /var/www/static is also correct. Mixing (location /assets/ with alias /var/www/static) is broken.

Fix 4: Fix SPA Routing with try_files

Single-page applications (React, Vue, Angular) need Nginx to serve index.html for all routes, so the frontend router can handle them:

Broken — 404 on direct URL access:

location / {
    root /var/www/app;
    index index.html;
    # Direct access to /dashboard returns 404 because there's no /dashboard file
}

Fixed — add try_files fallback:

location / {
    root /var/www/app;
    index index.html;
    try_files $uri $uri/ /index.html;
}

try_files $uri $uri/ /index.html tells Nginx to:

  1. Try the exact file ($uri).
  2. Try as a directory ($uri/).
  3. Fall back to /index.html if neither exists.

For APIs coexisting with an SPA:

location /api/ {
    proxy_pass http://localhost:3000/;
    # API requests go to Node.js backend
}

location / {
    root /var/www/app;
    try_files $uri $uri/ /index.html;
    # Everything else serves the SPA
}

The /api/ location is more specific than /, so API requests are correctly routed to the backend.

Fix 5: Fix Trailing Slash Issues

A trailing slash matters in location blocks:

  • location /api matches /api, /api/, /api/users, /apiv2 (any URI starting with /api).
  • location /api/ matches /api/, /api/users — but NOT /api alone or /apiv2.

Redirect bare path to trailing slash version:

location = /app {
    return 301 /app/;
}

location /app/ {
    root /var/www;
    try_files $uri $uri/ /app/index.html;
}

In proxy_pass, trailing slash changes path stripping:

# Without trailing slash on proxy_pass:
location /api/ {
    proxy_pass http://localhost:3000;
    # Request: /api/users → proxied as /api/users (full path kept)
}

# With trailing slash on proxy_pass:
location /api/ {
    proxy_pass http://localhost:3000/;
    # Request: /api/users → proxied as /users (strips /api/)
}

The trailing slash on proxy_pass strips the location prefix from the proxied URL. This is usually what you want when the backend does not know about the /api/ prefix.

Fix 6: Test Location Matching Without Restarting

Use nginx -T to dump the full resolved config and look for unexpected values:

sudo nginx -T | grep -A 10 "location /api"

Use nginx -t to test configuration syntax before reloading:

sudo nginx -t
# nginx: configuration file /etc/nginx/nginx.conf test is successful

Use curl to test which location is handling requests:

Add a temporary header to identify each location:

location /api/ {
    add_header X-Location "api-block" always;
    proxy_pass http://localhost:3000/;
}

location / {
    add_header X-Location "default-block" always;
    root /var/www/app;
    try_files $uri /index.html;
}

Then check which block responds:

curl -I http://localhost/api/users
# Look for: X-Location: api-block

Remove these debug headers before going to production.

Fix 7: Reload Nginx After Every Config Change

Changes to Nginx config have no effect until you reload or restart:

# Test config first (always do this before reloading)
sudo nginx -t

# Reload without downtime (graceful)
sudo nginx -s reload

# Or via systemctl
sudo systemctl reload nginx

# Full restart (causes brief downtime)
sudo systemctl restart nginx

nginx -s reload sends a SIGHUP to the master process. It reads the new config, starts new worker processes with the new config, and gracefully shuts down old workers after they finish their current requests. Zero downtime.

Verify the reload took effect:

sudo systemctl status nginx
# Check the timestamp — should show the recent reload time

Fix 8: Trace Which Location Actually Matched

When two locations could plausibly match the same URI, the fastest way to see which one wins is to make each location return a different marker. Use a temporary return that prints its identifier and the resolved root, then issue a curl and read the response:

location ~ \.php$ {
    return 200 "matched: regex_php path:$document_root request:$uri\n";
}

location ^~ /api/ {
    return 200 "matched: prefix_api path:$document_root request:$uri\n";
}

location / {
    return 200 "matched: prefix_root path:$document_root request:$uri\n";
}

Then call:

curl -s http://localhost/api/users.php
# matched: regex_php path:/var/www request:/api/users.php

If regex_php wins for /api/users.php, the ^~ modifier on the API block is being ignored because of a syntax error or because the request matched the regex first. Add the ^~ marker, reload, and curl again.

Once you have identified the winning location, replace the return statements with the original directives. Keep the marker output in a non-production environment so the next change is easy to verify.

Fix 9: Debug with Nginx Error and Access Logs

The access log shows which location handled each request (with the right log format):

log_format detailed '$remote_addr - $request - status:$status - upstream:$upstream_addr';
access_log /var/log/nginx/access.log detailed;

The error log shows why a location failed to match or returned an error:

# Watch logs in real time
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.log

# Filter for specific path
sudo tail -f /var/log/nginx/access.log | grep "/api/"

For 502 errors after location blocks successfully route to an upstream, see Fix: Nginx 502 Bad Gateway. For 403 errors on static files, see Fix: Nginx 403 Forbidden.

Still Not Working?

Check for config file includes. Nginx often includes files from /etc/nginx/conf.d/*.conf or /etc/nginx/sites-enabled/. A location in an included file may override your changes. Run sudo nginx -T to see the fully resolved config.

Check for conflicting server blocks. If you have multiple server blocks, make sure the request is reaching the right one. Nginx matches server blocks by server_name and port. Add default_server to the block that should catch requests when no other server name matches.

Check for if blocks overriding locations. if inside a location can behave unexpectedly in Nginx. The Nginx documentation warns that if is “evil” in certain contexts. Replace if with map, geo, or separate location blocks where possible.

Check for inherited directives. Some directives (like try_files) in a parent location do not apply to nested locations. Each location block that needs try_files must define it explicitly.

Check rewrite directives executing before location selection. A rewrite ... last in the server block rewrites the URI before location matching runs, so the location that matches is the one that matches the rewritten URI, not the one you typed in the browser. Use nginx -T and search for rewrite at server scope, then trace the URI step by step.

Check WebSocket and Upgrade headers on proxied locations. A location /ws/ block that simply uses proxy_pass without proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade" matches the request, but the upstream never sees the upgrade and the client thinks the location is broken. See Fix: Nginx WebSocket proxy not working for the full header set.

Check for SELinux or AppArmor blocking the worker. On RHEL with SELinux enforcing, the Nginx worker is denied read access to non-standard document roots even when the location is matched. The 403 is logged in /var/log/audit/audit.log, not the Nginx error log. Run sudo ausearch -m AVC -ts recent to confirm.

For upstream timeout issues that look like location mismatches, see Fix: Nginx 504 Gateway Timeout.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles