Nginx try_files:一条指令管住静态文件的查找与回退
把一个用 Vue 或 React 写的前端项目部署到 Nginx,访问首页一切正常,点进 /orders/42 也正常——然后用户在这个页面按了 F5,一个 404 砸在脸上。原因是:/orders/42 是前端路由虚拟出来的路径,磁盘上并不存在,Nginx 找不到文件只能报 404。
结论先行:try_files 一条指令就能解决这类问题——前几个参数按序检查文件是否存在,最后一个参数决定”全部落空时怎么办”。 本文按 how-to 组织:先交代实验环境,再给三组可直接复制的配置与真实输出(SPA 回退、严格 404、转发后端),最后用两个事故实验说清这条指令的边界。所有输出均为实跑结果,未跑过的结论会明确标注。

实验环境
可复现是前提,先交代条件:
- nginx/1.31.5(docker 镜像
nginx:alpine); - 站点目录挂载到
/usr/share/nginx/html,配置挂载到/etc/nginx/conf.d/; - 宿主机 8080 端口映射到容器 80,下文所有
curl均从宿主机发起; - 每轮改动配置后
docker exec 容器名 nginx -s reload再验证;错误日志用docker logs看(容器里/var/log/nginx/error.log是指向 stderr 的软链,tail它会挂住)。
最小站点结构:
1 | /usr/share/nginx/html/ |
指令语义:检查参数在前,兜底参数在后
try_files 的全部语义浓缩成一句话:前面的参数负责”依次找”,最后一个参数负责”都找不到时怎么办”。
1 | try_files 参数1 参数2 ... 最后一个参数; |
前面的参数做存在性检查,命中即停:$uri 查文件、$uri/ 查目录、命名 location(@backend)跳转过去。最后一个参数是兜底,不做检查,全部落空时才执行——可以是一个 URI(触发内部重定向),也可以直接给状态码(=404)。
内部重定向是理解一切坑的钥匙:兜底到 URI 时,请求会从头再走一遍 location 匹配,等于 Nginx 替用户重新发起了一次请求。
实例一:SPA 回退,并小心 403 坑
一行配置解决 F5 404——这是 try_files 最常用的姿势:
1 | server { |
四种请求的实跑结果(含一个意料之外的状态码):

读图:about.html 存在,$uri 直接命中;nope.html 不存在,两个检查参数落空,兜底到 /index.html——用户拿到的是首页内容和 200。前端拿到入口页后由 JS 读取 URL 渲染路由,用户无感,这就是 F5 404 问题的解法。
但这套配置里藏着一个 403 坑。 请求 /report/(目录存在,里面没有 index.html):

原因:$uri/ 检查的是”目录是否存在”,report/ 存在即命中;随后 index 指令找目录下的 index.html,找不到,autoindex 默认关闭,于是 403。命中目录不等于能给出内容——不想要这个行为,把 $uri/ 从参数里去掉即可。
实例二:=404 兜底,配合 error_page 做自定义错误页
纯静态内容站不该让死链返回 200(搜索引擎会收录一堆重复首页)。让该 404 的老实 404,并顺手自定义错误页:
1 | server { |
实测结果如下,注意状态码和响应体分别发生了什么:

状态码保持 404,响应体却被 error_page 替换成自定义页——两件事各干各的,互不冲突。顺带对比两种兜底的语义差别:=404 直接终结请求,省一次内部重定向;URI 兜底要重走 location 匹配。前者更省,后者更灵活。
实例三:转发后端时,查询参数别弄丢
伪静态场景把找不到的请求转给 PHP 或网关,兜底 URI 末尾的 ?$query_string 不是装饰:
1 | location / { |
带参数请求的实跑结果:

配图4 是一组 A/B 实验:上半段兜底写全 ?$query_string,后端拿到 qs=id=42&type=report;下半段只把兜底改成 /index.php,同样的请求过去,qs= 空了。漏写 $query_string,后端拿不到业务参数,这是 try_files 最高频的事故。下半段输出里还有个细节:兜底后 uri=/index.php——内部重定向发生时,$uri 已变成兜底目标本身;用户原始路径 /missing?id=42&type=report 只在 $request_uri 里还能找回来。
事故实验:兜底目标选了自己,500 伺候
把兜底 URI 指向一个不存在的文件,且它仍会命中同一个 location:
1 | location / { |
请求任意不存在的路径,实测直接 500,错误日志精确指出了死循环位置:

链路是:/nope.html 不存在 → 兜底到 /loop.html → 内部重定向重新匹配 location → /loop.html 还是不存在 → 再兜底到自己 → 循环到 10 次上限,Nginx 强制 500。兜底目标必须最终能被命中,写完配置拿一个必然不存在的路径 curl 一遍,是基本自测动作。
一组对比:两种错误写法
错误一:兜底 URI 忘带 query_string
1 | # 错:业务参数丢失 |
错误二:把兜底语义用在中间参数上
1 | # 错:/fallback.php 被当作磁盘文件检查,失败不报错,直接看下一个参数 |
规则一条:检查参数在前,兜底动作在后,兜底只写一个。
注意事项
- 顺序就是优先级。按书写顺序检查,第一个命中即停;宽泛的
$uri/放前面,后面的参数永远轮不到。 $uri/是双刃剑。命中目录但目录里没有 index 文件时返回 403(autoindex 关闭时)。纯 API 站点通常不需要它。- 性能账。每个参数一次磁盘 stat,候选堆到七八个等于每个请求多七八次系统调用。三五个以内是常态,再多先调目录结构。
- 内部重定向有 10 次上限(上节已实测,500 + cycle 日志)。
alias搭配容易拼错路径。alias 的路径替换规则与 root 不同,try_files 参数在 alias 下可能拼出意料之外的物理路径——此条来自官方文档与社区共识,本文未实跑,用 alias 时请逐条 curl 验证每个参数实际检查的路径。
小结
回到开头的 F5 场景:try_files $uri $uri/ /index.html; 一行解决 SPA 刷新 404。这条指令的心智模型一句话——前 N-1 个参数是”依次找”,第 N 个参数是”都找不到时怎么办”。三个事故坑(目录 403、丢 query_string、兜底选自己死循环)都来自兜底参数的语义误解,配置写完用一个必然不存在的路径和一条带参数的路径各 curl 一遍,就能在上线前全部拦住。