Nginx 和 Caddy:Web 服务器的选择与对比

2026-04-09 后端

2026-09-24 更新:第 6 节「错误页面处理」补了一段实测教训 —— 拿静态文件当错误页,会跟着站点改版慢慢跑偏;我们把错误页改成了 PHP 渲染、和别的页面共用同一份模板。

前段时间我把万象存笺从 Nginx 切到了 Caddy,整个过程踩了不少坑,也切实感受到了两个服务器在设计理念上的差异。这篇文章做个完整对比,如果你是个人站长或小项目运维者,可以作为选型参考。

为什么会有这次切换

万象存笺原本跑在 Nginx 上,前面有 EdgeOne CDN 做 HTTPS 终止和缓存加速,Nginx 主要承担 PHP-FPM 转发、静态文件服务和安全策略(防盗链、敏感文件屏蔽、安全头)这些"后端活"。

在 CDN 架构下,Nginx 的很多能力其实用不上:

  • GeoIP 国家封锁:请求来源全是 CDN 回源 IP,不是真实用户 IP,检测无效
  • 限速(rate_limit):同理,$binary_remote_addr 拿到的也是 CDN 节点 IP
  • 复杂的 location 正则:配置越复杂,后续维护成本越高

既然 Nginx 的优势发挥不出来,而 Caddy 又以"配置简洁、自动 HTTPS"闻名,就动了切换的念头。

配置对比:从 Nginx 到 Caddyfile

先看实际配置的体量差距。

Nginx 配置(仅核心站点)

这只是精简版。实际完整的 Nginx 站点配置大约 130 行,加上全局配置还要更多。

Caddyfile 配置(等价功能)

Caddyfile 大约 60 行,同样的功能,配置量减半。关键是语法更直观——没有 location 嵌套、没有 if 条件判断、没有 upstream 配置。

核心差异对比

1. 配置语言

维度 Nginx Caddy
语法风格 声明式 + 指令式混合 极简声明式
学习曲线 陡峭(location 优先级、rewrite 规则) 平缓(无嵌套、无优先级问题)
配置量 同功能多 2~3 倍 行数少一半
热重载 nginx -s reload 支持好 systemctl reload caddy 可能有问题,推荐 restart

Caddy 的 Caddyfile 语法最舒服的一点是不需要记指令优先级。Nginx 的 location 块有复杂的匹配优先级(精确匹配 > 前缀匹配 > 正则匹配 > 通用匹配),写错顺序就出 bug。Caddy 用 route {} 块让指令按书写顺序执行,清晰很多。

2. SSL/TLS 证书管理

Caddy 最出名的特性就是自动 HTTPS。配置里写 tls internal 就能自动生成自签证书(供 CDN 回源用),写 tls your@email.com 就能自动申请 Let's Encrypt 证书。

Nginx 需要自己管理证书:手动申请、配置续期 cron、指定证书路径。虽然 Certbot 可以自动化,但毕竟是额外工具。

对于前端有 CDN 的架构,这点差异特别明显——服务端只需要一个自签证书供 CDN 回源验证,Caddy 一行 tls internal 搞定,每 12 小时自动轮换。

3. PHP-FPM 集成

Nginx 处理 PHP 需要:

location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}

Caddy 一行:

php_fastcgi unix//var/run/php/php8.3-fpm.sock

而且 Caddy 内置了对 PATH_INFO 的支持,不需要额外配置。不过要注意,Caddy 的 php_fastcgi 必须和 handle 块配合好路由顺序,否则 PHP 文件可能被当作静态文件直接返回(我踩过这个坑,后面会讲)。

4. 性能

维度 Nginx Caddy
静态文件 极优(epoll 事件模型) 优秀(同样 Go net/http)
反向代理 极优(内存占用极低) 良好
并发连接 百万级(业界标杆) 万级(Go goroutine 模型)
TLS 性能 良好(OpenSSL) 优秀(Go 原生 crypto/tls)
内存占用 ~10MB(空闲) ~15-20MB(空闲)

说实话,在个人站点场景下,性能差异可以忽略不计。万象存笺日均 PV 不到 1000,Caddy 和 Nginx 都跑不满 1% 的 CPU。

Caddy 用 Go 写的,内存占用确实比 Nginx 高一些(Nginx 在 C 层面做得极轻量),但多出来的 10MB 内存对于现在 2GB+ 的云服务器来说完全不是问题。

5. 生态与模块

Nginx 的生态非常成熟:有 Lua 扩展(OpenResty)、有丰富的第三方模块、有成熟的运维工具链(Prometheus exporter、访问日志分析器)。

Caddy 的模块系统也很强大,但生态没有 Nginx 丰富。Caddy 的插件是用 Go 编译的,意味着加一个模块就得重新编译整个 Caddy 二进制。不过对于个人站点,Caddy 内置的功能已经足够了。

6. 错误页面处理

Nginx 用 error_page 指令指定错误页:

error_page 404 /404.html;
location = /404.html { internal; }

Caddy 用 handle_errors 块:

handle_errors {
    @404 { expression {http.error.status_code} == 404 }
    rewrite @404 /404.html
    file_server
}

Caddy 的方式更灵活——可以在错误处理块里做条件判断、重写、甚至执行 PHP。

上面两种写法有个共同前提:错误页是独立文件。这个前提在小站上容易埋雷 —— 站点改版时,404.html / 403.html 里手写的那份导航栏和页脚不会跟着模板走,得挨个同步。我们这边就是这么踩的:站点改版几次下来,404 页跟上了模板,403 页的导航还停在四个旧条目的版本,手机上栅格也少一档,直到某次改页脚才发现两张错误页长得不一样。

后来干脆把错误页改成 PHP 渲染:页头、导航、页脚 require 同一份模板文件,改一次全站生效;403/404 的文案单独抽成一个共用函数,兜底路由和「未知路径」「文章不存在」都调它。Caddy 这边接法是让这些地址都走 PHP 入口,而不是交给 file_server 发静态文件:

# 旧地址与短链内部重写(必须放在兜底路由之前,否则 not file 会把它们当成未知路径)
@err404 path /404.html /404
rewrite @err404 /404.php
@err403 path /403.html /403
rewrite @err403 /403.php

# 其余未知路径交给前端控制器,由它决定是 404 还是正常页面
@fallback {
    not file
    path *
}
rewrite @fallback /index.php

这么改之后 /404.html 返回的是真正的 404 状态码(以前作为静态文件是 200),页面本身又和全站共用模板。Nginx 想照做也不难:error_page 404 /404.php; 配合 fastcgi_pass 指到 PHP-FPM,或者用 try_files 把未知路径交给前端控制器 —— 思路一样。

切换过程中的实际踩坑

坑 1:PHP 文件被当作静态文件返回

切换后测试 PHP API,发现返回的是源码而不是执行结果。原因是 Caddy 的 php_fastcgi 和 handle 块的路由顺序问题——handle { try_files ... file_server } 块先匹配到了 .php 文件,直接当静态文件返回了。

解决: 把 php_fastcgi 放在 route {} 块的最前面,或者在 route {} 中使用命名匹配器先匹配 .php 文件:

route {
    @php { path *.php }
    php_fastcgi @php unix//var/run/php/php8.3-fpm.sock

    # 其他路由规则...
    handle {
        try_files {path} {path}.html {path}/
        file_server
    }
}

坑 2:Caddyfile 的语法细节

Caddyfile 看起来简单,但有些坑:

  • not header 修饰符:在命名匹配器中,not 必须用块语法 not { header Referer ~* "..." },不能用行内 not header
  • respond 指令:不支持行内 { close },必须写在下一行
  • 正则必须引号包裹:path_regexp "^/\." 而不是 `path_regexp ^/.
  • handle_errors 不支持 status 匹配器:需要用 { expression {http.error.status_code} == 404 }
  • systemctl reload caddy 可能失败:我多次遇到 reload 失败的情况,推荐 systemctl restart caddy

坑 3:日志权限

Caddy 默认以 caddy 用户运行,不是 root。创建日志目录时如果没用 chown caddy:caddy,Caddy 会因为没权限写日志而启动失败。需要在启动前创建目录并修改属主:

sudo mkdir -p /var/log/caddy
sudo chown caddy:caddy /var/log/caddy

坑 4:自签证书的 SNI 问题

Caddy 的 tls internal 会以站点域名为 CN 生成自签证书。用 IP 地址直接访问 HTTPS 时,会报 TLS 告警(SNI 不匹配),必须通过域名访问。这在 CDN 回源场景下不是问题,但本地调试时需要注意:

# 直接 IP 访问失败(SNI 不匹配)
curl -sk https://127.0.0.1/

# 通过域名访问正常
curl -sk --resolve "3z.ink:443:127.0.0.1" https://3z.ink/

最终选择建议

结合这次切换的真实体验,给不同场景的建议:

场景 推荐 原因
个人博客/小站点 Caddy 配置简单,自动 HTTPS,省心
多站点反代 Caddy 多个 site block 比 Nginx 的 server block 好维护
高并发业务(万级 QPS) Nginx 生态成熟、性能极致、运维工具链完善
CDN 回源服务器 Caddy 自签证书一行搞定,配置量少一半
团队协作项目 Nginx 运维团队熟悉、文档丰富、上下游工具多
需要 Lua/复杂扩展 Nginx (OpenResty) 模块生态 Caddy 比不了

总结

如果用一句话总结这次切换的感受:

Nginx 像一把瑞士军刀——功能全面、性能极佳、但学习成本高;Caddy 像一把折叠刀——做同样的事,更轻更快更顺手。

万象存笺切换 Caddy 后运行稳定,配置从 130 行缩减到 60 行,维护起来确实舒心很多。如果你也是个人站长、服务器前面有 CDN、主要需求是 PHP-FPM + 静态文件服务,Caddy 是一个值得考虑的选择。

提醒一点:不要为了切换而切换。如果你的 Nginx 跑得好好的、配置也没有维护负担,强行切换只是增加工作量。但如果你像我一样,每次改 Nginx 配置都要翻文档查 location 优先级,那 Caddy 绝对能提升幸福感。