Nginx try_files 与伪静态路由实战:PHP 站无 index.php、SPA fallback、location 优先级与 root/alias 陷阱全流程

为什么你的伪静态「看着配了」却 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.log

curl -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。

Last modification:October 15th, 2026 at 01:24 pm

Leave a Comment