You write a rewrite, then a try_files right under it, and you read it as "the rewrite on top runs first, the try_files below runs after." You put an if inside a location and read it as "when the condition is true, this stuff gets added to the outer config." Both readings trace the config file straight down, line by line, in the order it's written. And both have burned me.
Let me show you the two symptoms first. In each case the config reads cleanly, yet the behavior doesn't line up. Why that happens — the order in which nginx processes a request — is what this post is about.
Symptom 1: rewrite and try_files side by side, and you get a 404
In some location you want to rewrite the request once, then fall back to a static file. The naive version looks like this:
location /app/ {
rewrite ^/app/(.*)$ /public/$1 last;
try_files $uri $uri/ =404;
}
The intent is "rewrite /app/foo to /public/foo, serve the file if it exists, 404 otherwise." Since rewrite is on top and try_files is below, the rewrite takes effect first and then try_files handles the fallback — that's how it reads.
But throw /app/logo.png at it and you get a 404, even when /public/logo.png really exists.
$ ls /public/logo.png
/public/logo.png # the file is definitely there
$ curl -sI http://localhost/app/logo.png
HTTP/1.1 404 Not Found # and yet, 404
The try_files in this location is never evaluated once.
Symptom 2: config placed inside an if block doesn't take effect
In another location you want to add one header conditionally. The naive version looks like this:
location /download/ {
add_header X-Served-By "nginx" always;
if ($arg_debug = "1") {
add_header X-Debug "on" always; # only want this when ?debug=1
}
root /var/www;
try_files $uri =404;
}
The intent is "always add X-Served-By, and add X-Debug on top of it only when ?debug=1." But hit /download/foo?debug=1 and X-Debug shows up while X-Served-By, which you set on the outside, has vanished. The moment the condition goes true, part of the outer config drops.
Both configs read cleanly when you scan the lines top to bottom. And both miss.
How it works: nginx's HTTP processing phases
nginx runs each request through a fixed sequence of processing phases. Which phase a given directive runs in is determined per directive type, and has nothing to do with where the line sits in the config file. Both symptoms come out of exactly this one fact.
Start with the big picture. Lay out the phases an HTTP request passes through, in order, and you get this (source: the official Phases section of the Development Guide).
post-read right after the request is read
↓
server-rewrite rewrite directly under server (before location selection)
↓
find-config pick the location
↓
rewrite rewrite / if inside the location
↓
post-rewrite if the URI changed, go back to find-config
↓
preaccess connection count, rate limiting, etc.
↓
access allow / deny / auth
↓
post-access
↓
precontent try_files
↓
content serving files via proxy_pass / root
↓
log access log
The same rewrite runs before location selection (server-rewrite) when written directly under server, and after it (rewrite) when written inside a location. Where you put it changes the phase, which changes the order it runs in. What decides the order is not the line sequence but "which block, and which type of directive" — that's how you read this list. Now let's follow how each of the two symptoms travels along this timeline.
Symptom 1, followed by phase
The part that matters for rewrite ... last and try_files is this stretch.
┌─▶ find-config pick the location (first pass: /app/. with last, the second pass picks a different one)
│ ↓
│ rewrite rewrite ... last rewrites to /public/foo
│ ↓
└── post-rewrite URI changed → back to find-config
↓
precontent try_files runs here. we're now in the different location picked on the second pass.
the try_files written in the original /app/ is never reached → 404
The last in rewrite ... last means "once you've rewritten the URI, redo location selection with the new URI." So the instant /app/foo becomes /public/foo, the post-rewrite phase calls find-config back and picks a location all over again. The rewritten /public/foo no longer matches /app/, so it moves to a different location. Meanwhile try_files runs way later, in the precontent phase. It may get evaluated wherever the request landed, but the try_files written in the original /app/ is never reached again. If the destination has no file and no fallback, that's where the 404 comes from. It's not that your =404 fired — the request dead-ended somewhere else, never reaching the original try_files. "Written below, so it runs after" is not what happens; the phases differ, so regardless of line position the rewrite fires first and jumps out of the location.
Symptom 2, followed by phase
if and add_header take effect in two phases that are far apart.
find-config pick the location (/download/)
↓
rewrite if is true → switch the config context to the inner block
↓ (the switch carries over into all later phases)
preaccess connection count, rate limiting, etc.
↓
access allow / deny / auth
↓
post-access
↓
precontent try_files
↓
content apply add_header when the response is being sent
it looks at the block we switched into (X-Debug only)
→ the outer X-Served-By is gone
An if inside a location is an "implicit nested location" — it creates a config block of its own. And that if is a rewrite-phase directive. When the condition goes true, this phase switches the request's config context into the inner block, and that state carries over into the phases that follow. Much later, when add_header is applied at the point of sending response headers, what it reads is the block we switched into. All that's inside is X-Debug, and on top of that, add_header does not inherit from the outer scope once there's even one in the same block (it replaces rather than accumulates), so X-Served-By disappears entirely. If the condition is false, no switch happens and X-Served-By shows up as usual. The outer header vanishing is not about line order — it's because the switch that happened in the rewrite phase is still in effect in the later phases. What people call if is evil is, at bottom, this "context switch in the rewrite phase."
Fixes
Symptom 1
If you want to keep processing in the same location after the rewrite, change last to break. With break, the post-rewrite return to find-config doesn't happen, so the request can go down to the precontent try_files while staying in the same location.
location /app/ {
rewrite ^/app/(.*)$ /public/$1 break; # last → break
try_files $uri $uri/ =404;
}
If instead you want to route the request to a different location depending on the URI, leave last as is. In that case, put the fallback in the destination location.
location /app/ {
rewrite ^/app/(.*)$ /public/$1 last;
}
location /public/ {
root /var/www;
try_files $uri $uri/ =404; # put the fallback in the destination
}
Which one is right comes down to a design call: do you want to keep processing in the same location after the rewrite, or hand off to a different one?
Symptom 2
Don't put content-handling directives (add_header, proxy_pass, try_files, and so on) inside an if. Treat return and rewrite as about the only things that are safe inside if.
The condition here is a query argument (?debug=1), and location can't match on the query string. For branching on a value like this, push it out of if and into a map that builds a variable.
map $arg_debug $x_debug {
default "";
"1" "on";
}
server {
location /download/ {
add_header X-Served-By "nginx" always;
add_header X-Debug $x_debug always;
root /var/www;
try_files $uri =404;
}
}
The two add_header lines sit at the same level, so no nested block replaces anything and both take effect. When $arg_debug isn't 1, $x_debug is empty, and nginx won't emit an add_header with an empty value, so X-Debug shows up only during debugging.
Checking which location you're in right now
The feel for phases sticks once you get your hands dirty. The quick way is to plant one marker in a suspect location to tell whether the request settled there.
location /app/ {
add_header X-Loc "app" always; # marker that shows up if you stay in this location
rewrite ^/app/(.*)$ /public/$1 last;
try_files $uri =404;
}
Look at the headers with curl -sI http://localhost/app/foo, and if X-Loc: app isn't there, the request has already left this location via rewrite ... last (the add_header you wrote here has no effect once it's gone). Change last to break, hit it again, and if X-Loc: app appears, the request stayed in the same location and made it down to precontent. One marker header lets you see, across phases, which location the request is in right now.
Wrapping up
When you read an nginx config, your eyes follow the lines top to bottom. But what nginx actually does is run the request along a timeline of phases. When you're questioning a config, don't trace the line order — trace it in this order instead: "which phase does this directive run in? and at that point, which location is the request in?" That usually makes things line up.