nginx -t returns syntax is ok and test is successful. nginx -s reload finishes without a complaint. Nothing shows up in the error log.
And yet the behavior is wrong.
I've done this to myself more times than I'd like to admit. The one that hurt most was a CSP header I shipped to production that never reached a single browser for a full day — I only noticed because I got suspicious that no violation reports were coming in. There have been others: a file I put under /static/ getting swallowed by a different location, old behavior lingering after a reload. In every case nginx -t passed, and nothing was grammatically wrong anywhere.
The nature of this gap is clear: nginx -t validates only syntactic correctness, and never looks at semantic correctness.
What nginx -t actually guarantees for you
What nginx -t does, roughly speaking, is check "is this config file in a shape that nginx can load?" Does the directive name exist? Is the argument count right? Are the {} blocks closed? Are the value types valid? In compiler terms, it goes about as far as syntax errors and maybe type checking.
Everything past that point — "will this config process requests the way you intended?" — is outside the scope of nginx -t. This is where the blind spot is. When the test passes, you quietly reread it as "the config is correct." But all that passed was the syntax test.
Outside nginx -t, there are three layers of breakage.
- Semantic level — the config isn't read the way you intended. The grammar is perfect.
- Security level — it works exactly as you intended. It just also works exactly as the attacker intended.
- Runtime level — the config text is irrelevant. It's the behavior of the reload operation itself.
The higher layers are the ones you step on daily, and their cause is easy to look for inside the config file. The lower you go, the more it becomes the kind of incident where reading the config file gives you no answer at all. We'll go top to bottom. Reading this with your own nginx.conf open beside you should help.
Note that this article assumes the behavior of the nginx 1.31 (mainline) series. The stable series is 1.30, and the behavior covered here — if, location selection, add_header inheritance, alias, and reload — is the same across both. Where a version makes a difference, I'll call it out explicitly.
Semantic level: the grammar is correct, but it's read differently
The contents of if aren't evaluated in the order you think
When you want to "branch on a condition" in an nginx config, if is the first thing you reach for. But an if inside a location has long been called "if is evil" in the nginx community. It sounds like a religious argument if you only hear the name, but it's a statement about behavior.
First, why it breaks. if is a directive of the rewrite module, evaluated in an early phase of request processing (the rewrite phase). And the nastier part: when you write an if inside a location, that if block is treated as an implicit nested location. When the condition is true, nginx switches the request's entire configuration context into that inner location. Not every directive gets dropped (many settings like root or proxy_set_header carry over). But array-style directives in particular — like add_header — and content-processing directives may not carry over the way you expect. That's the trap.
Let's look at a concrete example. This is completely valid grammatically and passes nginx -t:
location /images/ {
add_header X-Served-By images;
if ($arg_size = large) {
add_header X-Size large; # I just want to add one header conditionally...
}
}
The author's intent is "add an extra X-Size header only when ?size=large." But if creates an implicit nested location, so when the condition is true, the config context switches to the inside of it. And add_header "does not inherit from the parent if there's even one at the current level" (we'll look at this behavior in detail in the add_header section below). The result: on a size=large request, only the inner X-Size rides along, and the outer X-Served-By disappears. What you meant to add has replaced instead. For the same reason, putting try_files or proxy_pass inside an if can get them ignored or make the URI silently rewrite, all because of this switch. And nginx -t says nothing. You ship it without noticing.
Detection and avoidance: The things that are genuinely safe inside an if in a location are, in practice, only return ..., rewrite ... last, set ... (and break). For anything else — header manipulation, try_files, proxy_pass — the moment you want to do it inside an if, that's a sign the design is wrong. Push the condition out into a map block (think of it as a lookup table that maps an input value to an output value), make a variable, and use that variable — or split the location. Not "if is evil, so don't use it," but "if creates an implicit location, so the outer config isn't inherited." Remember it by the reason, and next time your hand reaches for it, you can stop yourself.
Rewriting the earlier example with map looks like this:
# map goes in the http context (outside the server block)
map $arg_size $x_size {
default ""; # normally an empty string
large "large"; # gets a value only on ?size=large
}
server {
location /images/ {
add_header X-Served-By images;
add_header X-Size $x_size; # if the value is empty, this line isn't emitted
}
}
When the value of add_header is an empty string, it doesn't emit that header. So X-Size is attached only on ?size=large and not otherwise — exactly what you wanted originally, expressed without an if. And since both add_header directives are at the same level in location /images/, the problem of X-Served-By disappearing never happens. Replacing conditional branching with a "variable lookup" rather than a "control statement" is the basic form of avoiding if.
Location priority is not the order you wrote them in
When you write multiple location blocks, don't you assume "the one written higher up takes priority"? I did. And it bit me.
nginx's location selection runs on a rule that differs from the order you wrote. The procedure is this:
- If an
=(exact match) location matches the URI exactly, it's decided right there and nothing else is examined. - Otherwise, among the prefix locations (no modifier, or
^~), pick and remember the one with the longest match. Order within the config file is irrelevant here. The longest match wins. - If the selected prefix has
^~, it's decided there and regex is not checked. - Otherwise, check the regex locations (
~,~*) in written order, and the first that matches wins. Here, written order matters. - If no regex matches, use the prefix remembered in step 2.
So: "prefixes are longest-match (order-independent), regexes are first-match by written order, and regexes take priority over prefixes." That last point is what gets people. A reproduction:
location /static/ {
root /var/www;
}
location ~ \.(png|jpe?g|gif)$ {
expires 30d;
root /var/www/cache; # <- a different root
}
Let's trace a request to /static/logo.png. As a prefix, /static/ is the longest match and gets "remembered." But /static/ has no ^~. So nginx proceeds to the regex check, \.(png|jpe?g|gif)$ matches, and that one wins. The result: /static/logo.png goes looking in /var/www/cache. The /static/ block is skipped past. The grammar is perfect, nginx -t passes. Only your assumption — "I'm handling all static files under /static/" — is off.
Detection: Don't guess which location you're hitting — confirm it. The easy way is to temporarily add a marker header to the suspect locations:
location /static/ { add_header X-Loc "static" always; ... }
location ~ \.(png|jpe?g|gif)$ { add_header X-Loc "regex" always; ... }
Then hit curl -sI http://localhost/static/logo.png | grep X-Loc and you'll know instantly which one answered. Nailing down "where am I hitting right now" as a fact comes first.
Once you have that, if you want to lock it down, put ^~ on the prefix to stop the regex check itself:
location ^~ /static/ { # decided the moment this prefix is the longest match; regex is not examined
root /var/www;
}
With add_header, adding one in the child makes all the parents disappear
This is the trap that most symbolizes the "semantic level" of the three layers, and the one I was stuck on the longest.
As a premise, many people write security headers like HSTS or CSP once in the server block, intending them to apply to every location.
server {
add_header Strict-Transport-Security "max-age=63072000" always;
add_header Content-Security-Policy "default-src 'self'" always;
location /api/ {
add_header Cache-Control "no-store" always; # <- the moment you add this
proxy_pass http://backend;
}
}
You just added one cache-control header to /api/. But nginx's add_header is specified so that if there's even one add_header at the current level, it does not inherit any add_header from the parent level at all. It's replacement, not merging. The result: HSTS and CSP disappear from the /api/ response, and all that's left is Cache-Control. Only the security headers go silently missing.
This "array-style directives don't inherit (don't merge with) the parent once defined in a child" behavior is not unique to add_header. proxy_set_header and fastcgi_param have the same trap. Write this, for example, and Host and X-Forwarded-For stop being passed to the backend:
server {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location /api/ {
proxy_set_header X-Request-Id $request_id; # <- just added one
proxy_pass http://backend;
# neither Host nor X-Forwarded-For is inherited here
}
}
Detection: Missing headers — the only sure method is to actually look at the response. At each endpoint where you "think" you set a header, compare against the real thing.
curl -sI https://example.com/ | grep -i -E 'strict-transport|content-security'
curl -sI https://example.com/api/foo | grep -i -E 'strict-transport|content-security'
Present at the top but gone under /api/ is the classic signature of this trap.
How to deal with it: The most portable fix is to re-list every header you need in the child block. Split the common part into a separate file and include it, so you gather the duplication into one place while re-declaring it at each level:
# security-headers.conf
add_header Strict-Transport-Security "max-age=63072000" always;
add_header Content-Security-Policy "default-src 'self'" always;
server {
include security-headers.conf;
location /api/ {
include security-headers.conf; # re-read and re-declare in the child too
add_header Cache-Control "no-store" always;
proxy_pass http://backend;
}
}
The add_header_inherit merge; added in nginx 1.29.3 lets you change the behavior to "inherit the parent, then add the child" (it's in both the current stable 1.30 series and mainline 1.31 series). But it's not in the 1.28-and-earlier stable series, so it may not be available on your version — getting the premise "it's replacement" into your bones is more effective first.
Forget one trailing slash on alias, and you can climb outside the directory
Everything so far has been about "behavior that differs from intent." This alias trap is the same kind of mistake — a single-slash difference — but the quality of the outcome is different. The config looks like it works as intended and does, yet you can read files outside the published directory. It's not just a behavior bug; it's a security hole as-is.
A reproduction. It's the commonplace setup of "I want to serve static files from a different directory":
location /assets { # <- no trailing slash
alias /var/www/static/; # <- this one has a trailing slash
}
/assets/logo.png is served normally. nginx -t passes, and as far as the browser is concerned there's no problem. It looks like it works as intended.
Why it's dangerous. alias builds the real file path by replacing the portion of the URI that matched the location (here, /assets) with the value of alias. When the location is /assets (no slash), the string following /assets gets stuck on directly at the end. So a request for /assets../ resolves to /var/www/static/ + ../ = /var/www/static/../ = /var/www/. That means with a little trick like /assets../../etc/passwd, you can climb outside the intended published directory. It's a classic path traversal (an attack that walks back up directories to read non-public files on the server). Normal access never notices it at all.
Detection and fix: The way to spot it is simple — line up the trailing slashes on the location and the alias. Either both have one, or neither does. In the example above, make it location /assets/. Then /assets../ no longer matches this location in the first place, and the climb is gone. To audit an existing config, use nginx -T (below) to dump all locations, and eyeball just the blocks containing alias for the trailing-slash correspondence. To reproduce it at hand, poke one level up with curl: if curl -sI 'http://localhost/assets../' returns not a 200 or 403 but "something that shouldn't be visible," you've got a hit.
Security level: it works exactly as intended, which is why it's dangerous
The semantic-level traps were "behavior that differs from intent." From here, the quality changes. The config works exactly as you intended. It just also works exactly as the attacker intended. So it passes not only nginx -t but even a visual review, on a "well, it's working" basis.
Passing a client-touchable variable straight into proxy_pass
Using a variable for the proxy_pass destination lets you decide the destination dynamically per request. Handy. But it's a different story if the source of that variable is client-derived.
location /fetch/ {
proxy_pass http://$arg_target; # ?target=... decides the destination
}
With a request like /fetch/?target=internal-admin:8080/, nginx can relay the request to any host inside your network. This is a textbook entry point for SSRF (server-side request forgery, an attack that uses your server as a stepping stone to send requests to places it otherwise couldn't reach). Including a variable in proxy_pass also changes how the URI is handled and how name resolution behaves (you need a resolver if you use a hostname), and while you're distracted by those details, it's easy to overlook the danger that the destination is client-controlled.
I won't chase this further here — SSRF is a topic that deserves its own article. The one thing I want to nail down in this piece: "nginx -t passes" and "it's safe" are entirely separate things. Whether a value the client can influence flows into proxy_pass, a rewrite destination, or a root/alias path — that perspective is completely outside the syntax check.
Runtime level: the config text is no longer relevant
The last layer has a different flavor. Here the contents of the config file are correct. What "looks" broken is the behavior of the reload operation itself. Stare at the config and the answer isn't written there.
After a reload, the old behavior lingers for a while
You fix the config and nginx -s reload. It succeeds. And yet, for a while, the old behavior is observed. It looks like a bug, but this is nginx's normal behavior.
When it receives a reload (SIGHUP to the master process), nginx doesn't suddenly switch everything over. It starts new worker processes with the new config and tells the old workers to shut down gracefully. Gracefully — that's the point. The old workers stop accepting new connections, but they take care of requests already in flight to the end before terminating.
Normally this finishes in an instant, so you don't notice. The problem is when there are long-lived connections. WebSockets, large file downloads, long polling. An old worker holding one of these stays put until that connection ends. In other words, a window forms where the old and new configs are alive at the same time. That's the true identity of "the old behavior lingers after a reload." The config has been updated. It's just that a worker with the old config is still running.
Detection: This one you look at processes, not the config.
ps -eo pid,ppid,command | grep '[n]ginx'
Old workers linger with the label nginx: worker process is shutting down. If this "shutting down" keeps sitting alongside normal workers (nginx: worker process), that's the substance of old-and-new coexisting. If you want to cap how long they linger, set worker_shutdown_timeout:
worker_shutdown_timeout 30s; # force old workers to terminate after at most 30 seconds
The important thing here is not to chase this as a bug. The cause isn't written in the config file — it's in the single fact that "reload is that kind of operation."
Also, confirming worker generations with ps requires being able to log into that server. I'm also working on a small tool that peeks — from the outside, without touching the running nginx at all — at which worker generation is holding connections after a reload, using eBPF (ngxray, work in progress).
Summary: how to fill in the space outside the syntax check
nginx -t doesn't lie. It just answers a narrow question. "Is it in a loadable shape?" — it answers that. "Is it read as intended?" "Can it be abused by an attacker?" "What happens during a reload?" — it was never answering those to begin with.
Each of the three layers is filled in differently.
-
Semantic level (
if, location priority,add_headerinheritance drops) can be caught by looking at the actual response. Marker headers and the difference incurl -I. Get into the habit of comparing behavior withcurl -sIrather than reading the config and convincing yourself. Flattening the "config that's actually in effect" once withnginx -T(uppercase; it dumps the final config with allincludes expanded) also helps. -
Security level is about tracing where client-derived values flow. Whether external input reaches
proxy_pass, a destination, or a path. -
Runtime level is about looking at processes and connections, not the config. Check worker generations with
ps.
The syntax check is only the first sheet of the layers. From the second sheet down, for now, I fill it in with human observation and discipline. Having a machine watch this space — reading the config not just by syntax but by "meaning," warning about location overtakes and header inheritance drops — static analysis, and a mechanism that continuously observes the actual responses, is the topic I want to think about next.
For now, one line is all I want you to take home. When nginx -t passes, the next thing to hit is curl -sI. That the syntax is correct and that it works as intended are two things you confirm separately.
References (official documentation)
The behavior in this article can all be backed up by official documentation. When you suspect your config, this is the first place to check.
-
ngx_http_rewrite_module (
if) and If Is Evil — the story ofifcreating an implicit location -
The
locationdirective — the selection algorithm (longest prefix and regex priority) -
ngx_http_headers_module (
add_header) — inheritance rules andadd_header_inherit, added in 1.29.3 -
The
aliasdirective — how the real file path is assembled -
proxy_pass— behavior when using a variable in the destination, andresolver -
Controlling nginx and
worker_shutdown_timeout— reload and graceful shutdown
Top comments (1)
Excellent breakdown of the gap between syntactic validity and operational correctness. One thing I’d add is configuration regression testing. For larger environments, we’ve found it valuable to treat nginx configs like application code—keeping a suite of expected request/response cases and running them automatically after every configuration change. That catches semantic regressions long before they reach production and reduces the reliance on manual curl checks.
References: