skip to content

In nginx, a `server` block sets `add_header X-App "checkout";` and one `location` inside it declares an `add_header` of its own. Requests to that location stop carrying X-App. What inheritance rule explains that, and how do you fix it?

level: middleimportance: should knowfreq 55%

answer

  1. all-or-nothing, never merged
  2. one entry cancels the inherited list
  3. same trap for proxy_set_header
  4. shared snippet, included at every level
  5. always is about status codes, not inheritance

basics

~20 s

nginx inherits add_header directives from the enclosing level only if the current level declares none of them. Defining one add_header in the location discards the entire inherited list rather than adding to it, so the server-level header disappears.

solid answer

~50 s

This is nginx's replace-not-merge rule for list-valued directives. `add_header` — and `proxy_set_header` the same way — is documented as inherited from the previous configuration level *only if there are no directives of that type defined on the current level*. The moment the `location` declares one `add_header`, the whole inherited set is dropped, and only the location's own headers are sent. There is no partial merge and no warning; `nginx -t` is perfectly happy. The fix is to make every level that declares any of these directives declare all of them, which in practice means pulling the shared headers into a snippet file and `include`-ing it in both the server block and each location that adds its own. The alternative is to never add headers below the level where the shared set is defined. Note that `always` is unrelated — it controls whether a header is emitted on error responses, not whether inheritance happens.

code

nginx · 14 lines
nginx
server {
    add_header X-App "checkout";

    location / {
        # X-App is sent here
        return 200 "ok\n";
    }

    location /dl {
        add_header Content-Disposition "attachment";
        # X-App is NOT sent here: the level declares its own add_header
        return 200 "file\n";
    }
}

go deeper

for a junior

Know that headers set higher up can vanish in a location that sets its own, and that the safe habit is to include the shared header snippet wherever you add a header.

for a middle

State the rule exactly — inherited only if the current level declares none of that directive — and show the include-snippet fix, plus that proxy_set_header behaves the same way.

for a senior

Demonstrate how you find it in production: compare response headers per path, read the resolved config from nginx -T, and add a per-location header assertion to the deploy smoke test.

for a principal

Set the standard that prevents it: one shared snippet as the source of truth, a rule about which levels may declare header directives, and an automated check so a new location cannot quietly drop the fleet-wide set.

## Two kinds of directive inheritance Most nginx directives hold a single value. `client_max_body_size`, `keepalive_timeout` and `proxy_read_timeout` are inherited into nested contexts, and if a nested context declares its own value, that value simply replaces the inherited one for that block. Nobody is surprised by that. A second family holds a *list*: `add_header` appends response headers, `proxy_set_header` sets request headers passed to an upstream. Several of these directives can appear in one block and they accumulate within that block. Their inheritance rule is different, and it is the source of one of nginx's most persistent production bugs. The documentation states it precisely: these directives are inherited from the previous configuration level **if and only if there are no directives of the same type defined on the current level**. Read the negation carefully. It is not "the current level's entries are added to the inherited ones". It is "declaring even one entry at this level discards every inherited entry". ## What that looks like in a real config ```nginx server { add_header X-App "checkout"; add_header X-Frame-Options SAMEORIGIN; location / { # inherits both headers above } location /report.csv { add_header Content-Disposition "attachment"; # X-App and X-Frame-Options are now GONE for this path } } ``` Requests to `/` carry both server-level headers. Requests to `/report.csv` carry only `Content-Disposition`. The config passes `nginx -t`, nothing appears in the error log, and the loss is visible only if you diff response headers between two paths — which is exactly why this usually reaches production and is found later by a security scan or a broken client. The same rule bites harder with `proxy_set_header`, because nginx supplies implicit defaults when no `proxy_set_header` is present at all (`Host $proxy_host` and `Connection close`). A `location` that sets one custom header therefore also throws away any `proxy_set_header` block defined at the server or http level, so a carefully assembled set of forwarding headers can vanish for one path. ## Fixes that actually work **Repeat the full set at every level that declares any.** Tedious by hand, reliable when factored into a file: ```nginx # /etc/nginx/snippets/app-headers.conf add_header X-App "checkout"; add_header X-Frame-Options SAMEORIGIN; ``` ```nginx location /report.csv { include /etc/nginx/snippets/app-headers.conf; add_header Content-Disposition "attachment"; } ``` The include is textual — the directives land in the location as if typed there — so the level now declares the complete list and nothing is lost. This keeps one editable source of truth while satisfying the all-or-nothing rule. **Or declare the shared set at exactly one level and never add below it.** If no `location` uses `add_header`, every location inherits the server's set cleanly. This is the simplest policy for a small config, but it is a policy the whole team has to hold, because a single future location that adds one header re-introduces the bug quietly. **Do not reach for `always`.** `add_header ... always` changes *which response codes* the header is emitted for — without it, nginx adds the header only on a specific set of successful and redirect responses, so headers go missing on 4xx and 5xx pages. That is a different, also-common bug worth knowing, but it has no effect on inheritance. Stock nginx has no merge mode for these directives. Third-party modules exist that behave differently, but assuming one is present is a bad default. ## How you confirm it `nginx -T` prints the resolved configuration, so you can see which levels declare `add_header` and predict where the inherited list is cut off. Then verify on the wire per path — `curl -sI https://host/report.csv` next to `curl -sI https://host/` — because the whole point of this bug is that it is path-specific and invisible in aggregate.

  • Does the same rule apply to `proxy_set_header`, and why is it more dangerous there?
    Yes, identically. It is more dangerous because nginx supplies implicit defaults when a level has no `proxy_set_header` at all, so a location that adds a single custom header both discards the inherited forwarding headers and loses those implicit defaults. Forwarding headers you assumed were global then silently stop reaching the upstream for that one path.
  • What does the `always` parameter on `add_header` change?
    Without `always`, nginx emits the header only on a limited set of response codes — 200, 201, 204, 206, 301, 302, 303, 304, 307, 308 and a few others. With `always`, it is emitted regardless of status, so the header also appears on 4xx and 5xx responses. It has been available since nginx 1.7.5 and does not affect inheritance at all.
  • How would you detect this problem across a large config before it ships?
    Render the effective config with `nginx -T` and flag every context that declares `add_header` or `proxy_set_header` while an enclosing context also declares some — those are the cut points. Back it with a smoke test that curls a representative URL per location group and asserts the expected header set, since only a per-path check catches it.

saying these in an interview costs you the question

  • Assumes inner and outer add_header lists are merged
  • Thinks `always` restores the inherited headers
  • Believes nginx -t would warn about the lost headers
  • Says the header is missing because the location did not match
  • Adds the header in the http block and assumes deeper levels keep it

context