同一台 VPS 上放了几个站点,Nginx 配置很快就会出现重复:证书参数复制一遍,PHP 参数复制一遍,响应头再复制一遍。拆成 include 文件之后,改一处就能同步多个站点,但也容易出现一种更难察觉的问题:配置检查通过,某条路径却丢了响应头,或者上传目录里的 PHP 文件也能被执行。
先确定片段应该放在哪一层、由谁处理跨域,以及应用允许哪些脚本执行。下面以一个只通过 index.php 进入的 PHP 应用为例。WordPress、多入口 PHP 程序、使用 alias 的目录和容器内 PHP-FPM,不能直接照搬这套路径。
include 文件仍然要放在正确的位置
include 不会替文件创建新的作用域。它出现在 http 中,文件内容就必须能在 http 中使用;出现在 location 中也是一样。
可以把 MIME 类型和跨域白名单的 map 放在 http,把证书、域名和根目录留在各站点的 server,把 FastCGI 参数放进真正处理 PHP 的 location。不要把包含 fastcgi_pass 的片段直接加载到 http 中。它的有效上下文是 location 或 if in location,放错位置会在配置检查时失败。FastCGI 指令文档列出了各指令允许的位置。
例如,主配置中已有 http 块时,只在其中增加这些引用,不要再嵌套一个 http:
http {
include /etc/nginx/mime.types;
include /etc/nginx/snippets/cors-map.conf;
include /etc/nginx/conf.d/*.conf;
}
这里展示的是文件组织关系,不是可替换整个 nginx.conf 的完整主配置。保留现有的 events、日志及其他必要设置。mime.types 的位置因安装方式而异,要以服务器上的实际文件为准;snippets 目录及引用的文件也需要先创建。
CORS 先决定由应用处理,还是由 Nginx 处理
前端与 API 同源时,通常不需要 CORS。跨域访问确实存在时,先看框架是否已经处理了预检、允许的请求头和 Cookie。应用与 Nginx 同时添加 Access-Control-Allow-Origin,可能产生重复响应头,让浏览器拒绝读取响应。最好由一处负责完整策略。
如果由 Nginx 负责,白名单可以放在 /etc/nginx/snippets/cors-map.conf:
map $http_origin $cors_origin {
default "";
"https://app.example.com" "https://app.example.com";
"https://admin.example.com" "https://admin.example.com";
}
这个文件在 http 层加载,只生成变量,不会自动添加响应头。map 的普通字符串匹配忽略大小写,若需要别的匹配规则,应阅读官方说明,不要随手用能匹配任意子域的宽泛正则。
随后在真正返回 API 响应的位置使用 $cors_origin。未命中时它为空,Nginx 不发送该空值响应头。不要直接反射任意 $http_origin,尤其不要同时无条件允许凭据。如果返回值随 Origin 改变,实际响应和预检响应都要考虑 Vary: Origin,防止共享缓存混用结果。
预检还要核对请求的方法与请求头;返回一个 204 不等于允许了后续请求。若需要 Cookie,要同时满足浏览器凭据设置、明确的允许来源及凭据响应头,Cookie 自身的 SameSite 等限制也仍然存在。这些细节可以对照 MDN 的 CORS 说明。
CORS 管的是浏览器能否读取跨域响应,不能替代 API 鉴权或 CSRF 防护。有些请求无需预检就会送到服务器,即使浏览器最终不让调用方读取响应,应用也必须独立判断是否允许写入。非浏览器客户端更不会因为少了 CORS 响应头而停止请求。
本文后面的 PHP 示例不添加 CORS 头,跨域需求由应用处理。这样 /api/ 经过前端控制器后,也不会因为内部重定向切换了 location 而丢失一套只写在原位置的策略。
add_header 的 always 不负责继承
常见问题是:在 server 中添加 HSTS,再在 location /api/ 添加几个跨域头,两组都会生效。按默认继承规则,当前层一旦定义了自己的 add_header,就不再继承上一层的整组 add_header。always 只影响响应状态码,不改变这条规则。
所以排查时要看最终命中的 location,以及其中的 if、错误页和内部重定向。不能只看配置文件顶部确实写过安全头,就认为每条响应都带着它们。
兼容旧版本的做法,是把所需公共头整理成片段,在所有自己定义 add_header 的位置明确引用。Nginx 1.29.3 起提供 add_header_inherit merge;,可以合并上层响应头,但旧版本不认识它;合并后也要检查是否产生重复字段。版本门槛与默认规则见响应头模块文档。
一个只执行 index.php 的 HTTPS 站点
下面配置适用于具有单一前端控制器的应用。前提是证书已经申请并有续期安排,/srv/example/public/index.php 确实存在,PHP-FPM 与 Nginx 能访问同一套文件,且使用支持 TLS 的 Nginx。域名、证书路径、根目录和 FPM socket 全部需要换成自己的值。
把它保存为前述 conf.d 引用目录下的一个站点文件:
server {
listen 80;
server_name example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
root /srv/example/public;
index index.php;
ssl_certificate /etc/nginx/certs/example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /index.php {
try_files $uri =404;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
location ~* \.php(?:/|$) {
return 404;
}
location ~ /\. {
deny all;
}
}
精确匹配的 /index.php 负责执行入口,其余以 PHP 后缀访问或附带 PATH_INFO 的请求被拒绝。这里没有为应用启用 PATH_INFO。try_files 找不到静态文件时,会把请求转交 /index.php;具体行为可以查核心模块文档。
“文件存在才执行”仍然不够。假设上传目录允许写入 PHP,而配置允许执行任意实际存在的 .php 文件,上传成功就可能形成执行入口。单入口应用直接限制到入口脚本,也便于逐条检查。上传文件还应由应用控制类型、文件名与存放位置,不能指望这一段正则解决所有上传问题。
这套配置拒绝隐藏路径,也会挡住 /.well-known/。如果证书客户端使用 HTTP-01,请按它的部署方式提供专用挑战目录或临时处理规则,并检查 80 端口的重定向是否符合该客户端的要求;不要为了验证证书就放开所有隐藏文件。
FPM socket 没有跨发行版统一的文件名。Ubuntu 等系统可能带 PHP 版本号;容器里的 FPM 也可能通过私网 TCP 监听。502 时先核对监听位置和访问权限。使用 TCP 时不要把 FPM 端口暴露给公网。fastcgi_params 中如果已经定义了 SCRIPT_FILENAME,应调整到只保留一份正确的参数,避免重复传递。
示例采用普通 root,因此用 $document_root$fastcgi_script_name 构造脚本路径。$realpath_root 可以解析根目录中的符号链接,但不是所有 alias 配置的通用修复办法。Nginx 与 PHP-FPM 看到的文件路径不同,仍需按实际挂载关系重新配置。
HTTP/2 和 HSTS 分别检查
上面的配置先保留 HTTPS 所需内容,没有默认加入 HTTP/2。Nginx 1.25.1 起可以在 HTTPS server 中添加:
http2 on;
使用前确认安装包编译了 HTTP/2 模块。较旧版本使用 listen 443 ssl http2;,较新版本已弃用这种参数写法;不要把新旧两种形式同时加上。具体支持条件见 HTTP/2 模块文档。
HSTS 让浏览器在记住策略后只通过 HTTPS 访问指定主机。先确认本站 HTTPS 和续期稳定,再考虑短期限:
add_header Strict-Transport-Security "max-age=300" always;
这一行放在 HTTPS 的 server 中,并检查前文的响应头继承问题。确认后再逐步延长 max-age。不要在尚未检查其他子域时直接加 includeSubDomains;该参数会把策略扩展到子域。浏览器已经记住的策略也不会因为服务器删除这一行立刻消失,需要通过有效 HTTPS 响应发送 max-age=0 才能撤销该主机策略,子域独立设置的策略仍需分别处理。HSTS 文档说明了这些行为。
HSTS 约束的是浏览器与主机的连接,不会替你配置 CDN 到源站的加密和证书验证。使用 CDN 时,回源策略需要另行核对。
修改后检查实际响应,再重载
先备份或提交配置,再检查展开后的引用关系。nginx -T 会输出当前配置,其中可能包含内部地址或凭据,不要把完整输出贴到公开讨论区。
sudo nginx -T
sudo nginx -t
在由 systemd 管理 Nginx 的机器上,检查成功后再执行:
sudo systemctl reload nginx
容器、面板或其他服务管理方式,应使用对应的重载入口。语法检查能发现不支持的指令、文件引用与部分证书问题,却不会替你判断 CORS 是否符合业务需求,也不会保证上传文件无法执行。
上线后分别查看正常页面、API 错误响应和预检响应。跨域请求至少检查一个允许来源、一个不允许来源及实际写入接口的鉴权;HSTS 则检查添加跨域头的路径是否仍有该响应头。PHP 应用要确认入口可用、不存在的脚本被拒绝,并检查上传目录不会执行脚本。
后续增加片段时,在文件开头注明允许放置的位置及它依赖的变量。一个公共文件被多个站点引用,修改前就列出受影响的站点,修改后按路径检查响应。这样遇到响应头缺失或 502,能从最终命中的配置往回查,而不是把所有片段再复制回去碰运气。











