DEV Community

Kazu
Kazu

Posted on • Edited on

Silent nginx Config Bugs That Pass nginx -t: if, location, add_header, alias

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.

  1. Semantic level — the config isn't read the way you intended. The grammar is perfect.
  2. Security level — it works exactly as you intended. It just also works exactly as the attacker intended.
  3. 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 content-processing directives like try_files or proxy_pass may not carry over the way you expect. That's the trap.

Let's look at a concrete example. A common, simple setup: serve a static site, but route just the requests under /api/ to a backend. It's completely valid grammatically and passes nginx -t:

location / {
    root /var/www/html;
    try_files $uri $uri/ =404;      # serve static files

    if ($uri ~ ^/api/) {
        proxy_pass http://backend;  # route only /api/ to the backend...
    }
}
Enter fullscreen mode Exit fullscreen mode

The author's intent is "normally serve static files, and only proxy to the backend when the path starts with /api/." But if creates an implicit nested location, so when the condition is true the config context switches wholesale into it. Inside there's only proxy_pass; the outer content processing like root and try_files isn't carried into the switched-to location. Worse, once there's an if in the location, try_files can stop being evaluated as expected even for requests where the condition is false. The grammar is correct and nginx -t says nothing, so you get "occasional 404s" and "occasional un-proxied requests" without ever 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 — try_files, proxy_pass, header manipulation — the moment you want to do it inside an if, that's a sign the design is wrong. When you want to "route by path" like here, the answer is not if but splitting the location. Routing in nginx is location's job; if breaks it by cutting in.

Rewriting the earlier example by splitting the location looks like this:

location / {
    root /var/www/html;
    try_files $uri $uri/ =404;      # static serving is this block's only job
}

location /api/ {
    proxy_pass http://backend;      # /api/ is a separate location from the start
}
Enter fullscreen mode Exit fullscreen mode

Making /api/ its own location means no implicit nesting and no config-context switch. The static-serving location / and the proxying location /api/ each hold only their own processing. Replacing conditional branching with "location as routing" rather than "if as a control statement" is the basic form of avoiding if. When you want to branch on a value rather than a path, push the condition out into a map (a lookup table that maps an input value to an output value) with the same mindset, and use the resulting variable.

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:

  1. If an = (exact match) location matches the URI exactly, it's decided right there and nothing else is examined.
  2. 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.
  3. If the selected prefix has ^~, it's decided there and regex is not checked.

  4. Otherwise, check the regex locations (~, ~*) in written order, and the first that matches wins. Here, written order matters.

  5. If no regex matches, use the prefix remembered in step 2.

"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
}
Enter fullscreen mode Exit fullscreen mode

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; ... }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

/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, add a trailing slash to the location side too, like this:

location /assets/ {          # <- add the trailing slash so they match
    alias /var/www/static/;  # <- alias side has a trailing slash too
}
Enter fullscreen mode Exit fullscreen mode

Now /assets../ no longer matches this location in the first place, and the climb is gone. Normal requests like /assets/logo.png are still served exactly as before.

If the location and the directory name match in the first place, the safest move is to drop alias and lean on root instead. root doesn't replace the location portion — it appends the URI as-is under the directory — so this trailing-slash problem simply can't occur:

location /assets/ {
    root /var/www/static;    # /assets/logo.png -> /var/www/static/assets/logo.png
}
Enter fullscreen mode Exit fullscreen mode

(Note that root appends the URI path verbatim, so the real directory has to be laid out as /var/www/static/assets/ too. When you don't want to change an existing physical layout, take the alias + matched-trailing-slash route instead.)

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
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_header inheritance drops) can be caught by looking at the actual response. Marker headers and the difference in curl -I. Get into the habit of comparing behavior with curl -sI rather than reading the config and convincing yourself. Flattening the "config that's actually in effect" once with nginx -T (uppercase; it dumps the final config with all includes 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.

Top comments (10)

Collapse
 
ggle_in profile image
HARD IN SOFT OUT

nginx -t is the ultimate gaslighter. It smiles at you, says "syntax is ok," and then lets you ship a CSP header that never reaches a single browser while you spend the next 24 hours wondering why your violation reports are suspiciously quiet. I have been in that exact spot, and the worst part is you cannot even blame the tool—it told the truth, just not the truth you needed.

This article is a goldmine. The way you structured it by layers—semantic, security, runtime—really helps separate the "I misconfigured something" from the "the system is doing exactly what I told it, which is the problem." The alias trailing slash trap is one I have personally fallen into, and the path traversal vector is honestly terrifying when you realize how trivial it is to exploit.

One thing I would love to see built on top of this knowledge: a static analysis tool that lints Nginx configs the way ESLint does for JavaScript. Something that warns about if inside location, flags mismatched trailing slashes on alias, and checks whether add_header in a child block is wiping out security headers from the parent. nginx -t only catches grammar, but a proper linter could catch intent. I know there are some community tools out there, but nothing that feels as comprehensive as this article.

Also, since you already have the curl -sI technique as a quick check, I would suggest turning that into a small smoke test suite that runs automatically in your CI pipeline after every config change. Just a handful of endpoints that verify the expected headers and status codes are actually coming through. That would catch most of the semantic-level traps before they even hit production, and it costs almost nothing to run.

Anyway, I am bookmarking this and keeping it open next time I touch an Nginx config. Thanks for writing the guide that the official docs never quite got around to.

Collapse
 
shinagawa-web profile image
Kazu

Thanks for reading and for the kind words. That ESLint analogy really hits. A linter that catches the add_header wipe-out, flags alias slash mismatches, and warns on if inside location is exactly the gap nginx -t leaves open. Something worth building.

Collapse
 
ggle_in profile image
HARD IN SOFT OUT

Glad to see I am not the only one who has spent a late night staring at a CSP header that never made it to the browser, wondering if I have finally lost my grip on reality. There is something uniquely humbling about realizing the tool you trusted has been gaslighting you in plain sight.

On the linter idea: I have been thinking about what a proper Nginx linter would actually look like, and I think the hardest part is not the parsing—it is the intent detection. nginx -t knows grammar, but a linter would need to know semantics. For example:

if inside a location is not always wrong, but it is almost always a code smell. A linter could warn with "consider using map or splitting this location instead."

add_header in a child block wiping out parent headers is a classic trap. A linter could track the inheritance chain and warn when a child add_header is present without also including the parent's headers.

alias trailing slash mismatches are trivial to catch with a simple regex or AST check.
Enter fullscreen mode Exit fullscreen mode

There is a tool called gixy that does some of this, but it has not been updated in years and does not cover many of the newer gotchas. A modern linter could also integrate with nginx -T to analyze the effective configuration after includes are expanded—which would catch issues that span multiple files.

Also, I love the CI smoke test idea you already suggested. A simple curl -sI suite that runs after every config change would catch most of the semantic issues before they reach production. It costs almost nothing and saves hours of "why is this not working" debugging.

If you ever decide to build that linter, count me in as a contributor. I would love to help shape a tool that actually understands what Nginx configs mean, not just what they look like.

Thanks again for writing the article that finally convinced me to stop trusting nginx -t blindly.

Collapse
 
ndcodes profile image
Nnamdi Felix Ibe • Edited

This is the missing half of a post I published a few days ago. I leaned on nginx -t as the test-before-reload safety check and basically stopped there, which is the exact false confidence you're naming. The syntax passes, you reread it as "correct," and the semantic and security layers walk straight through the gap.

The add_header one would have gotten me. Adding a single Cache-Control to a location and silently losing the inherited HSTS and CSP is nasty precisely because it looks additive when it's actually a replacement. Same trap on proxy_set_header. I did not know that.

The alias trailing-slash traversal is the one I'm auditing tonight.

"When nginx -t passes, the next thing to hit is curl -sI" is the line I'm keeping. The top-to-bottom framing from semantic to security to runtime is what makes it stick. Following your work.

Collapse
 
shinagawa-web profile image
Kazu

Thanks for reading! The add_header replacement behavior is one that bites quietly.
Glad it was useful. Good luck with the alias audit tonight; the trailing-slash case is easy to miss until you test it with an actual path traversal attempt.

Collapse
 
voltagegpu profile image
VoltageGPU

Great article — those silent misconfigurations can be real time bombs! I've run into similar issues when using nested if blocks in location contexts, where the logic isn't enforced as expected despite passing nginx -t. It's a good reminder that config validation isn't always foolproof, and real traffic testing is essential.

Collapse
 
shinagawa-web profile image
Kazu

Thanks for the comment! Nested if blocks in Nginx are tricky. The behavior can be really counterintuitive even when nginx -t passes cleanly. Totally agree that testing with real traffic is the only way to be sure. Glad the article resonated!

Collapse
 
merbayerp profile image
Mustafa ERBAY

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:

Collapse
 
shinagawa-web profile image
Kazu

Thanks for the addition — regression testing for nginx config is something I hadn't thought about as a first-class practice. The idea of running expected request/response cases automatically after every config change is a clean way to close the loop that nginx -t can't close.

Collapse
 
merbayerp profile image
Mustafa ERBAY

I’m glad it was useful! 🙂 We eventually realized that nginx -t is similar to compiling code—it proves the configuration is valid, not that the behavior is correct. That’s why we started adding configuration regression tests to our CI/CD pipeline.

Over time, we also found value in validating security headers, cache behavior, redirects, and even expected status codes automatically after each deployment. Once those checks become part of the pipeline, configuration changes become much less stressful.