DEV Community

Kazu
Kazu

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

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

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:

  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.

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
}
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, 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
}
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 (1)

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: