11.2 The if Trap and Its Cures
"location if" is a rewrite-phase directive pretending to be a control structure. Inside it, you are standing in a different location than you think — reproduced step by step.
Advanced
What you will learn
What
ifactually is (a rewrite-phase context jump, not flow control) — and what that breaks.The two sanctioned patterns:
returninsideif,setinsideif.The canonical trap, reproduced live:
try_files+ a matchingif= 404.The cure that scales:
map, and when you genuinely can't avoidif.
1. What
if really is
Lesson 2.4 told you NGINX "has no
if you should love." Here's the debt paid in full.
if comes from the
rewrite module — the same phase family as
set and
return (Lesson 11.1). It is not a general-purpose branch; it is a
conditional context switch:
When an
ifcondition matches, NGINX continues processing the request inside the if's implicit nested location. Rewrite-phase directives (set,return,rewrite) work there — they're evaluated early. But content handlers are not inherited into that nested context —proxy_pass,try_files,fastcgi_passset up in the outer location simply do not exist inside theif.
So the semantics depend entirely on what you
put in the
if: only rewrite-phase directives → they run and processing continues in the if-context; anything that assumed the outer content handler → silently gone. This is why the famous community wisdom says
if is "if (…){ rewrite… } or die trying."
2. The two sanctioned patterns
Both are safe because they never rely on the outer content handler surviving.
Pattern A —
return
inside
if: the request dies right there, on your terms, with your message.
location /api {
if ($arg_token = "") {
return 401 "missing token — add ?token=anything\n";
}
proxy_pass http://127.0.0.1:9001;
}Receipt (real capture):
missing token — add ?token=anything
status=401 <- with ?token=abc: 200, proxied to b-node-1
Pattern B —
set
inside
if: decide a flag early, consume it later. Since
set runs in the rewrite phase, the variable is ready before headers or handlers evaluate it.
location /tier {
set $tier free;
if ($arg_pro = 1) { set $tier pro; }
add_header X-Tier $tier;
return 200 "tier=$tier\n";
}Receipts (real captures — the flag moved, the world didn't):
X-Tier: pro <- /tier?pro=1
X-Tier: free <- /tierNote what makes this safe: the
if contains
only
set. The content phase (the
return, the
add_header evaluation) happens after, in the outer location, reading the variable the
if prepared. Phase separation is the whole discipline.
3. The trap, reproduced
The canonical foot-gun, straight from a decade of "nginx if is evil" threads — the museum's centerpiece:
location /trap {
try_files /this-file-never-exists @fallback;
if ($arg_mode = special) {
set $note "if matched";
}
}
location @fallback {
return 200 "fallback served (try_files worked — no if matched)\n";
}The intent is obvious: "when mode=special, note it, then serve like always." The reality (real captures):
no arg: status=200 (try_files -> @fallback)
?mode=special: status=404 (if matched: handler LOST -> 404)Walk the failure with the phase model from Lesson 11.1: the
if matches → processing jumps into the if's nested context → the content phase looks for a content handler there and finds
none (try_files doesn't inherit) → NGINX falls back to the implicit static handler → serves
/this-file-never-exists as a
file →
The
@fallbackyou so carefully configured never existed as far as that request is concerned.
Every variant of this trap has the same skeleton:
You wrote | What breaks when the if matches |
|---|---|
| fallback logic vanishes (this lesson's 404) |
| requests serve static files from the if-context — often 404s "randomly" |
| extra location-matching passes — occasionally loops |
| the if's headers replace the outer set entirely (Module 2's receipt) |
4. The cure:
map first
The museum's final exhibit rewrites the same intent with zero foot-guns:
# http{} context — map never lives inside a server/location
map $arg_mode $trap_mode {
special special;
default normal;
}
...
location /cured {
try_files /this-file-never-exists @cured_fallback;
}
location @cured_fallback {
return 200 "mode=$trap_mode — map decided, try_files intact\n";
}Receipt (real capture,
?mode=special):
mode=special — map decided, try_files intactSame input, same decision, but
map is a
lookup that happens when the variable is read, not a context jump. Nothing moves; no phase is entered or skipped;
try_files does its job and reads the answer off
$trap_mode. Module 2 taught the mechanics; this is the payoff:
any
if
whose branches only choose values is a
map
wearing a disguise.
The decision procedure that keeps configs honest:
Do both branches only
setorreturn? →ifis acceptable (patterns A/B).Does the condition choose a value used elsewhere? →
map, always.Does the condition need to switch handlers (different
proxy_pass)? → split into twolocations and let location matching do the branching — it's what it's for.Still tempted to put a content directive inside
if? → re-read section 3, then restructure.
5. When you truly can't avoid
if
Some conditions can't be pre-computed by
map — most often regex-matching a header
and acting on the match's captures (
map regexes can extract via named captures too, but complex multi-condition logic reads worse than it works). Then:
keep only rewrite-phase directives inside (
set/return/rewrite … break),compute a routing variable,
let a
locationor aproxy_passvariable consume it.
That's exactly the Module 7 WAF pattern (
map blacklist → sanctioned
if { return 403 }) and the Module 10 SNI router's spirit, now fully explained.
Try it yourself
curl -i 'http://127.0.0.1:9301/tier'→ X-Tier: free; ?pro=1 → pro. Then the other:9301endpoints, the trap and the cure:curl -i 'http://127.0.0.1:9301/trap?mode=special'→ 404; drop the arg → 200. Show a colleague; enjoy their face.curl -i 'http://127.0.0.1:9301/cured?mode=special'→ the map version doing the same job safely.Try to break
/api(pattern A) by adding aproxy_passinside theif—nginx -taccepts it without a murmur (proxy_passis legal in if-in-location), and that IS the trap: the match runs in the if's implicit nested location. For a refusal, trytry_filesinside theif— that one draws the error.
Q&A
Q: Why doesn't NGINX just fix
if
? A: Backward compatibility, and the phase model itself: content handlers are bound to locations at config time; a runtime branch can't conjure a second binding without becoming… a location. Which is the answer:
use locations as your branches.
Q: Is
if
in
server{}
(outside location) also dangerous? A: Less so — there's no content handler to lose yet; it's still rewrite-phase. But
map + variables still reads better.
Q: What about
error_page
tricks instead of
if
? A:
error_page 418 = @special; +
return 418 when the condition matches is a classic phase-safe branch — the "sanctioned-if pattern" Module 6's mTLS lesson used. It's pattern A with extra steps, and it's legitimate.
Takeaways
ifis a rewrite-phase context jump: matching requests continue inside an implicit nested location where outer content handlers (try_files,proxy_pass) do not exist.Sanctioned contents:
return(die on your terms) andset(decide a flag for later). Anything else is a trap wearing a condition.The live receipt from section 3:
try_files+ matchingif= 404 where 200 belongs — the canonical "nginx if is evil" evidence.Reach for
mapby default; branch withlocations when handlers must differ; treat anyifcontaining a content directive as a bug until proven otherwise.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.