在此之前,我一直生活在由 Cloudflare 打造的舒适圈中:完全不需要考虑 SSL 证书的部署与续期,即使在国内需要优化线路,我也只是使用 TCP 四层反代的方式,将证书交给 Cloudflare 管理。然而在逐步构建家庭服务器的过程中,证书终究还是绕不过的一环。在此之前,证书给我的印象是:繁琐。申请麻烦,部署困难,以及越来越短的证书有效期导致需要经常考虑续期问题,这些都是让我转向证书自管理的阻碍。
然而,在阅读本篇文章之后,你完全可以实现:
- 在一次性的配置后,即可实现后续的全自动证书续期
- 对于托管在不同平台的多个域名,仅需配置某一个平台的 API,即可实现全自动 DNS 验证
- 在多台应用服务器上,仅需一个简单的 shell 脚本,配合定时任务,即可实现全自动证书部署
如果没有多域名,多台机器分离部署的需求,阅读本篇文章后,你仍然可以学到:
- acme. sh 的证书申请, 自动化续期与部署方式
- acme. sh 的调试方式
- 证书申请中使用其他域名帮助进行 DNS 01 验证
提示
本篇教程仍然需要懂得基础的 Linux 操作方法与部分网络证书知识
思想设计 & 主体结构
整体架构分为三部分:
- 证书的申请与签发(基于 acme.sh)
- 证书的存储与分发(本篇中基于 Cloudflare KV 和 Cloudflare Workers,当然你可以选择更适合自己的存储源和分发源,如
OSS
和Nginx
,但是本篇暂不涉及) - 证书的部署(由一个简单的 shell 脚本与定时任务组成)
三个部分可以完全分离,并不需要放在同一台服务器或主机上。
对于多个不同的域名,则需要选择一个域名用于进行 DNS 01 认证(假设为 autorenew.com
),而其他域名只需一次性的 DNS 配置,将 _acme-challenge.example.com
CNAME 到 _acme-challenge.autorenew.com
(或者其他前缀)即可完成后续的验证与续期。
申请完成后,调用 --deploy-hook
脚本,将证书上传至 Cloudflare KV。后续续期后,覆盖上传新证书。
在客户端使用 shell 脚本,定期从远端下载证书并和本地证书对比更新,实现本地证书的自动更新续期。
如果没有特殊需求,微林(vx. Link)的证书服务则可以免费提供证书的自动申请与续期,而你要做的仅是在 DNS 服务商配置一段 CNAME 的 DNS 记录,以及在服务器上使用一段 shell 脚本自动更新被续期的证书。(微林打钱!!)但是目前来说,微林上的证书服务还有一些不完善,这促使我转向证书自管理,具体如下:
- 证书的下载链接不提供 https,仅使用 http(经过论坛反馈目前已支持,可以使用 https://get.vx.link/ 来代替之前的 http 下载链接)
- 目前尚不支持三级泛域名的证书申请(2025.07.07)
acme.sh 的安装
当前部分仅对 acme.sh 的安装一笔带过,更多高级配置请参考官方文档
curl https://get.acme.sh | sh -s email=my@example.com #注意修改邮箱
邮箱填写并非强制,填写邮箱后会自动注册一个 acme 账户,用于后续异常通知,或违规问题。需要注意的是,如果你不想填邮箱,则需要删除 email=my@example.com
,否则acme 与 CA 会自动阻止类似于 @example.com
等无效邮箱的注册和申请证书。
在 acme.sh 中选择&配置 DNS 服务商
DNS 别名模式
假设我有多个不同的域名/子域名都需要申请证书,并且麻烦的是,这些域名都托管在不同的域名解析商中,如 DNSPOD,阿里云,华为云等。这意味着,如果分别使用 acme.sh 申请证书,并且还要保证自动续期,则需进行非常多的 API 配置,这同时也增加了很多风险。
但此时,我还有一个域名托管在 Cloudflare,该域名并不非常重要,并且 Cloudflare 的 API 配置简单而且可以精细权限控制,那么我就可以使用该域名进行其他所有域名的验证工作。
将需要申请证书的域名(假设为 example.com
),创建 _acme-challenge.example.com
的 CNAME 记录,指向用于验证的,托管在 Cloudflare 的域名 _acme-challenge.autorenew.com
,一劳永逸。
这样配置可行的原理是,证书申请中的 DNS 01 验证可以跟随 CNAME 解析,所以只要最终被 CNAME 到的域名中的 TXT 配置正确,也可以通过 DNS 01 验证。
acme.sh 官方文档对此有更加详细的解释说明,参阅 DNS-alias-mode。
Cloudflare API 配置
进入 Cloudflare 的后台,在 管理账户-账户 API 令牌
令牌处创建一个 API 令牌,权限仅勾选 DNS 区域的编辑。同时,进入域名配置页,在网页右侧找到 区域ID
并记录下来。
编辑 .acme.sh/account.conf
文件,在最后添加上:CF_Token="xxx"
,CF_Zone_ID="xxx"
,同时确保文件权限为 600.
acme.sh 的证书申请
接下来仅需一行命令进行证书的申请托管在 DNSPOD 的域名(假设为 dnspod.com
)的泛域名证书:
acme.sh --issue --dns dns_cf --challenge-alias "cloudflare.com`" -d "*.dnspod.com" --server letsencrypt
--challenge-alias
参数会自动选择传入域名的_acme-challenge
前缀子域名进行验证- 如果想自定义前缀,则需使用
--domain-alias
- 比如,我想设置
abc.cloudflare.com
子域而不是_acme-challenge.cloudflare.com
专用于 DNS 验证,则需传入--domain-alias "abc.cloudflare.com"
- 比如,我想设置
- 如果想自定义前缀,则需使用
--server letsencrypt
选择在let‘s Encrypt 处申请证书--dns
选择使用 cloudflare 的自动 dns 验证服务
对于托管在其他解析服务商的域名,则需要重复调用几次 --issue
来申请证书:
acme.sh --issue --dns dns_cf --challenge-alias "cloudflare.com`" -d "*.aliyun.com" --server letsencrypt
acme.sh --issue --dns dns_cf --challenge-alias "cloudflare.com`" -d "*.hwcloud.com" --server letsencrypt
这里并不建议通过 -d
参数同时传入多个域名。-d
参数会使得 acme.sh 创建一个 SAN 多域名证书,包含 -d
传入的所有域名。这确实会更方便,但同时也增加了一些安全风险,所以这里我们不使用 SAN 证书。
acme.sh 申请调试
Let’s Encrypt 对证书申请频率的限制非常严格,但是在调试中却不可避免的需要多次申请证书。这时我们可以将 --server
中的 CA 改为 letsencryp_test
。这会从远端申请到一个假证书专用于调试。
假证书的申请也并不是无限制的,但是会比真正的证书申请频率高几倍。
为什么不使用 --staging
参数?
- 根据一些 GitHub 的 issue 说,
--staging
有时仍然会获取到正式的证书。为了避免碰到限制,使用letsencryp_test
作为 server 会更加稳妥
acme.sh 的自动上传
acme.sh 支持在部署的时候通过 --deploy-hook
的时候自动调用外部 shell 脚本,将其用来上传申请好的证书到存储源。
在 .acme.sh/deploy/
目录下创建 cloudflare_kv.sh
:
#!/bin/bash
# Cloudflare KV API 配置
CF_ACCOUNT_ID="xxx"
CF_API_TOKEN="xxx"
KV_NAMESPACE_ID="xxx"
cloudflare_kv_deploy() {
_cd="$1" # domain
_key="$2" # key file path
_cert="$3" # fullchain file path
_ca="$4" # ca file path
_fullchain="$5" # cert+ca fullchain path
# 移除泛域名前缀 *.
domain="${_cd#*.}"
# 拼接 KV keys
fullchain_key="${domain}:fullchain"
privkey_key="${domain}:key"
# ⚡ 检查私钥格式并转换为 PKCS#8(如有必要)
if grep -q "BEGIN EC PRIVATE KEY" "$_key"; then
echo "⚠️ 检测到 EC 私钥,正在转换为 PKCS#8 格式..."
pkcs8_key=$(mktemp)
openssl pkcs8 -topk8 -nocrypt -in "$_key" -out "$pkcs8_key"
_key="$pkcs8_key"
echo "✅ 私钥已转换为 PKCS#8 格式"
else
echo "ℹ️ 私钥已是 PKCS#8 或 RSA,无需转换"
fi
# 上传函数
upload_kv() {
local key=$1
local file=$2
local value=$(base64 < "$file")
curl -s -o /dev/null -X PUT \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: text/plain" \
--data "$value" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/storage/kv/namespaces/$KV_NAMESPACE_ID/values/$key"
}
# 上传证书和私钥
upload_kv "$fullchain_key" "$_fullchain"
upload_kv "$privkey_key" "$_key"
echo "✅ 已上传 ${domain} 的证书和私钥到 Cloudflare KV"
}
- 对于此处的
CF_API_TOKEN
,则需要重新创建一个 Cloudflare API 令牌,仅分配权限为Workers KV 存储 - 编辑
。 KV_NAMESPACE_ID
则是 KV 存储实例的 ID,创建后即可在 KV 名称的右侧看到
[!note] 同时,当前脚本会自动将原来的 EC 私钥转换为
PKCS #8
的格式,以换取更好的兼容性。KV 中的 keys 的格式为{domain}:key
, 如果 domain 为泛域名,则会自动移除前面的*.
。比如:
*.example.com
在 KV 的 key 存储为example.com
,这会导致*.example.com
和example.com
的证书冲突,需要注意。
后续,chmod +x cloudflare_kv.sh
给脚本添加执行权限后,通过下面命令测试:
acme.sh --deploy -d "*.dnspod.com" --deploy-hook cloudflare_kv
--deploy-hook
默认会在.acme.sh/deploy/
目录下寻找 shell 脚本,并且不支持子目录(不能调用.acme.sh/deploy/custom/
下的脚本)
执行完成后,在 KV 中检查是否已经成功上传证书,证书本身已经过 base64 编码。对于其他域名,仍然需重复执行多次上面的 --deploy
。这样可以保证部署流程被写入 acme.sh, 这会让下次的自动续期自动上传续期后的证书到 KV 中。
[!note] 对于其他的存储源,须确保续期后的证书可以覆盖掉旧证书。Cloudflare KV 的默认逻辑即为覆盖,所以不需要考虑。
acme.sh 的自动续期
默认情况下,acme.sh 每天会自动检查是否有需要续期的域名,同时自动调用申请时的配置进行 renew,如 --dns
,--challenge-alias
,--server
,以及部署时的自动上传脚本 --deploy-hook
。
测试可以使用 acme.sh renew -d '*.dnspod.com' --force
来观察自动续期时的情况,同样需要注意的是申请证书的频率限制非常严格,不要多次通过 force 强制续期测试。
证书的分发(Cloudflare Workers)
本篇的分发服务使用 Cloudflare Workers 实现,绑定 KV 后,在 Workers 中进行身份验证,域名区分等,可以简单的通过 GET 请求来下载证书以及私钥。
使用 Workers 来处理分发还有另一个好处:分发服务本身不用担心证书过期的问题,该服务本身的证书由 Cloudflare 管理。需要注意的就是 Workers 在国内的连接性并不太好,需要测试是否能成功下载。
export default {
async fetch(request, env, ctx) {
try {
// 解析 URL 和请求头
const requestUrl = new URL(request.url);
const requestSearchParams = requestUrl.searchParams;
const authHeader = request.headers.get("X-CERTIFICATE-KEY");
// 校验 X-CERTIFICATE-KEY
if (authHeader !== env.X_CERTIFICATE_KEY) {
return new Response(
JSON.stringify({ code: 401, message: "Unauthorized Request." }),
{
status: 401,
headers: { "Content-Type": "application/json;charset=utf-8" },
}
);
}
// 获取 type 参数并小写化
let requestCertificateType = requestSearchParams.get("type");
if (!requestCertificateType) {
return new Response(
JSON.stringify({ code: 400, message: "Missing type parameter." }),
{
status: 400,
headers: { "Content-Type": "application/json;charset=utf-8" },
}
);
}
requestCertificateType = requestCertificateType.toLowerCase();
// 拼接 KV key(匹配 acme.sh 上传格式)
// fullchain: home.meoware.cn:fullchain
// key: home.meoware.cn:key
const keySuffix = requestCertificateType === "fullchain" ? ":fullchain"
: requestCertificateType === "key" ? ":key"
: null;
if (!keySuffix) {
return new Response(
JSON.stringify({ code: 400, message: "Invalid type parameter." }),
{
status: 400,
headers: { "Content-Type": "application/json;charset=utf-8" },
}
);
}
// KV Key 前缀,支持多域(从 ?domain 参数获取)
const domainParam = requestSearchParams.get("domain");
if (!domainParam) {
return new Response(
JSON.stringify({ code: 400, message: "Missing domain parameter." }),
{
status: 400,
headers: { "Content-Type": "application/json;charset=utf-8" },
}
);
}
const kvKey = `${domainParam}${keySuffix}`;
// 从 KV 获取证书或私钥
const certificateResponse = await env.KV_SSL_CERTIFICATES.get(kvKey);
if (certificateResponse === null) {
return new Response(
JSON.stringify({ code: 404, message: "Certificate Not Found." }),
{
status: 404,
headers: { "Content-Type": "application/json;charset=utf-8" },
}
);
}
// Base64 解码
const certificateResponseDecode = atob(certificateResponse);
// 返回证书或私钥
return new Response(certificateResponseDecode, {
status: 200,
headers: { "Content-Type": "text/plain;charset=utf-8" },
});
} catch (err) {
console.error("Error handling request:", err);
return new Response(
JSON.stringify({ code: 500, message: "Internal Server Error." }),
{
status: 500,
headers: { "Content-Type": "application/json;charset=utf-8" },
}
);
}
},
};
- 需要在
网页后台-绑定
中绑定之前的 KV 存储源 - 当前脚本会验证请求头中的
X_CERTIFICATE_KEY
,再部署好 Workers 后,需在网页后台-设置-变量和机密
中添加名为X_CERTIFICATE_KEY
的环境变量。
请求格式为:
curl -v -H "X-CERTIFICATE-KEY: xxxxx" \
"https://ssl.workers.dev?domain=dnspod.com&type=fullchain"
type
: 可以为 fullchain 和 key,分别对应证书本身和密钥domain
:对应域名,当前 JS 默认所有证书为泛域名,即传入dnspod.com
会返回*.dnspod.com
的证书和对应私钥
证书的部署
在客户端上,最后要做的就是证书部署,这部分很简单,仅需一个 shell 脚本。他的作用是:请求远端证书,并和本地的已有证书做对比。如果不一样代表证书已续期,更新本地证书并执行刷新命令:
#!/bin/bash
# 注意:此脚本需要 curl 与 md5sum 命令,如果没有请提前安装
# 定义 URL 和对应的本地存储位置
declare -A urls_and_paths=(
# 下面是一个例子,如果是在 nginx 中,您应该根据 nginx.conf 或者自定义的配置文件来填写参数
["https://ssl.workers.dev?domain=dnspod.com&type=key"]="/etc/nginx/ssl/dnspod.com/cert.key"
["https://ssl.workers.dev?domain=dnspod.com&type=fullchain"]="/etc/nginx/ssl/dnspod.com/cert.crt"
# 一行一份,添加更多的 URL 和对应的本地存储位置
# 请注意, Key 和 Crt 文件应该都一起更新
)
# ⚠️ 这里定义需要附加的 header(支持多个)
# 格式:("Header1: Value1" "Header2: Value2")
extra_headers=(
"X-CERTIFICATE-KEY: xxxx"
)
# 定义特定的指令(如果要重启服务,可以使用 nginx -s reload)
command_to_execute="nginx -s reload"
# 定义变量来判断是否需要执行操作
execute_operation=false
# 拼接 curl 的 header 参数
curl_header_args=()
for header in "${extra_headers[@]}"; do
curl_header_args+=("-H" "$header")
done
# 遍历所有 URL,并下载文件
for url in "${!urls_and_paths[@]}"; do
local_path="${urls_and_paths[$url]}"
# 下载远端文件到临时文件
temp_file=$(mktemp)
curl -s "${curl_header_args[@]}" -o "$temp_file" "$url"
# 检查是否下载成功
if [ $? -ne 0 ]; then
echo "Warning: Failed to download $url"
rm -f "$temp_file"
continue
fi
# 计算远端文件和本地文件的 MD5 值
remote_md5=$(md5sum "$temp_file" | awk '{print $1}')
local_md5=$(md5sum "$local_path" 2>/dev/null | awk '{print $1}')
echo "Remote MD5 for $url is $remote_md5 | Local MD5 is $local_md5"
# 如果 MD5 值不同,则更新本地文件
if [ "$remote_md5" != "$local_md5" ]; then
echo "Updating file from $url to $local_path"
mv "$temp_file" "$local_path"
execute_operation=true
else
rm -f "$temp_file"
fi
done
# 执行特定的指令,当有任意一个 URL 的文件发生改变时才执行
if [ "$execute_operation" = true ]; then
eval "$command_to_execute"
fi
当前脚本基于微林的自动化更新脚本改编而来,增加了请求头配置, 有几行配置需要更改:
urls_and_paths
中左边为证书直链,右边则是本地存储的位置,确保有权限写入extra_headers
添加请求直链时额外的认证请求头,在本篇教程中需要添加的只有X-CERTIFICATE-KEY
command_to_execute
在证书更新完毕后需要执行的命令,如nginx
则需要执行nginx -s reload
刷新证书
将脚本存储在固定位置,添加执行权限,添加到计划任务:
chmod +x vxSSLupdate.sh
(crontab -l 2>/dev/null; echo "0 0 * * * /path/to/vxSSLupdate.sh") | crontab -
References
- fernvenue 的證書的自我修養
- 微林(vx. Link)的证书服务
- 微林的自动化更新脚本
- acme.sh 的官方文档 DNS-alias-mode