一、.htaccess 不生效,是因为 Nginx 根本不读它
把站点从 Apache 搬过来的人,十有八九会撞上这件事:.htaccess 原样拷过去,伪静态全失效,访问旧地址直接 404。原因很简单,Apache 的 .htaccess 是「目录级配置」,每次请求都会从 Web 根一路读到你请求的那个目录,把所有 .htaccess 里的 RewriteRule 拼起来执行;Nginx 没有这个概念,它只读主配置文件,逐个目录读配置这件事它压根不做,做了也慢。
所以迁移 .htaccess 不是「找个翻译器把语法换一下」,而是要把「分散在几十个目录里的、隐式叠加的、随请求变化的规则」重写成「一份集中的、按 location 分组的、静态匹配的配置」。这个过程里有几个地方,语法几乎能一一对应但语义不同,也是最容易出事故的地方。
下面按照 .htaccess 里最常见的几类指令分开讲,每类给出 Apache 写法、Nginx 对照写法,以及实际替换时的注意事项。
二、RewriteRule 到 location 与 rewrite:一个隐式、一个显式
Apache 的典型伪静态长这样:
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^article/([0-9]+)\.html$ index.php?cid=$1 [L,QSA]这段的意思是:如果请求的路径不是一个真实存在的文件、也不是一个真实存在的目录,并且匹配 article/数字.html 这个形态,就内部重写到 index.php?cid=数字,并保留原有查询串。
对应的 Nginx 写法是:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ ^/article/([0-9]+)\.html$ {
rewrite ^ /index.php?cid=$1 last;
}这里有三个关键差别。
第一,RewriteCond !-f 在 Nginx 里通常由 try_files 承担,而不是写成条件判断。try_files $uri $uri/ /index.php?$query_string 的语义是:先看这个路径有没有对应文件,有就直接返回;再看有没有同名目录;都没有才交给 /index.php。这一条 try_files 顶掉了 Apache 里的两个 RewriteCond,而且执行效率高得多。
第二,[QSA] 在 Nginx 里靠 $query_string 显式带上。很多人写 rewrite ^ /index.php?cid=$1 last; 之后发现分页参数丢了,就是因为 rewrite 里带了新的查询串,Nginx 会直接替换原查询串而不是合并。如果目标 URL 末尾加了 ?(如 /index.php?cid=$1?),表示丢弃原查询串;不加 ? 又自己写了参数,则是替换。想要合并,就得显式写 ...&$query_string,或者在 location 内不做 rewrite 而交给 try_files 兜底。
第三,[L] 和 last 含义不同。Apache 的 [L] 是「这一轮重写到此为止」,但目录级配置还有可能被再次应用;Nginx 的 last 是「用重写后的 URI 重新走一遍 location 匹配」,break 才是「在本 location 内停止处理」。用错这两个标志,最常见的症状是重写规则被重复执行导致参数翻倍,或者进了 /index.php 的 location 之后又被别的规则抓走。
移植时建议先用 nginx -T 把最终生效的配置打出来核对一遍,确认你重写的规则没有被别的 location 抢先匹配。location 的匹配优先级是:精确匹配 = 最高,其次是前缀匹配里最长的那个(如果带 ^~ 则不再检查正则),然后是正则匹配(按配置文件里的出现顺序),最后才是普通前缀匹配。
三、RewriteCond 这一层:%{...} 变量到 Nginx 的对应
Apache 的条件判断用的是 %{变量} 形式,Nginx 里这些值分散在不同的内置变量和 map 指令中。常见的对应关系如下。
%{HTTP_HOST} 对应 $host。注意 $host 会优先取请求行里的 Host,取不到才用 server_name,并且小写化、去掉端口;而 $http_host 是原始的 Host 头,带端口、大小写未处理。做域名判断一律用 $host,用 $http_host 会因为带了 :443 而匹配失败。
%{REQUEST_URI} 对应 $request_uri,是原始未解码的完整路径含查询串。%{QUERY_STRING} 对应 $query_string。%{REQUEST_METHOD} 对应 $request_method。%{HTTP_USER_AGENT} 对应 $http_user_agent,但注意 Apache 会对 UA 做一次转义处理,改 UA 规则时直接用原始值要小心反斜杠。
复杂一点的条件逻辑(多个 RewriteCond 之间用 [OR] 串联、或者用 %{REQUEST_URI} 取捕获组)在 Nginx 里最干净的实现是 map。比如「来自某些 UA 且访问非静态资源时返回 403」,用 if 写容易出问题,用 map 写就清楚得多:
map $http_user_agent $bad_ua {
default 0;
~*(semrush|ahrefs) 1;
}
map $uri $is_static {
default 0;
~*\.(css|js|jpg|png|woff2)$ 1;
}
server {
if ($bad_ua$is_static = 10) {
return 403;
}
}map 的结果在请求开始时就算好,可以反复引用,比在 if 里做正则匹配清楚得多。if 在 location 内只推荐用于 return 和 rewrite ... last 这两类操作,其余用法(尤其是 if 里写 proxy_pass)在 Nginx 官方文档里被称为「邪恶的 if」,行为容易出乎意料。
四、Options 与 AllowOverride:目录列表与访问控制的迁移
Apache 的 Options -Indexes 是关掉目录列表。.htaccess 里写这个的人很多,尤其是有上传目录的站点。Nginx 的默认行为本来就是不开目录列表,只有当某个 location 或 server 里写了 autoindex on; 才会开。所以迁移时这一条不是「要写什么」,而是「确认哪里都没写 autoindex on」。
grep -rn 'autoindex' /etc/nginx/输出为空即安全。如果有,说明有人主动开过,要问清楚是不是有下载站需求,再决定保留还是关掉。
Order deny,allow / Deny from all 这类访问控制,Nginx 里用 allow/deny 表达,但语义是「自上而下第一条匹配的生效」,跟 Apache 的 Order 逻辑不同。Apache 2.4 之后已经改成 Require all denied 的写法,迁移时以最终语义为准:
location /data/private/ {
deny all;
return 403;
}但如果目录里只有静态文件、且希望由 PHP 程序控制下载权限,正确做法是把目录放到 Web 根之外,或者用 internal 标记配合 X-Accel-Redirect,而不是靠 deny 拦。
Options +FollowSymLinks 在 Nginx 里没有对应项,软链接默认就被跟随,不需要配置。Options -MultiViews 也没有对应项,Nginx 不做内容协商(除了 gzip_static 那种显式配置)。这两条可以直接删掉。
AddType / AddHandler 对应 Nginx 的 types 块或者 location ~ \.php$ 的 fastcgi_pass。要特别注意一件事:Apache 的 AddHandler application/x-httpd-php .php .phtml 会让 .phtml 也被当作 PHP 执行,Nginx 里如果照抄,等于给上传目录开了一个后门。迁移时只把 .php 交给 FastCGI,绝对不要加别的扩展名。
五、ErrorDocument 与自定义错误页
Apache 的 ErrorDocument 404 /404.html 在 Nginx 里对应 error_page,但有个容易踩的点:
error_page 404 /404.html;
location = /404.html {
internal;
}如果不加 internal,用户直接访问 /404.html 也能打开,本身无害,但会让搜索引擎把错误页当成正常页面收录(HTTP 状态是 200)。加了 internal 之后,只有内部跳转能访问它,外部直接访问会拿到 404,这是更正确的行为。
另一个坑是 error_page 写在 location 里还是 server 里。写在 location 里只对该 location 生效;写在 server 里对该虚拟主机下所有 location 生效,但会被 location 内的 error_page 覆盖。如果某个 location 有自己的一套错误页,全局的就管不到它了。迁移之后建议用 curl -I 逐个路径验证:
curl -sIo /dev/null -w '%{http_code} %{redirect_url}\n' https://example.com/no-such-page
curl -sIo /dev/null -w '%{http_code}\n' https://example.com/404.html第一行应该返回 404、没有跳转;第二行如果返回 404 说明 internal 生效了,如果返回 200 说明漏了。
六、Rewrite 到 301 的迁移:别把永久跳转写错方向
老站用 .htaccess 做域名跳转的很多,形如:
RewriteCond %{HTTP_HOST} ^old-domain\.com$ [NC]
RewriteRule ^(.*)$ https://new-domain.com/$1 [R=301,L]Nginx 的写法是:
server {
listen 80;
listen 443 ssl;
server_name old-domain.com www.old-domain.com;
return 301 https://new-domain.com$request_uri;
}这个写法比 rewrite 块更简单也更正确,因为它把「域名判断」交给 server_name,把「路径保留」交给 $request_uri,不涉及正则捕获。
但迁移时最常见的错误是只迁了 http,忘了 https。老的 .htaccess 里 RewriteCond %{HTTPS} off 的规则和域名跳转规则常常是分开的两段,迁的时候只写了一段的逻辑,结果 https 的旧域名直接 404。稳妥做法是让 server_name 同时挂在 80 和 443 两个端口上,用同一个 return 301 处理,不要分两套逻辑。
还有一个更隐蔽的问题:如果老域名上还有效的 https 证书已经过期,那么「https 旧域名跳新域名」这条链路在浏览器层面就会先报证书错误,用户根本走不到跳转。这种情况只能靠续签或直接用 HTTP 跳转,跳转本身无法绕过证书校验。迁之前先 echo | openssl s_client -connect old-domain.com:443 -servername old-domain.com 2>/dev/null | openssl x509 -noout -dates 看一眼证书有效期。
七、迁移之后必须验的四项
配置写完、nginx -t 通过、reload 完成,这只是「语法对」。真正的验证要靠实际请求。四项检查,缺一不可。
第一,伪静态的典型地址。把 Apache 时期访问量最高的十个伪静态 URL 列出来,逐个用 curl 加上 -o /dev/null -w '%{http_code}' 参数验证返回 200 而不是 404。这一步能把 try_files 顺序写错、正则漏掉小数点之类的问题全部抓出来。
第二,带参数的地址。老站链接里常带 ?utm_source=xx&page=2,迁移之后要确认这些参数没被吞掉。判据是页面上渲染出来的分页状态,不是 URL 本身。
第三,静态资源的 MIME 类型。Apache 的 mime.types 和 Nginx 的 types 块覆盖的扩展名不完全一致。迁移后最容易出问题的是 .woff2(被当 application/octet-stream 下载,字体失效)和 .webp(被当图片下载后无法显示)。
for p in /static/a.woff2 /static/a.webp /static/a.svg /static/a.mjs; do
printf '%-24s ' "$p"
curl -sIo /dev/null -w '%{content_type}\n' "https://example.com$p"
done四项都返回了正确的类型才算过。
第四,日志里 404 的增量。切换之后对比前后各一小时的 404 数量与 Top20 路径。正常情况下数量接近,路径集合也应该接近;如果出现大量以前没见过的路径 404,说明有规则漏迁了,从这些路径反推是哪条 rewrite 没写。
最后一条经验:保留一份 .htaccess 原文作为对照文档,别急着把文件删掉。迁移后的头一个月,遇到任何「以前能访问现在不行」的反馈,第一件事就是回到这份原文里找对应规则,比凭记忆猜快十倍。等到确认真的一条都不缺了,再归档到文档目录,而不是直接删。