免费证书本身不是问题,“三个月后突然过期”才是。大多数事故不是因为 ACME 客户端不会续期,而是因为续期路径在某个时刻被悄悄改坏了,而没有人收到告警。

一、先选验证方式

方式原理前提风险
HTTP-01在 80 端口放一个验证文件域名直接解析到本机境外代理会拦截
DNS-01写一条 _acme-challenge TXT有 DNS API 权限API Token 泄露
TLS-ALPN-01443 端口握手校验无其它服务抢占端口复用冲突

对“域名解析在自己手里的单机站”,HTTP-01 最简单;一旦前面挂了 CDN 代理,就必须用 DNS-01。

关键判断:如果 dig +short 你的域名 返回的是 CDN 厂商的 anycast IP,而不是你的服务器 IP,那么 HTTP-01 的验证请求根本到不了你的机器。

二、Caddy:默认就做对了

jiuka.top, www.jiuka.top {
    root * /var/www/jiuka
    encode zstd gzip
    file_server

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
    }
}

Caddy 会在证书剩余约 1/3 有效期时自动续期,并在 80 端口保留 ACME 与跳转逻辑。它把状态存在 /var/lib/caddy,备份时要一起备份,否则重建后会重新申请,容易撞上速率限制。

三、Cloudflare 场景:改用 DNS-01

Caddy 内置了 DNS 插件,但需要单独编译(xcaddy build --with github.com/caddy-dns/cloudflare)。另一种做法是保持 Caddy 默认,改用 certbot 的 DNS 插件签发,再让 Caddy 读取证书。

无论哪种,Token 权限要最小化:

{
  "permissions": ["Zone:DNS:Edit"],
  "zone_resources": { "jiuka.top": "*" }
}

只给目标 zone,不给 Zone:Read 以外的账户级权限;Token 存为 0600 的环境文件,不进 Git。

四、让“续期成功”可被证明

证书不过期 ≠ 续期正常。真正需要监控的是剩余天数和续期任务最近一次成功时间。

#!/usr/bin/env bash
# /usr/local/bin/check-cert.sh
set -euo pipefail
DOMAIN="jiuka.top"
PORT="443"
DAYS=$(echo | openssl s_client -servername "$DOMAIN" -connect "$DOMAIN:$PORT" 2>/dev/null \
  | openssl x509 -noout -enddate | cut -d= -f2 | xargs -I{} date -d {} +%s)
LEFT=$(( (DAYS - $(date +%s)) / 86400 ))
echo "cert expires in ${LEFT} days"
[ "$LEFT" -gt 21 ]

配合 systemd timer 每天跑一次,失败时通过邮件或企业微信机器人告警。告警要发到“会有人看”的渠道,而不是只写进 journald。

五、三个常见坑

  1. 80 端口被安全组关掉:HTTP-01 直接失败,但 443 仍能访问,容易误判为“证书没问题”。
  2. www 没有加进 SAN:主域正常,用户输入 www. 时证书报错。
  3. 续期后没 reload:证书文件更新了,进程仍持有旧证书。Caddy 自动处理,Nginx 需要显式 nginx -s reload。

六、验收标准

  • curl -I https://域名 返回 200 且证书链完整
  • curl -I http://域名 返回 301 到 https
  • 证书剩余有效期 > 60 天(说明自动续期在跑)
  • 关闭 80 端口后能收到告警
  • 续期记录可在日志中追溯

把这几条写进上线检查清单,比“应该会自动续”可靠得多。