加了 add_header,为什么响应头反而少了一个
这是一个真实到不能再真实的场景:你的 Nginx 站点在 http 块里统一加了安全响应头,在 location 块里又想针对静态资源单独加一个 Cache-Control。配置写完之后 nginx -t 通过、reload 成功,但你 curl -I 一看:安全头全没了,只剩 Cache-Control。
很多人第一反应是"配置没生效"或者"被上游覆盖了",于是去查 CDN、查应用层,绕了一大圈。真正的原因藏在 Nginx 的 add_header 指令一条不太直观的规则里:add_header 不继承,只要子层级里出现任何一条 add_header,父层级的所有 add_header 全部失效。
这个问题在个人站长圈里发生的频率极高,尤其在给站点加 HSTS、CSP、X-Frame-Options 这些 SEO/安全相关响应头的时候。本文把这个机制彻底讲清楚,并给出三种可靠的写法。
一、先复现问题,把机制看清楚
写一个最小配置来复现:
server {
listen 80;
server_name test.local;
# 父层级:加了两个全局安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
root /var/www/html;
return 200 "hello";
}
location /static/ {
root /var/www/html;
# 子层级里加了一条缓存头
add_header Cache-Control "public, max-age=31536000" always;
}
}验证结果:
curl -sI http://test.local/ | grep -iE 'x-frame|x-content|cache-control'
# 输出:
# X-Frame-Options: SAMEORIGIN
# X-Content-Type-Options: nosniff
# (没有 Cache-Control,符合预期)
curl -sI http://test.local/static/a.css | grep -iE 'x-frame|x-content|cache-control'
# 输出:
# Cache-Control: public, max-age=31536000
# (两个安全头都不见了!)这就是标准的"覆盖"行为。Nginx 官方文档里 add_header 的说明中有一句很容易被忽略的话,大意是:这些指令只有在当前层级没有任何 add_header 被定义时才从上一层级继承。一旦当前层级定义了哪怕一条,继承链就断开,父层级的所有 add_header 都不再生效。
这个设计跟 proxy_set_header 的"逐条继承"完全不同,所以从其他指令迁移过来的人特别容易踩。
二、为什么 Nginx 要这么设计
理解设计动机有助于记住这个坑。响应头是有冲突语义的,比如 Cache-Control 你在父层级写了 no-cache,子层级又想要 max-age=31536000,如果两者都发出去,客户端行为就不可预测了(HTTP 规范下多个 Cache-Control 会合并,结果是矛盾的)。
所以 Nginx 选择了最简单的策略:子层级定义了就完全接管,不再合并。这保证了你在某个 location 里写下的响应头就是最终结果,不会有"看不见的父级头"混进来。代价就是你必须重复声明,或者用变量统一管理。
注意区分:proxy_set_header、fastcgi_param 这类是逐条独立继承的,父级设了 A、子级设了 B,最终 A 和 B 都在。而 add_header 是"全有或全无"。同一个配置文件里两种语义并存,这是最容易混淆的地方。
三、方案一:把所有 add_header 用 include 收口到一处
最省事、也最推荐个人站长使用的方案:建一个 snippet 文件,所有需要这些头的层级都 include 同一个文件。
# /etc/nginx/snippets/security-headers.conf
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;然后每个 location 里需要时都 include:
server {
listen 80;
server_name test.local;
include snippets/security-headers.conf;
location /static/ {
root /var/www/html;
include snippets/security-headers.conf; # 必须重复这一行
add_header Cache-Control "public, max-age=31536000" always;
}
}这样做的核心好处是单一数据源:以后要加 HSTS,只改 snippet 一个文件,所有 include 它的地方同步生效。缺点是每个 location 都要记得写 include,漏了就静默失效——所以建议配合下面的自动校验。
自动校验脚本(把期望的头和实际返回的头做比对,挂到部署流程里):
#!/bin/bash
# check_headers.sh 部署后执行,确保关键响应头没有静默消失
set -uo pipefail
HOST="$1"
URLS=(
"http://$HOST/"
"http://$HOST/static/test.css"
"http://$HOST/api/health"
)
REQUIRED=("X-Frame-Options" "X-Content-Type-Options" "Referrer-Policy")
FAIL=0
for u in "${URLS[@]}"; do
H=$(curl -sI --max-time 10 "$u")
for r in "${REQUIRED[@]}"; do
if ! echo "$H" | grep -qi "^$r:"; then
echo "[FAIL] $u 缺少 $r"
FAIL=1
fi
done
done
[ $FAIL -eq 0 ] && echo "[OK] 所有关键响应头齐全"
exit $FAIL用法:./check_headers.sh yourdomain.com,返回非 0 就说明有 location 漏配。把它放进 CI 或每次补丁后的手工检查清单,这类问题就永远不会再漏到线上。
四、方案二:用变量 + map 集中控制(适合多站点统一管理)
如果你有多个站点、每个站点的响应头要求还不一样,用变量会更灵活。add_header 的值可以是变量,变量可以在更上层通过 map 定义。
# http 块里定义变量
map $host $frame_opt {
default "SAMEORIGIN";
"embed.example.com" ""; # 这个域名允许被 iframe,值给空
}
map $host $hsts_value {
default "max-age=31536000; includeSubDomains";
"test.local" ""; # 测试域名不发 HSTS
}
map $request_uri $cache_control {
default "no-cache";
"~*\.(css|js|png|jpg|jpeg|gif|webp|woff2?)$" "public, max-age=31536000, immutable";
}
server {
listen 80;
server_name test.local;
# 只有非空时才发
add_header X-Frame-Options $frame_opt always;
add_header Strict-Transport-Security $hsts_value always;
add_header Cache-Control $cache_control always;
location / { root /var/www/html; }
}重要陷阱:当变量求值为空字符串时,Nginx 会发出一条值为空的头吗?答案是否定的——Nginx 会跳过值为空的 add_header。这正是我们上面用空字符串实现"条件不发"的依据。但这个行为有版本差异,建议实测确认:
# 验证空值头是否被跳过
curl -sI http://test.local/ | grep -i strict-transport
# 若上面 map 把 test.local 映射为空,这里应该没有输出另外,用变量方案时要注意:如果 $hsts_value 引用的变量未定义,Nginx 会报配置错误,所以 map 里一定要写 default 分支。
这种写法的最大优点:所有 location 里都不用再写 add_header,全部收敛到 server 层级,从根上避免了覆盖问题。缺点是响应头的取值逻辑散落在 map 里,可读性下降,适合对 Nginx 比较熟的站长。
五、方案三:只在 http 层级定义,location 用其他手段
还有一种思路是反过来:把安全头全部放在 http 或最外层 server,locations 里坚决不写 add_header,静态资源的缓存策略改用别的机制实现:
# 静态资源缓存也可以用 map + 变量(上一节方案)解决
# 或者用 expires 指令(它不属于 add_header,不受覆盖影响)
location /static/ {
root /var/www/html;
expires 1y; # 自动加 Expires 和 Cache-Control
add_header Cache-Control "public, max-age=31536000, immutable" always;
# ↑ 这一条仍然会触发覆盖问题,所以此法只适合所有头都在 http 层级的情况
}说实话,方案三单独用并不彻底,因为 expires 产生的 Cache-Control 是 Nginx 自动加的、不参与 add_header 的覆盖判断,但你自己写的那条 add_header 依然会切断继承。所以实际推荐组合是方案一 + 方案三:安全头统一 include,静态资源用 expires 而不手写 Cache-Control。
location /static/ {
root /var/www/html;
expires 1y;
include snippets/security-headers.conf; # 关键:显式重复
}
# 结果:Cache-Control 由 expires 生成(含 max-age=31536000)
# 安全头来自 include,一个都不少六、几个容易忽略的相关坑
1. always 参数的作用
add_header X always; 里的 always 表示无论响应码是什么都发送。不带 always 时,只有 200、201、204、206、301、302、303、304、307、308 这些状态码才会发送头。这解释了另一个常见困惑:为什么 4xx/5xx 错误页上安全头不见了——因为忘了写 always。
# 错误页也要安全头的正确写法
add_header X-Frame-Options "SAMEORIGIN" always;
# 而不是
add_header X-Frame-Options "SAMEORIGIN";2. if 块里的 add_header
if 块内也有 add_header 时,情况会变得更混乱:if 块算一个独立层级。能不用 if 就不用,这是 Nginx 圈的共识("if is evil")。需要条件逻辑时,用 map 变量代替。
3. 上游应用发的头会不会被覆盖
Nginx 的 add_header 是追加行为,不删除上游已有的头。所以如果你的 PHP 应用自己发了 X-Frame-Options: DENY,Nginx 又 add_header 了 SAMEORIGIN,客户端会收到两条同名头,行为取决于浏览器(多数会取第一个或用最后一个,不一致)。要覆盖上游的头得用 more_set_headers(需要 headers-more-nginx-module):
# 需要 ngx_http_headers_more_filter_module
more_set_headers "X-Frame-Options: SAMEORIGIN";
# 确认模块是否已编译
nginx -V 2>&1 | tr ' ' '\n' | grep -i headers-more或者更省事:让应用层不要重复发,把响应头的唯一来源放在 Nginx。
4. add_trailer(HTTP/2 trailer)有同样的继承规则
如果你在用 HTTP/2 的 trailer,add_trailer 的继承语义跟 add_header 完全一致,别指望它表现得不同。
七、检查清单:加任何一个响应头前先过一遍
- 这个头要加在哪个层级?如果只在 server 层级加一次就够,优先这么做,避免一切继承问题。
- 如果必须加到 location,问自己:这个 location 是否存在其他 add_header?如果存在,父级的头会不会被我切断?
- 需要重复的时候,用
includesnippet,而不是复制粘贴——同一个头写两遍迟早不一致。 - 所有安全头都加
always,保证错误响应也带。 - 用
curl -sI分别验证正常页、静态资源、404 页三种情况的响应头,别只看首页。 - 把
check_headers.sh挂到部署流程,让静默失效变成显式失败。
八、一句话总结
add_header 的继承是"全有或全无":子层级一旦出现任何一条 add_header,父层级的所有 add_header 在当前层级失效。避坑的最优解不是记住这条规则然后小心地重复,而是用 include snippet 或 map 变量把响应头收敛到一处,让配置结构本身不可能出错。响应头这东西平时没人看,出问题的时候往往已经影响到 SEO 收录和安全评分了,值得花十分钟把结构理顺。