Serving an App Under /demo/ in nginx: Exact Matches, ^~ Against a PHP Regex, and the proxy_pass Slash

By Lars Nielsen ·

CaRMen's public demo lives at /demo/ on the project's landing site. The landing site is its own nginx server block on the host; the demo is a separate application in Docker, listening on a local port. Joining the two takes a handful of lines of nginx configuration, included into the landing site's server block, and three of them are each a small lesson in how nginx picks a location.

The configuration

  • location = /demo { return 301 /demo/; }
  • location ^~ /demo/ {
  • proxy_pass http://127.0.0.1:8082/;
  • proxy_set_header Host $host;
  • proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  • ...
  • }

1. The exact match for /demo

Without the first block, a visitor who types /demo without the trailing slash wouldn't match location /demo/ at all and would land in the landing site's own handling. The = modifier defines an exact match, and nginx's documentation says that when an exact match is found, the search stops there. So /demo, and only /demo, gets a permanent redirect to /demo/, where the real location takes over.

2. Why the trailing slash in proxy_pass matters

The demo container serves its app at /, not at /demo/. The trailing slash in proxy_pass http://127.0.0.1:8082/ is what bridges that. nginx's proxy documentation describes the rule: if proxy_pass is given with a URI, and a bare / counts, the part of the request URI that matched the location is replaced by that URI. So /demo/api/deals reaches the container as /api/deals. Without the slash, proxy_pass http://127.0.0.1:8082 passes the request URI through unchanged, and the container would receive /demo/api/deals, a path it doesn't know.

3. The regex location that would have won

The first version of this block was a plain location /demo/; the ^~ came in a follow-up commit six hours later. The landing site's server block already had a location for PHP files:

  • location ~ \.php$ { ... }

nginx's documentation spells out the order it uses to pick a location. First it checks the prefix locations and remembers the one with the longest matching prefix. Then it checks the regular-expression locations, in the order they appear in the configuration, and the first regex that matches wins. Only if no regex matches does the remembered prefix location get used.

So a plain location /demo/ was the best prefix match for every demo URL, and it still lost to \.php$ for any of them that ended in .php. Those requests would have gone to the landing site's PHP handling instead of the demo container.

The ^~ modifier is the documented way out: if the longest matching prefix location has ^~, regular expressions aren't checked at all. With location ^~ /demo/, everything under /demo/ belongs to the demo, whatever it ends in. A comment above the block says why, because a ^~ without an explanation looks like a typo.

Headers for the app behind it

The rest of the block passes the usual headers on: the original Host, the client address, the scheme, and X-Forwarded-For via $proxy_add_x_forwarded_for, which, per nginx's documentation, is the incoming X-Forwarded-For header with the connecting client's address appended, or just that address if the header wasn't there. That matters further down the chain: the application limits login attempts per client IP, and it can only see the real client if each proxy in front of it records the address it saw.

The order to remember

Most nginx surprises with sub-paths come from the same three facts. An exact match (=) ends the search. A regex location beats a longer prefix location unless that prefix is marked ^~. And a URI in proxy_pass, even just a slash, replaces the matched part of the path, while no URI passes it through untouched. Those three rules are enough to put a second application under a path of an existing site without surprises.

Filed under

Leave a comment

Comments are reviewed before they appear.

Basic HTML allowed: links, emphasis, code and lists. Web addresses become links automatically.