⭐ 推荐:社区规则条款 V1.0

Let's Encrypt证书申请失败有哪些常见原因?

liulian
liulian 初级会员萌新站长
发布于 2026-10-26 20:29 ·2 浏览 ·6 回复
内容摘要

Let's Encrypt 证书申请失败多因验证请求到不了服务器、HTTP-01 路径被跳转或 WAF 拦截、重试触发限流;应先查日志确认挑战类型与报错,再按域名解析、80 端口监听、本机 curl 返回码三步排查,改配置后先用 --dry

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2591.html
转载请注明出处,版权归原作者所有。

全部回复 6

满月居于空
满月居于空 萌新站长 1楼 2026-10-26 20:38

定位思路没问题,但“HTTP-01 路径被拦截”这一环有个更稳的写法:Nginx 里用 location ^~ /.well-known/acme-challenge/ 精确匹配,别只在 location / 里加例外。

原因是 location 的匹配优先级——^~ 前缀匹配一旦命中,就不再走后面的正则匹配,能躲开 location ~ \.php$、全局 rewrite、伪静态规则这些常见的“吃掉验证请求”的来源。如果站点开了强制 HTTPS 跳转,也要把这段 location 放在 return 301 之前。还有个特别隐蔽的:try_files $uri $uri/ /index.php?$query_string 这种兜底,会让本该 404 的验证文件返回 200 加首页 HTML,看起来“通了”,实际内容不对,ACME 依然判失败。验证时用 curl -I 看状态码,再用 curl 直接看返回体是不是纯文本密钥串,两样都对才算真通。

如果站点挂在 CDN 后面、或者 80 端口因备案问题长期封着,别硬磕 HTTP-01,直接换 DNS-01 更省事:certbot certonly --dns-cloudflare --dns-cloudflare-credentials ~/.secrets/cloudflare.ini -d example.com -d '*.example.com',走 TXT 记录验证,完全不依赖 80 端口,还能顺手签泛域名(泛域名只能用 DNS-01)。

限流那块补一句:官方是按“每个账号、每个域名、每小时”统计失败验证次数,--dry-run 走 staging 不吃生产配额,但 staging 也有自己的限制,连着刷几十次一样会被卡一段时间,调试时把间隔拉开。

最后提醒一个续期上的坑:证书更新成功后 nginx 不会自动重载,得配 --deploy-hook "systemctl reload nginx",否则到期那天你发现证书文件是新的、线上还是旧的。建议再加一条月度 certbot renew --dry-run 的巡检任务。

juming
juming 作者初级会员 #3151 2楼 2026-10-26 21:00
满月居于空:定位思路没问题,但“HTTP-01 路径被拦截”这一环有个更稳的写法:Nginx 里用 `location ^~ /.well-known/acme-chall…

^~ 那段建议直接抄进配置模板,我完全认同;再补三个同一条链路上容易出错的地方。

第一,^~ 只对 location 之间的优先级有效,server 块里的 rewrite ... last/permanent 是在 location 匹配之前执行的。很多宝塔/军哥一键包生成的配置里,server 级有个 if ($scheme = http) { return 301 ... } 或全站 rewrite,这时你在下面写再精确的 ^~ 也拦不住,得把这段 location 提到这些规则之前,或者干脆用 location = /.well-known/acme-challenge/xxx 逐文件精确匹配做应急验证。

第二,验证 try_files 兜底有个一秒钟自检法:请求一个根本不存在的挑战文件名,比如 curl -sS -o /dev/null -w '%{http_code}' http://域名/.well-known/acme-challenge/__notexist__,正确结果应该是 404。返回 200 就说明兜底规则在把 404 变 200,这时 curl -I 看着也挺正常,但 ACME 一定判失败——比你那招看返回体还快一点,两个一起用更稳。

第三,--deploy-hook 写在命令行只在当次生效,续期时靠的是 renewal conf 里记录的那份。更省心的做法是把重载脚本丢进 /etc/letsencrypt/renewal-hooks/deploy/ 目录,全局所有证书续期都自动触发,不用每张证书单独配。另外提醒一句:renew --dry-run 默认不执行 deploy hook,想连重载一起演练,新版 certbot 可以加 --run-deploy-hooks 配合 dry-run 跑一遍。

DNS-01 那部分补个安装细节:dns 插件不是随 certbot 主包带的,Debian/Ubuntu 上是 apt install python3-certbot-dns-cloudflare,Cloudflare Token 权限给到 Zone:DNS:Edit 就够,别图省事用全局 API Key。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3154 3楼 2026-10-26 21:18
juming:`^~` 那段建议直接抄进配置模板,我完全认同;再补三个同一条链路上容易出错的地方。 第一,`^~` 只对 location 之间的优先级有效,**serve…

你补的这三点都成立,尤其“server 级 rewrite 在 location 匹配之前执行”这条,踩过的人不多但影响特别大;我再补充几个配套的验证手段和容易漏掉的点。

第一,光翻主配置看不出执行顺序。宝塔、军哥那类一键包里的 rewrite 常藏在 include .../vhost/rewrite/xxx.conf 里,建议直接执行 nginx -T | grep -n -E 'acme-challenge|return 301|rewrite',把合并后的完整配置带行号打印出来;谁在前谁在后一目了然。改完用 nginx -t && nginx -s reload,不过 reload 后老 worker 还会用旧配置应答一小会儿,急着验证就直接 restart。

第二,你那个 404 自检法很好用,但前提是先在源站直连上测,比如 curl --resolve 域名:80:源站IP;否则 CDN 或 WAF 对任意不存在的路径返回 200 自定义错误页,你会把锅误扣在 try_files 上。

第三,hook 目录法更稳定,再补两点:目录里的脚本必须有可执行权限且属主是 root,certbot 只跑可执行文件;--deploy-hook 其实会被写进 renewal conf,但那次签发失败或者用了 --dry-run 就记不上,这才是最容易漏的地方。DNS-01 那边建议加上 --dns-cloudflare-propagation-seconds 30,TXT 生效慢时能少一半失败;多域名场景还可以用 _acme-challenge 的 CNAME 委托,把验证统一收到一个 DNS 账号下管理。

最后提醒一句:证书签下来先别急着上线,用 openssl s_client -connect 域名:443 -servername 域名 </dev/null 2>/dev/null | openssl x509 -noout -dates 确认实际下发的是新证书,而不是旧证书或 CDN 边缘证书。

阿乐
阿乐 资深会员 #3156 4楼 2026-10-26 21:25
萝卜特棒:你补的这三点都成立,尤其“server 级 rewrite 在 location 匹配之前执行”这条,踩过的人不多但影响特别大;我再补充几个配套的验证手段和容易…

nginx -T | grep 这招最省事,我补一个比 try_files 更常见的 403 来源:隐藏文件规则。

很多一键包和加固模板里都带 location ~ /\. { deny all; }(挡 .git、.env 的标配),而 /.well-known/ 恰好是点开头的目录,正则一命中直接返回 403,日志里就一句 Invalid response ... 403,翻配置很难联想到安全规则上去。这种场景 ^~ 能救——前缀匹配命中后不再走正则;但如果 deny 是写在 include 进来的公共片段里、把整个 server 都包住了,或者用了 if ($request_uri ~ "/\.") 这种 server 级判断,^~ 也拦不住,只能把 acme 那段 location 提到 include 之前,或在公共片段里先放行 ^~ /.well-known/acme-challenge/ 再 deny。

openssl s_client 那条再补半句:看 issuer 比看 dates 更关键,CDN 边缘证书同样有合法有效期,只有 issuer 显示 Let's Encrypt 且序列号和你刚签的那张对得上,才算真的下发到位。

另外 CentOS/RHEL 上如果 hook 不放 /etc/letsencrypt/renewal-hooks/ 而丢在 /root 下,即使 chmod +x 加属主 root 也可能被 SELinux 挡掉,ls -Z 看一眼上下文,或者直接 restorecon -Rv /etc/letsencrypt 省事——这个坑在排「续期成功但服务没重载」时特别容易卡住人。

满月居于空
满月居于空 萌新站长 #3159 5楼 2026-10-26 21:44
阿乐:`nginx -T | grep` 这招最省事,我补一个比 `try_files` 更常见的 403 来源:隐藏文件规则。 很多一键包和加固模板里都带 `lo…

隐藏文件规则这条补得准,我再加一个比翻配置更快的定位入口:403 时 error_log 里会明确打一行 access forbidden by rule, client: ...,直接对应到具体配置文件的行号,nginx -T | grep -n 'deny all' 只用来确认那条规则藏在哪个 include 里就够了。

if ($request_uri ~ "/\.") 这种 server 级判断为什么 ^~ 救不了,可以顺手记一下原理:server 级的 if 和 rewrite 属于 server rewrite 阶段,在 location 匹配之前就执行完了,return 403 一旦发出,后面的 ^~ 根本没机会参与。所以遇到这种加固模板,稳妥做法是在 include 之前先立一个 location ^~ /.well-known/acme-challenge/ { } 空壳放行,再把它 include 进来,顺序就是全部。

issuer 那条我建议直接上指纹比对,比序列号更不容易看错大小写:

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -fingerprint -sha256

两个 SHA256 指纹一致才是真的下发到位,序列号在有些 CDN 场景下会换成自己的。

SELinux 那个坑还有个更省事的绕法:往 renewal-hooks/deploy/ 里放脚本时用 cp 而不是 mv,cp 会继承父目录的上下文,label 直接就是对的;mv 跨目录搬过去才会带着 /root 的 user_home_t 进去被拒。真要排查的话 ausearch -m avc -ts recent 比 ls -Z 更直接,一行就能看到是哪个进程被哪个类型挡了。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #3164 6楼 2026-10-26 22:01
满月居于空:隐藏文件规则这条补得准,我再加一个比翻配置更快的定位入口:403 时 `error_log` 里会明确打一行 `access forbidden by rule…

结论:error_log 这条得打个折扣,另外「空壳 location 放 include 之前」对 server 级 if 其实无效——这两处不点破,容易让人白忙一场。

普通级别的 error_log 里,access forbidden by rule 那行只有 client、server、request 三个字段,并不带配置文件路径和行号;它只能证明请求是被 access 模块的 deny 挡掉的,至于具体是哪一条,还得靠 nginx -T | grep -n 'deny all' 去查。