I edit an nginx config almost every week, and the same handful of directives cause almost every surprise. This is my cookbook: the matching rules I keep re-learning, the root/alias foot-gun, the nginx try_files patterns I reuse, and the add_header inheritance trap that quietly drops your security headers. Every rule below was reproduced on a real nginx/1.27.5 container. You will see actual curl output, an actual error-log line, and a genuine nginx -t failure with its fix — nothing is invented. Where these connect to the rest of the stack, I lean on HTTP caching and zero-downtime deploys.
The whole session ran in one container with a config dir mounted read-only and a static site at /srv/site:
$ docker run -d --name ngx \
-p 127.0.0.1:8080:8080 \
-v "$PWD/conf.d/default.conf":/etc/nginx/conf.d/default.conf:ro \
-v "$PWD/site":/srv/site:ro \
nginx:1.27-alpine
$ docker exec ngx nginx -v
nginx version: nginx/1.27.5
How nginx location matching actually picks a winner
This is the question that generates the most confusion, so let me settle it with a config that has every kind of location pointing at the same URL space. nginx does not simply take the first location in the file; it ranks candidates:
location = /a { return 200 "exact"; }
location /a { return 200 "plain prefix"; }
location ~ ^/a { return 200 "regex"; }
location ^~ /b/ { return 200 "prefix, regex skipped"; }
location ~ \.png$ { return 200 "png regex"; }
location / { return 200 "fallback"; }
Now ask for the URLs that each rule is trying to win:
$ for p in /a /a/ /b/x.png /c/x.png /other; do curl -s "http://127.0.0.1:8080$p"; done
WIN: 'location = /a' (exact)
WIN: 'location ~ ^/a' (regex)
WIN: 'location ^~ /b/' (prefix, regex skipped)
WIN: 'location ~ \.png' (regex)
WIN: 'location /' (fallback prefix)
Read it row by row. /a hits the exact match, which beats the regex and the prefix. /a/ is not an exact match, so the regex ~ ^/a wins over the plain prefix /a — a shorter prefix does not outrank a regex. /b/x.png matches both ^~ /b/ and the .png regex, but ^~ means “stop, do not even look at regexes”, so the prefix wins. /c/x.png matches no special prefix, so the regex wins. /other falls all the way through to location /.
root vs alias: one appends, one replaces
root builds a path by appending the full URI; alias builds it by replacing the matched prefix. This is the difference people remember for a day and then forget:
location /a/ { root /srv/site; } # -> /srv/site/a/hello.html
location /b/ { alias /srv/aliased/; } # -> /srv/aliased/hello.html
location /c/ { alias /srv/aliased; } # -> /srv/aliasedhello.html (BUG)
$ curl -s http://127.0.0.1:8082/a/hello.html
served from root + URI (/srv/site/a/hello.html)
$ curl -s http://127.0.0.1:8082/b/hello.html
served from alias (/srv/aliased/hello.html)
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:8082/c/hello.html
HTTP 404
The 404 in /c/ is the classic alias bug, and the error log names the wrong file outright:
2026/10/12 [error] open() "/srv/aliasedhello.html" failed (2: No such file or directory),
request: "GET /c/hello.html HTTP/1.1"
alias /srv/aliased had no trailing slash, so nginx replaced /c/ with /srv/aliased and glued hello.html straight onto it. The rule is boring and absolute: with alias, make the trailing slashes on the location and the alias agree. When the error log shows a mangled path, this is the first place to look.
nginx try_files: SPA fallback, static files, API fallback
try_files is the workhorse of every real server. It tries each argument in order and uses the first that exists; the last argument is the fallback (a URI, =404, or a named location).
location /spa/ { try_files $uri $uri/ /spa/index.html; } # single-page app
location /docs/ { try_files $uri $uri/ =404; } # static, honest 404
location /api/ { try_files $uri $uri/ @api; } # hand off to the backend
location @api { return 200 "named location @api handled /api"; }
$ curl -s http://127.0.0.1:8083/spa/dashboard
<div id="app">SPA shell</div>
$ curl -s http://127.0.0.1:8083/spa/app.js
console.log('spa bundle');
$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:8083/docs/missing.html
HTTP 404
$ curl -s http://127.0.0.1:8083/api/users
named location @api handled /api
Three habits in four lines. For the SPA, a deep link like /spa/dashboard is neither a file nor a directory, so it falls back to the shell and the client router takes over — but a real asset like app.js is served directly. The static location uses =404 so that a typo returns a 404 instead of your homepage with a 200, which is how SPAs end up accidentally indexed. The API location drops to the @api named location, which is where a proxy_pass belongs.
The add_header inheritance trap
Here is the one that bites in production. add_header is not merged down the tree: if a nested block declares any add_header, it discards every add_header from its parent. A server-level header set for security vanishes the moment a location adds one of its own.
server {
add_header X-Server-Level "from server" always;
location /inner { return 200 "inherits"; } # no local add_header
location /outer { add_header X-Location "from location" always; return 200 "replaces"; }
}
$ curl -sD - -o /dev/null http://127.0.0.1:8084/inner | grep -i x-
X-Server-Level: from server
$ curl -sD - -o /dev/null http://127.0.0.1:8084/outer | grep -i x-
X-Location: from location
/outer should still carry X-Server-Level, but it does not. The location’s own add_header replaced the server’s entire set. The fix is to repeat the shared headers in every block that adds one, or move them somewhere they are not shadowed. The second half of the trap is status codes: add_header is only added to successful responses unless you write always.
$ curl -sD - -o /dev/null http://127.0.0.1:8084/error-plain # 404: no X-Plain header
HTTP/1.1 404 Not Found
$ curl -sD - -o /dev/null http://127.0.0.1:8084/error-always # add_header ... always
HTTP/1.1 404 Not Found
X-Always: set with always
So HSTS, X-Frame-Options, and the rest only appear on 4xx/5xx if you remembered always — and they survive inheritance only if the child block adds nothing.
proxy_pass and the trailing-slash subtlety
When proxy_pass has a URI part, the matched location prefix is replaced by it. Add a trailing slash and you strip the prefix; leave it off and you pass the full path through. One character, completely different upstream request:
location /strip/ { proxy_pass http://127.0.0.1:8090/; } # trailing slash
location /keep/ { proxy_pass http://127.0.0.1:8090; } # no trailing slash
$ curl -s http://127.0.0.1:8085/keep/foo
backend received: /keep/foo
$ curl -s http://127.0.0.1:8085/strip/foo
backend received: /foo
/keep/ sends /keep/foo unchanged; /strip/ rewrites it to /foo. That is exactly what you want when a backend mounts its routes at the root, and exactly the thing that breaks routing when you expected the opposite.
rewrite vs return: when each one is right
Use return unless you genuinely need to rewrite. return ends the request immediately with a response; rewrite re-evaluates locations and costs more work. Here both send a client a 301, and only the internal rewrite avoids a round trip:
location = /old { return 301 /new; }
location = /old-rewrite { rewrite ^/old-rewrite$ /new permanent; }
location /r/ { rewrite ^/r/(.*)$ /new/$1 last; }
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:8086/old
301 http://127.0.0.1:8086/new
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:8086/old-rewrite
301 http://127.0.0.1:8086/new
$ curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:8086/r/foo
200
return and rewrite ... permanent look identical to the client, but return is cheaper and clearer. rewrite ... last is different: it re-runs location matching internally, so /r/foo came back as a 200 from /new/ with no redirect at all — perfect for legacy URL shapes, overkill for a simple redirect.
gzip, caching headers, and client_max_body_size
Compression needs three things right: gzip on, a gzip_types list (the default only covers text/html), and — the easy miss — gzip_vary on. Without it, nginx serves gzip to a shared cache with no Vary, and the cache can hand compressed bytes to a client that never asked for them.
gzip on;
gzip_types text/css application/javascript application/json;
gzip_vary on; # emits Vary: Accept-Encoding
add_header Cache-Control "public, max-age=31536000, immutable" always;
client_max_body_size 64k;
$ curl -s -H 'Accept-Encoding: gzip' -D - -o /dev/null http://127.0.0.1:8087/big.css
HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: public, max-age=31536000, immutable
Content-Encoding: gzip
Vary: Accept-Encoding
$ curl -s -o /dev/null -w 'plain %{size_download} bytes\n' http://127.0.0.1:8087/big.css
plain 17400 bytes
$ curl -s -H 'Accept-Encoding: gzip' -o /dev/null -w 'gzip %{size_download} bytes\n' http://127.0.0.1:8087/big.css
gzip 147 bytes
A hashed, immutable asset is the one place to cache aggressively; the versioned filename is the invalidation strategy, which is the whole point of the HTTP caching playbook. Finally, client_max_body_size is the upload wall everyone hits eventually:
$ head -c 51200 /dev/zero | curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X POST --data-binary @- http://127.0.0.1:8087/upload
HTTP 200
$ head -c 102400 /dev/zero | curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X POST --data-binary @- http://127.0.0.1:8087/upload
HTTP 413
A 50 KB POST passes the 64k limit; a 100 KB POST is rejected with 413 Request Entity Too Large before it ever reaches your app. Raise the limit deliberately, and remember the same default exists on the backend too.
Reading nginx -T and surviving nginx -t
Before every reload, run nginx -t. When something is wrong it points at the exact file and line:
$ nginx -t
nginx: [emerg] unexpected "}" in /etc/nginx/conf.d/broken.conf:6
nginx: configuration file /etc/nginx/nginx.conf test failed
$ nginx -t
nginx: [emerg] duplicate location "/x" in /etc/nginx/conf.d/broken2.conf:5
nginx: configuration file /etc/nginx/nginx.conf test failed
The first was a return with a missing semicolon; nginx noticed the stray }. Adding the ; makes it pass. nginx -t never touches the running workers — it is a pure syntax check, so a failing test means your last edit would have taken the site down, and a nginx -s reload on a passing test is safe.
nginx -t is the syntax check; nginx -T (“T”, uppercase) is the dump. It prints every file nginx actually loaded, in order, with # configuration file: headers, then the same success line. That is the only way to see the fully assembled config, including includes you forgot and the distro’s default nginx.conf:
$ nginx -T | grep -n 'configuration file\|listen '
1: nginx: configuration file /etc/nginx/nginx.conf test is successful
3: # configuration file /etc/nginx/nginx.conf:
37:# configuration file /etc/nginx/mime.types:
138:# configuration file /etc/nginx/conf.d/default.conf:
141: listen 8090;
150: listen 8080;
164: listen 8082;
When two server blocks fight or a vhost mysteriously does nothing, nginx -T shows whether your file was even included, and in what order. Between nginx -t before each reload and nginx -T when reality disagrees with your file, almost every config mystery is a two-command fix.
That is the cookbook: rank locations by the ladder, keep your slashes straight, let try_files do the routing, never let a local add_header shadow a security header, choose return over rewrite, and prove the whole thing with -t and -T.