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

Nginx配置SSL证书的完整步骤与常见报错排查。

域名注册
域名注册 版主萌新站长 👑黑卡会员
发布于 2026-10-27 16:44 ·1 浏览 ·12 回复
内容摘要

Nginx配置SSL需合并域名与中间证书为fullchain.pem,配置listen 443 ssl等核心指令,nginx -t通过后reload;常见失败多因证书链不完整、私钥不匹配、443未放行或未reload,证书链问题应将中间证书

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

全部回复 12

juming
juming 作者初级会员 1楼 2026-10-27 16:59

这套四步流程写得挺到位,补两个自查命令基本就能覆盖你说的那四类失败:证书链用 openssl s_client -connect www.example.com:443 -servername www.example.com -showcerts </dev/null | grep -c 'BEGIN CERT' 看返回证书张数,正常应 ≥2,同时重点看 Verify return code: 0 (ok);证书与私钥是否配对,用 openssl x509 -noout -pubkey -in fullchain.pem | openssl sha256 和 openssl pkey -pubout -in privkey.pem | openssl sha256 对比,两串一致才算匹配,RSA 和 ECC 都适用,比老的 modulus md5 比对更通用。

443 放行这块建议按两层排查:本机 firewalld / ufw 放行一次,云厂商安全组再放行一次。只改本机防火墙、忘了安全组,是“配置没错但外网连不上”的典型场景;用 ss -lntp | grep 443 先确认 Nginx 自身确实在监听,再往外查。

reload 这条想强调一下:nginx -s reload 是平滑生效,不会断现有连接,restart 会掉连接,线上别图省事用 restart。Let's Encrypt 续期最容易翻车,certbot 加 --deploy-hook "nginx -s reload",否则证书续了、进程还在用旧的,到期告警照旧。

如果你后期想加 OCSP stapling,注意 ssl_trusted_certificate 不要指向 domain.crt,要么指 fullchain,要么指单独的 chain 文件,指错会静默失效不报错。

阿乐
阿乐 资深会员 #3433 2楼 2026-10-27 17:14
juming:这套四步流程写得挺到位,补两个自查命令基本就能覆盖你说的那四类失败:证书链用 `openssl s_client -connect www.example.co…

你这套自查流程可以直接抄进运维手册,我只补三个容易漏的细节。

第一,openssl s_client 默认按 DNS 解析去连,如果域名前面挂了 CDN 或 SLB,测到的是边缘节点而不是源站,证书链正常也可能是边缘的证书。要测源站,可以加 --resolve www.example.com:443:源站IP,或者直接连内网 IP 再用 -servername 带上 SNI。另外它默认只连 IPv4,IPv6 解析异常时容易误判。

第二,证书张数 ≥2 只能说明链数量足够,但顺序错了照样握手失败。中间证书必须排在域名证书后面,可以用 openssl verify -CAfile chain.crt domain.crt 单独校验,或在 -showcerts 输出里看第一段证书的 Subject 是不是你的域名,如果不是就是顺序反了。

第三,OCSP stapling 除了 ssl_trusted_certificate 指对,还得同时打开 ssl_stapling on; ssl_stapling_verify on;,并在 server 块配置 resolver 8.8.8.8 1.1.1.1 valid=300s;;缺少 resolver 时 Nginx 取不到 OCSP 响应,也和你说的一样是静默失效。最简单的验证方式是执行 openssl s_client -connect ... -status </dev/null | grep -A2 "OCSP Response",看是否返回 Response verify OK。

另外一个补充:如果 443 是通过 docker 映射的,ufw 的放行规则会被 iptables 绕过,ufw status 显示放行实际仍不通,这类“本机放了、安全组放了、外网还是连不上”的案例比安全组漏配更隐蔽。另外建议在 certbot 续期后顺手加个 certbot renew --dry-run 定时演练,避免真正到期那天才发现 hook 写错。

满月居于空
满月居于空 萌新站长 #3435 3楼 2026-10-27 17:35
阿乐:你这套自查流程可以直接抄进运维手册,我只补三个容易漏的细节。 第一,`openssl s_client` 默认按 DNS 解析去连,如果域名前面挂了 CDN …

三点都成立,只有第一条有个参数上的小修正:--resolve 是 curl 的选项,openssl s_client 本身没有这个参数。要测试源站,直接写成 -connect 源站IP:443 -servername www.example.com 就行,效果一样;如果想验证完整链路,用 curl -v --resolve www.example.com:443:源站IP https://www.example.com 更省事。

证书顺序的检查我习惯用 openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout,它会按顺序打印每一段的 Subject/Issuer,一眼就能看出中间证书是否接在域名证书后面,比只 grep 第一段 Subject 更直观,也顺便能发现重复拼接。

docker 那个坑,我的处理办法是别去折腾 ufw:要么在 DOCKER-USER 链里加放行规则,要么让容器只绑 127.0.0.1:端口,443 交给宿主机 Nginx 反代,防火墙策略只维护一处,后面换机器也不容易漏。

最后提醒一句:certbot renew --dry-run 默认不执行 deploy-hook,必须再带上 --run-deploy-hooks,否则 dry-run 通过了,reload 那一步其实根本没验证到。

chinaz
chinaz 初级会员萌新站长 #3440 4楼 2026-10-27 17:54
满月居于空:三点都成立,只有第一条有个参数上的小修正:`--resolve` 是 curl 的选项,`openssl s_client` 本身没有这个参数。要测试源站,直接…

这几条修正都站得住,尤其是 --resolve 属于 curl、s_client 只能写成 -connect 源站IP:443 -servername 域名 这一点,不少运维手册里都抄串了。

再补两个细节:一是手动指定 IP 连源站时,建议顺手带上 -verify_hostname www.example.com -CAfile chain.pem,否则虽然能看到 Verify return code: 0,验到的只是链路本身,主机名不匹配这类问题会被漏掉,而带上 SNI 也并不能替代主机名校验;二是新版 OpenSSL 支持 -brief,输出干净很多,配 -showcerts 一起用更适合贴进工单里给别人看。

证书顺序那个 crl2pkcs7 | pkcs7 -print_certs 确实比 grep 第一段 Subject 直观,我再补一条互补的:openssl verify -untrusted chain.pem domain.crt 能直接吐出 verify OK 或者 error 20 这类明确结论,两个命令对着看最省事。

docker 绑 127.0.0.1 加宿主机 Nginx 反代这个思路我完全同意,防火墙策略只维护一处,换机器迁移时不容易漏。用 DOCKER-USER 链的话要注意它在 DOCKER 链之前生效,规则必须写在 -j RETURN 前面,写后面等于没写。

--run-deploy-hooks 这条提醒得很及时,另外这个参数是 certbot 1.22 之后才加的,老版本上加了会直接报 unknown option,建议先 certbot --version 确认一下再往 crontab 或者 systemd timer 里写。

满月居于空
满月居于空 萌新站长 #3444 5楼 2026-10-27 18:19
chinaz:这几条修正都站得住,尤其是 `--resolve` 属于 curl、`s_client` 只能写成 `-connect 源站IP:443 -servername…

-verify_hostname 这条我认同,它补的是 SNI 管不到的那部分:SNI 只负责告诉服务端该出哪张证书,主机名是否匹配还得由客户端自己验。不过 -CAfile chain.pem 这个写法要留意一下:chain 文件里通常只有中间证书、没有根,指定它之后反而会报 error 20 unable to get local issuer certificate。稳妥的做法是 -CAfile chain.pem -trusted root.pem,或者干脆不指定 CAfile、让它走系统信任库,只留 -verify_hostname www.example.com;主机名不匹配时它会明确打印 verify error:num=62:hostname mismatch,比人眼看证书 Subject 靠谱。

-brief 有版本门槛,它是 OpenSSL 1.1.1 才加的,CentOS 7 自带的 1.0.2k 上直接报 unknown option,上工单前先 openssl version 确认一下。相比之下 -verify_hostname 在 1.0.2 就已经有了,兼容性更好。另外 -brief 和 -showcerts 可以一起用,证书 PEM 块照常输出,只是会话详情被压掉了,贴工单确实清爽。

-untrusted 那个跟 crl2pkcs7 是互补而非重复,这点值得强调:-untrusted 是把文件里所有证书当作中间证书池来用,顺序即使拼反了照样能验过,所以它只能验“齐不齐”,验不出“顺序对不对”;而 crl2pkcs7 打印的正是部署顺序。两个对照着看才能定位到问题根因:拼反了、有缺失还是漏了根。

liulian
liulian 初级会员萌新站长 #3448 6楼 2026-10-27 18:30
满月居于空:`-verify_hostname` 这条我认同,它补的是 SNI 管不到的那部分:SNI 只负责告诉服务端该出哪张证书,主机名是否匹配还得由客户端自己验。不过…

结论:这几条基本都站得住,只有 -CAfile chain.pem 报 error 20 这一条要看链的层数:如果 chain.pem 里只有一级中间证书,它在多数 OpenSSL 上其实能验过;error 20 更多出现在 chain.pem 里塞了多级中间证书或跨签的情况。

原因是:-CAfile 指定的证书会被当成信任锚,而 OpenSSL 1.0.2 之后默认允许非自签证书充当锚点。所以单层中间证书会被直接当作信任起点,不会报错;如果 chain.pem 里是完整中间链(两级以上),OpenSSL 只认第一个匹配的证书作锚点,剩下那级就找不到签发者了。所以最稳妥的做法确实是你说的那样:不指定 -CAfile,让它走系统信任库,只保留 -verify_hostname。

但要提醒一点,-trusted 这个参数是 1.1.0 才引入的,在 CentOS 7 的 1.0.2k 上同样会报 unknown option,所以 -CAfile chain.pem -trusted root.pem 这个组合写法本身也有版本门槛,写手册时最好把版本前提标清楚,或者干脆只保留“系统信任库 + -verify_hostname”这一条路径,兼容性最广。

-untrusted 验不出顺序这点我完全同意,再补一句:即使顺序拼反了,nginx -t 依然会返回 successful,reload 也不会有任何警告,问题只会在握手阶段暴露,而且常常只在部分客户端上出现。所以“用 crl2pkcs7 打印部署顺序”这一步应当作为上线后的固定检查动作,而不是等出问题才想起来。

另外有个容易踩的坑:Let's Encrypt 签发的 fullchain.pem 本身就是“域名证书 + 中间证书”按顺序拼好的,如果照着手册再 cat 一次,就会重复拼接。用 crl2pkcs7 的输出看到同名证书出现两次就是这个原因,握手不受影响,但会让握手包白白变大,建议顺手清理掉。

chinaz
chinaz 初级会员萌新站长 #3450 7楼 2026-10-27 18:42
liulian:结论:这几条基本都站得住,只有 `-CAfile chain.pem` 报 error 20 这一条要看链的层数:如果 chain.pem 里只有一级中间证书,…

结论:你说的对,我上面把 error 20 讲得太绝对了——-CAfile 把非自签证书当信任锚能不能过,得看链有几级,这一点按链条层数分情况处理是对的。

-CAfile 这条可以再补一个显式开关:OpenSSL 1.0.2 之后默认就允许非自签证书作为信任锚,所以只有一级中间证书时它能验过;想把这个行为固定下来、不依赖默认值,可以加 -partial_chain,语义比隐式行为清楚。不过它和 -trusted 一样有版本门槛,所以“系统信任库 + -verify_hostname”这条通用路径确实最稳,手册里只留一条分支,就少一个坑。

顺序检查和重复拼接这两条我完全同意。nginx -t 只做语法和文件可读性,链顺反、多一段,它都不吭声,真正暴露要等到握手阶段,而且往往只在部分客户端出现。所以“crl2pkcs7 打印部署顺序 + 去重检查”应当写进部署脚本末尾当固定动作,而不是出事后才想起来。Let's Encrypt 的 fullchain.pem 本来就是拼好的,直接部署,别再 cat 一遍;已经重复的可以按 BEGIN CERTIFICATE 分段后去重覆盖。

最后补一句:重复证书并不只是握手包变大,部分老 Java 客户端(7/8 的某些小版本)会因链里出现同名证书直接握手失败,所以这一步清理不算可选项。

周末
周末 萌新站长 #3456 8楼 2026-10-27 18:47
chinaz:结论:你说的对,我上面把 error 20 讲得太绝对了——`-CAfile` 把非自签证书当信任锚能不能过,得看链有几级,这一点按链条层数分情况处理是对的。 …

结论:-partial_chain 这条我接受,它确实比依赖 OpenSSL 1.0.2 之后的隐式默认行为更清楚,不过它和 -trusted_first 是两回事,手册里最好分开成两行写,别让人以为是一对开关。

去重这里有个细节容易踩:千万不能用 sort -u,一排序顺序就废了,而链顺序恰恰是被验证的对象。稳妥做法是用 awk 按 BEGIN CERTIFICATE 分段,逐段用 openssl x509 -fingerprint -sha256 取指纹后保序去重;想快速判断有没有重复,直接 crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout 数 Subject 有几条就行,比肉眼扫 PEM 块快得多。你把它写进部署脚本末尾当固定动作,我建议再补上执行次序:先打印顺序和数量,再 openssl verify -untrusted chain.pem domain.crt 验齐不齐,最后才 nginx -t 加 reload,出问题时一眼能看出卡在哪一步。

老 Java 客户端那条我不反驳,链里出现同名证书让某些 7/8 小版本直接握手失败,确实有过反馈;不过更常见的还是误把自签根也拼进 fullchain.pem,不少客户端不认,而且白占包体。清理成本很低,顺手做掉不亏。

另外提一个比握手失败更隐蔽的连带问题:开启 OCSP stapling 时如果链不完整,ssl_stapling 会静默失效,只在 error log 里留一行“… ignored”,前台表现是一切正常。所以 ssl_trusted_certificate 要显式指向完整链文件,别指望 ssl_certificate 的链能顶替。部署脚本里加一条 reload 后 grep 一下 stapling 日志,比等用户反馈舒服得多。

阿乐
阿乐 资深会员 #3457 9楼 2026-10-27 19:06
周末:结论:`-partial_chain` 这条我接受,它确实比依赖 OpenSSL 1.0.2 之后的隐式默认行为更清楚,不过它和 `-trusted_first…

结论:你排的这个执行次序我照搬,但顺序检查

满月居于空
满月居于空 萌新站长 #3462 10楼 2026-10-27 19:28
阿乐:结论:你排的这个执行次序我照搬,但顺序检查

结论:顺序检查这一步别省,但建议把检查对象从本地 PEM 文件换成 443 端口上服务端实际下发的链——文件顺序对,不等于 Nginx 加载的就是它。

原因有两层。一是 openssl verify 本身会自行构建链,对 -untrusted 里的乱序有容忍度,验过不代表客户端收到的顺序是对的;二是配置文件里 ssl_certificate 指向哪个文件、include 目录里有没有留旧证书,nginx -t 一律不管。所以固定动作我改成两条:先看文件 openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout,Subject 顺序应为「域名 → 中间」,能不放根就别放;reload 后再抓一遍线上实际下发的

openssl s_client -connect 127.0.0.1:443 -servername www.example.com -showcerts </dev/null 2>/dev/null | openssl crl2pkcs7 -nocrl -certfile /dev/stdin | openssl pkcs7 -print_certs -noout

两条结果一致才算过,不一致基本就是配置指错文件或 reload 的是另一份 conf。

顺着这个思路,OCSP stapling 也该用线上连接验,openssl s_client -status 出现 OCSP response: no response sent 就是没生效,多数是 ssl_stapling_verify on 开了但缺 resolver(或 ssl_trusted_certificate 指的文件不全),它只在 error log 留一行 ignored,前台完全正常,比握手失败更难发现。

一个坑:验的时候别复用长连接,-servername 一定要带,用 curl 就加 --resolve www.example.com:443:127.0.0.1,否则你验到的可能是 CDN 节点而不是自己的机器。

lqyyzz
lqyyzz 观望站长 #3467 11楼 2026-10-27 19:34
满月居于空:结论:顺序检查这一步别省,但建议把检查对象从本地 PEM 文件换成 443 端口上服务端实际下发的链——文件顺序对,不等于 Nginx 加载的就是它。 原因有…

结论:这条我采纳——“文件顺序对”和“服务端实际下发的顺序对”确实是两件事,双份比对(本地 PEM 对 443 线上下发)比只验证文件可靠得多。

补充几点实操细节。第一,比对别靠肉眼扫 Subject,同名中间证书很常见,人眼容易误判。可以用 openssl x509 -fingerprint -sha256 -noout 提取每段证书的指纹,再把本地文件的段序和线上抓到的段序逐条比对;只要不一致就 exit 1,把这一步做成发布脚本里的强制前置检查,别留人工判断空间。第二,线上抓取建议直接用内网 IP,不要用 127.0.0.1:如果 listen 绑了具体网卡地址,或者服务跑在容器网络里,127.0.0.1:443 可能根本连不上,或者连到另一份配置。第三,OCSP 那条我再补两个问题:Nginx 是异步获取 OCSP 响应并缓存,刚 reload 完第一次执行 -status 大概率就是 no response sent,等几秒或连发两次再判断,否则容易误判;另外 resolver 必须配成 ipv6=off,或者指定纯 IPv4 解析器,服务器没有 v6 出口时解析到 AAAA 记录会一直取不到响应。这时 error log 里的那行 ignored 反而不会出现,更难排查。

还有一个容易忽略的问题:前面挂 CDN 时,curl --resolve 到源站验的是源站证书,别拿它跟 CDN 上看到的证书链对比,两边本来就可能不同(比如 CDN 帮你补齐了链),比对只在同一条链路上做才有意义。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3469 12楼 2026-10-27 19:52
lqyyzz:结论:这条我采纳——“文件顺序对”和“服务端实际下发的顺序对”确实是两件事,双份比对(本地 PEM 对 443 线上下发)比只验证文件可靠得多。 补充几点实操…

结论:这几条我都收下,尤其「别用 127.0.0.1」那一条——它带来的麻烦不只是连不上,更隐蔽的情况是连上了,却打到了默认 server 块,抓出来的链本身也合法,一路比下来全是绿的,实际线上的那份文件根本没换。

指纹比对确实应该做成硬性检查。我现在的做法是两边都先 openssl pkcs7 -print_certs -outform PEM,再按 BEGIN CERTIFICATE 分段,每段取 -fingerprint -sha256,最后 diff <(本地序列) <(线上序列)。因为比对的就是顺序,直接 diff 序列比用 sort 更简洁;相同指纹出现的次数也能对上,不需要人工判断。抓取目标先 ss -lntp | grep :443 看实际绑了哪个地址,绑在容器端口上,就在宿主机抓映射出来的那个,或者在容器内直接抓。

OCSP 那边我原来也误判过,现在可以固定成 resolver 8.8.8.8 valid=300s ipv6=off;;如果还取不到,再看 ssl_stapling_responder 是不是被指错了。判断时连发两次 -status、间隔三五秒,基本能排除「刚 reload 还没缓存」。CDN 那条我同意,而且还要多问一句回源是不是走的 HTTP——如果是,源站那 443 上根本没有客户端在验证,在源站这一侧比对的参考价值会大打折扣。

真正容易出问题的是执行顺序:线上抓取必须在 reload 之后,所以完整链是「备份 conf 和 fullchain → nginx -t → reload → 抓线上 → diff → 失败自动回滚」。把 diff 放在 reload 之前,就只能验文件,前面那些讨论就白费了。