为什么你的伪静态「看着配了」却 404
几乎每一个帮别人看过服务器的人,都遇到过同一类问题:明明在 Nginx 里写了 rewrite 或 try_files 规则,本地测试也「好像对」,一上线却各种 404、403、甚至二次编码的乱码 URL。最常见的元凶,不是 rewrite 写错了,而是根本没搞清 try_files 和 rewrite 各自该负责什么,以及 location 的匹配优先级。
这篇文章围绕一个个人站长天天打交道的场景展开:把动态站(Typecho / WordPress / 自研 PHP)做成「无 index.php 的伪静态」,同时给单页应用(SPA / Vue / React)做前端路由 fallback,并处理好静态文件与目录的边界。这三件事恰好是 try_files 最能发挥作用、也最容易翻车的地方。
先记住一句话:rewrite 改的是「路径」,try_files 找的是「文件」
很多教程把这两个混着讲,导致新手一上来就是一堆 rewrite,其实它们的定位完全不同:
- rewrite:对请求 URI 做正则替换,可能触发新的 location 匹配(
last)或停止重写(break)。它擅长「把 A 形式的 URL 变成 B 形式」。 - try_files:按顺序依次尝试文件是否存在,命中第一个存在的就用它(内部跳转),全都不存在就用最后一个参数(可能是某个文件、
=404、或@named_location)。它不擅长「改写 URL」,擅长「兜底查找」。
结论就是:能用 try_files 解决的事,尽量不要用 rewrite。伪静态、SPA fallback、缓存文件命中,全都是典型的 try_files 场景。
场景一:PHP 站伪静态(无 index.php 的干净 URL)
以 Typecho 为例,它的干净 URL 形式是 /archives/123/,实际要交给 index.php 处理。历史上大家爱写一堆 rewrite 规则,其实更稳、更好维护的写法是让 Nginx 先找真实文件,找不到再交给 PHP:
server {
listen 443 ssl;
server_name www.example.com;
root /var/www/example;
index index.php index.html;
location / {
# 先找真实文件,再找目录,最后交给 index.php
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
# 别让 .php 之外的敏感文件被当脚本执行
location ~* /(?:\.git|\.env|composer\.(json|lock)) {
deny all;
}
}这里 try_files $uri $uri/ /index.php?$args 是核心,逐项解释:
$uri:请求的原始路径。如果磁盘上真有这个文件(比如/css/style.css),直接返回它,不进 PHP,性能最好。$uri/:如果是个目录,交给目录的 index 处理。/index.php?$args:前面都不存在,就内部跳转(不是 301,浏览器地址栏不变)到 index.php,并把原始查询串$args带上。伪静态能实现,全靠这一步。
关键坑位:$args 前的 ? 不能省,也不能写成 $query_string 之外的拼接形式。少了它,/search?q=nginx 这种带参 URL 交到 PHP 时参数会丢,表现为「搜索页永远返回空结果」。
场景二:SPA 前端路由 fallback,但别把 API 也吞了
单页应用(Vue Router、React Router 的 history 模式)需要「任何前端路由都返回 index.html」,否则用户刷新 /dashboard 就会 404。做法同样是 try_files:
location / {
root /var/www/spa/dist;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}这里必须注意顺序与优先级:location /api/ 是前缀匹配且比 location / 更具体,Nginx 会优先命中它。所以 API 请求会被代理到后端,不会掉进 /index.html 的兜底里。但如果你反过来把 API 也写在 location / 里、靠 try_files 的最后一项去区分,那就大错特错了——try_files 只能「找文件」,不能按 URL 前缀分流。
还有一个隐蔽问题:SPA 项目里如果残留了 404.html 之类的文件,或者构建产物目录里有和前端路由同名的文件,try_files 的 $uri 会先命中文件而不是 index.html,导致「有时刷新正常有时 404」。排查方法是 curl -I 看返回的是不是 text/html 且大小和 index.html 一致。
场景三:静态资源缓存命中 + 回源兜底
在「构建产物带 hash 文件名」的前端项目里,常见需求是:请求 /assets/app.abc123.js,如果本地构建目录有就直接发,没有就去上游/对象存储拉,最后都没有才 404。try_files 可以用命名 location 做兜底分派:
location /assets/ {
try_files $uri @assets_fallback;
}
location @assets_fallback {
proxy_pass https://cdn-origin.example.com;
proxy_set_header Host cdn-origin.example.com;
proxy_cache assets_cache;
proxy_cache_valid 200 30d;
}@assets_fallback 是一个命名 location,它不能被外部 URL 直接访问,只能被 try_files 的最后一个参数跳转进去。这是 try_files 最优雅的用法:把「找不到时怎么办」这段逻辑单独拎出来,主 location 保持清爽。
location 匹配优先级:为什么你的规则「不生效」
十次「规则不生效」,九次是优先级没搞对。Nginx location 的匹配顺序是这样的(从高到低):
=精确匹配:命中即停,最高优先级。例如location = /favicon.ico { ... }。^~前缀匹配(且一旦命中就不再检查正则):适合location ^~ /static/ { ... }。~和~*正则匹配(区分/不区分大小写):按配置书写顺序第一个命中的生效。- 普通前缀匹配:记住最长匹配优先(不是最先定义的优先)。
这里有两个最容易误解的点:
- 前缀匹配是按长度,不是按顺序。写
location /a/在前、location /a/b/在后,请求/a/b/c命中的是后者,因为它更长。 - 正则一旦匹配成功,会覆盖普通前缀匹配(除非前缀用了
^~)。很多人把location ~ \.php$放在最后,结果它把本该走静态的location /uploads/抢走了——这就是「图片被当 PHP 执行 / 上传目录能跑脚本」安全事故的根源。
root 与 alias:一个用错就路径穿越的经典陷阱
讲 try_files 绕不开 root 和 alias 的区别,因为很多诡异的 404(尤其是伪静态和静态资源)都源于它俩被混用。root 是把 location 路径拼接到根目录后面,而 alias 是直接替换掉 location 匹配的那一段。举个例子:
# root 写法:请求 /img/a.png -> 实际文件 /var/www/static/img/a.png
location /img/ {
root /var/www/static;
}
# alias 写法:请求 /img/a.png -> 实际文件 /var/www/static/a.png
location /img/ {
alias /var/www/static/;
}看出来了吗?root 会保留 location 里的 /img/,alias 会把它去掉。用 alias 时 location 结尾和 alias 结尾的斜杠必须成对出现,只写一个斜杠(比如 location /img { alias /var/www/static/; })会造成路径拼接错位,轻则 404,重则形成目录穿越漏洞(攻击者用 ../ 读到上级目录文件)。
一个完整的生产级 server 块模板
把上面几个场景合并,就是一份个人站可以直接改巴改巴上线的配置:
server {
listen 443 ssl http2;
server_name www.example.com;
root /var/www/example;
index index.php index.html;
location = /robots.txt { access_log off; log_not_found off; }
location = /favicon.ico { access_log off; log_not_found off; }
# 上传目录:只允许静态,绝不执行脚本
location ^~ /uploads/ {
location ~ \.php$ { deny all; }
}
# 静态资源长缓存
location ~* \.(css|js|png|jpg|jpeg|gif|webp|avif|svg|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# 伪静态兜底
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
try_files $uri =404; # 关键:防止 .php 路径被伪造而走到不存在的脚本
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
location ~ /\.(?!well-known) { deny all; }
}这份配置里有两个安全要点值得单独强调,它们比「伪静态能不能用」重要得多:
location ~ \.php$ { try_files $uri =404; }:如果没有这一行,攻击者可以请求/uploads/evil.jpg/x.php这类路径,让 Nginx 把不存在的脚本交给 PHP-FPM 执行,触发经典的「FPM 任意代码执行」漏洞(CVE-2019-11043 家族)。加上try_files $uri =404让文件不存在时直接 404,堵死这条路。location ^~ /uploads/ { location ~ \.php$ { deny all; } }:嵌套 location 让上传目录即使被传了.php文件也不会执行。这是文件上传功能必配的一道锁。
排错三连:先看命中,再看路径,最后看日志
规则不生效时,别猜,按顺序查。之所以要先看 location 命中,是因为绝大多数「我明明写了规则」其实压根没进入那个 location——被更长的前缀或者一条正则抢走了。路径这一层要确认 root/alias 拼出来的真实文件位置对不对,最后才轮到日志看 try_files 落在哪一项。
# 1. 看这个请求最终命中了哪个 location、走了什么路径
nginx -T 2>/dev/null | grep -n "location"
# 2. 直接用 curl 观察返回:状态码、类型、真实地址
curl -I https://www.example.com/archives/123/
curl -I https://www.example.com/dashboard # SPA 刷新应为 200 + text/html
# 3. 打开 rewrite/error 日志,看 try_files 到底落在了哪一项
# 在 server 块内临时加:
# rewrite_log on;
# error_log /var/log/nginx/debug.log notice;
tail -f /var/log/nginx/debug.logcurl -I 看返回的 Content-Type 是最好用的一招:伪静态请求如果返回了 application/octet-stream 或者尺寸明显不对,说明它没进 PHP 而是命中了一个静态文件;SPA 刷新如果返回 404,说明 fallback 没生效或 index.html 路径写错。
最容易踩的五个坑
- 忘记
$args:伪静态路径带查询参数时参数丢失,表现为搜索、分页失效。 - 把
index.php写成绝对 URL:try_files ... https://.../index.php会触发 301 跳转改变地址栏,应该用相对路径/index.php。 - 正则 location 抢走静态目录:把
~ \.php$放在上传目录规则的后面却没用^~,导致上传目录仍能被当脚本执行。 - SPA 的 API 被 fallback 吞掉:把 API 逻辑塞进
location /,结果所有接口请求都返回了首页 HTML。 - 改完配置不 reload:
nginx -t校验后必须nginx -s reload(或systemctl reload nginx),不 reload 等于没改。
小结
try_files 是 Nginx 里「性价比最高」的指令之一:掌握了它,伪静态、SPA 路由、缓存兜底三大高频需求都能一次写对,还能顺手堵住几个安全漏洞。核心心法就两句——「能找文件就别改路径」和「一定先想清楚 location 优先级」。下次再遇到伪静态 404,先别急着加 rewrite,回头看看你的 try_files 是不是漏了 $uri/ 或者 $args。