全链路无人值守自动化 SSL
起因
腾讯云的免费 SSL 证书有效期只有 3 个月,且不支持自动续签,不是很方便(其他云厂商也类似)
解决方案
选 B (土豪随意)
结合 acme.sh + GitHub Actions,撸了一套 全链路无人值守自动化 SSL 脚本,具备以下特色:
功能模块
特色说明
高可靠设计 ✓
不依赖 Actions Cache 保存证书或私钥状态,适合临时 Runner
全自动运维 ✓
无需人工干预,实现 SSL 证书生命周期全自动管理
自动签发 ✓
自动调用 Let’s Encrypt 申请证书
智能续期 ✓
检查线上证书到期时间,仅在临近过期时签发,避免无效请求
云端集成 ✓
自动上传证书至腾讯云 SSL 证书管理
CDN 热更新 ✓
自动更新 CDN HTTPS 配置并立即生效
脚本流程指令
脚本下载
acme-auto.yml name : Auto SSL Renew & Deploy (Tencent Cloud)
# The workflow does not read the repository or use Actions cache/API state.
permissions : {}
on :
workflow_dispatch :
inputs :
force_issue :
description : 'Issue immediately; use only for first deployment or recovery.'
required : false
default : false
type : boolean
schedule :
# Check weekly. A certificate is issued only when it expires
# within 21 days; ordinary checks do not call Let's Encrypt.
- cron : '17 3 * * 1'
# Prevent concurrent: manual trigger + cron
concurrency :
group : ssl-renew-${{ github.repository }}
cancel-in-progress : false
jobs :
issue_cert :
runs-on : ubuntu-latest
steps :
# -------------------------
# Check the certificate currently served by the CDN
# -------------------------
- name : Check deployed certificate expiry
id : cert_check
env :
DOMAIN : ${{ secrets.DOMAIN }}
run : |
set -euo pipefail
# A manual forced run supports the first deployment, when no public
# certificate has been configured on the CDN yet.
if [ "${{ inputs.force_issue }}" = "true" ]; then
echo "needs_issue=true" >> "$GITHUB_OUTPUT"
echo "⚠️ Manual forced issuance requested."
exit 0
fi
CERT_FILE="$(mktemp)"
trap 'rm -f "$CERT_FILE"' EXIT
# The leaf certificate is the first certificate returned by the TLS endpoint.
if ! timeout 30 openssl s_client \
-connect "${DOMAIN}:443" \
-servername "$DOMAIN" \
-showcerts </dev/null 2>/dev/null \
| awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/' > "$CERT_FILE"; then
echo "Unable to retrieve the certificate served by ${DOMAIN}." >&2
exit 1
fi
if ! openssl x509 -in "$CERT_FILE" -noout -enddate; then
echo "The TLS endpoint did not return a valid leaf certificate." >&2
exit 1
fi
# A valid default certificate for a different hostname must not
# suppress renewal for this workflow's two CDN domains.
for HOSTNAME in "$DOMAIN" "www.$DOMAIN"; do
if ! openssl x509 -in "$CERT_FILE" -noout -checkhost "$HOSTNAME"; then
echo "The deployed certificate does not cover ${HOSTNAME}." >&2
exit 1
fi
done
# Keep a 21-day recovery window for DNS or cloud-side deployment failures.
if openssl x509 -in "$CERT_FILE" -noout -checkend $((21 * 24 * 60 * 60)); then
echo "needs_issue=false" >> "$GITHUB_OUTPUT"
echo "🔹 Current certificate is valid for more than 21 days."
else
echo "needs_issue=true" >> "$GITHUB_OUTPUT"
echo "🔔 Current certificate expires within 21 days."
fi
# -------------------------
# Install acme.sh only when a new certificate is needed
# -------------------------
- name : Install acme.sh
if : steps.cert_check.outputs.needs_issue == 'true'
env :
ACME_EMAIL : ${{ secrets.ACME_EMAIL }}
run : |
export PATH="$HOME/.acme.sh:$PATH"
echo "⬇️ Installing acme.sh ..."
curl -fsSL https://get.acme.sh | sh -s email="$ACME_EMAIL"
# -------------------------
# Issue a certificate only when the deployed certificate is near expiry
# -------------------------
- name : Issue certificate
id : renew
if : steps.cert_check.outputs.needs_issue == 'true'
env :
DOMAIN : ${{ secrets.DOMAIN }}
DP_Id : ${{ secrets.DP_ID }}
DP_Key : ${{ secrets.DP_KEY }}
run : |
set -euo pipefail
export PATH="$HOME/.acme.sh:$PATH"
echo "🎬 Issuing a new certificate..."
~/.acme.sh/acme.sh \
--issue \
-d "$DOMAIN" \
-d "www.$DOMAIN" \
--dns dns_dp \
--ecc \
--server letsencrypt \
--dnssleep 120
echo "renewed=true" >> "$GITHUB_OUTPUT"
echo "🔔 Certificate issued"
# -------------------------
# Prepare certificate
# -------------------------
- name : Prepare cert files
if : steps.renew.outputs.renewed == 'true'
env :
DOMAIN : ${{ secrets.DOMAIN }}
run : |
mkdir -p certs
cp ~/.acme.sh/${DOMAIN}_ecc/${DOMAIN}.key certs/
cp ~/.acme.sh/${DOMAIN}_ecc/fullchain.cer certs/
# -------------------------
# Deploy to Tencent Cloud
# -------------------------
- name : Upload cert and update CDN
if : steps.renew.outputs.renewed == 'true'
env :
QCLOUD_SECRET_ID : ${{ secrets.QCLOUD_SECRET_ID }}
QCLOUD_SECRET_KEY : ${{ secrets.QCLOUD_SECRET_KEY }}
DOMAIN : ${{ secrets.DOMAIN }}
run : |
pip install --prefer-binary --disable-pip-version-check tencentcloud-sdk-python==3.1.50
python3 <<'EOF'
import os, json
from tencentcloud.common import credential
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.ssl.v20191205 import ssl_client, models
from tencentcloud.cdn.v20180606 import cdn_client, models as cdn_models
# ---------- Upload cert ----------
cred = credential.Credential(
os.getenv("QCLOUD_SECRET_ID"),
os.getenv("QCLOUD_SECRET_KEY")
)
ssl = ssl_client.SslClient(cred, "ap-guangzhou")
domain = os.getenv("DOMAIN")
with open("certs/fullchain.cer") as f:
pub = f.read()
with open(f"certs/{domain}.key") as f:
key = f.read()
upload_req = models.UploadCertificateRequest()
upload_req.from_json_string(json.dumps({
"CertificatePublicKey": pub,
"CertificatePrivateKey": key,
"Alias": f"AutoSSL-{domain}"
}))
upload_resp = ssl.UploadCertificate(upload_req)
cert_id = json.loads(upload_resp.to_json_string())["CertificateId"]
print("✅ Uploaded new cert: ", cert_id)
# ---------- Update CDN ----------
domains = [domain, f"www.{domain}"]
cdn = cdn_client.CdnClient(cred, "ap-guangzhou")
for d in domains:
try:
print("🔧 Updating CDN for domain: ", d)
req = cdn_models.UpdateDomainConfigRequest()
req.from_json_string(json.dumps({
"Domain": d,
"Https": {
"Switch": "on",
"CertInfo": {"CertId": cert_id}
}
}))
cdn.UpdateDomainConfig(req)
print("✅ CDN updated: ", d)
except Exception as e:
print("❌ CDN update failed: ", d, e)
raise
EOF
# -------------------------
# Result
# -------------------------
- name : Result
if : always()
run : |
if [ "${{ steps.renew.outputs.renewed }}" = "true" ]; then
echo "✅ SSL renewed and deployed."
else
echo "🔹 No renewal needed."
fi
执行流程
脚本执行流程如下:
trigger workflow
│
▼
read certificate served by DOMAIN:443
│
▼
is it valid for more than 21 days?
│
├── yes ── ▶︎ exit (no ACME request)
│
└── no
│
▼
install acme.sh
│
▼
issue a new certificate via DNSPod
│
▼
upload certificate to Tencent Cloud
│
▼
update CDN
签发指令
命令
作用
是否需要验证参数
使用场景
issue
申请新证书
需要
线上证书剩余有效期不足 21 天时
工作流每周检查一次 CDN 对外提供的 DOMAIN:443 证书。检查本身不会访问 Let’s Encrypt;只有证书剩余不足 21 天才会执行 acme.sh --issue,因此正常情况下约每两到三个月才签发一次。
该方案不缓存 ~/.acme.sh。GitHub Actions 的缓存适合依赖加速,不适合作为会更新的证书状态存储,也避免在缓存中长期保存账户密钥、域名私钥或 DNS API 凭据。
配置使用
脚本以腾讯云为样例,如需使用其他云厂商,只需把脚本中的步骤 Deploy to Tencent Cloud 代码替换为对应的版本
DNSPod Token 配置
腾讯云 → DNSPod 「账号中心 → API密钥」,选择 DNSPod Token → 创建密钥,记下 ID 和 Token
ID:668000
Token:1md3646b5c4d7ba5969c48e521629ird
API 密钥配置
腾讯云 → DNSPod 「账号中心 → API密钥」,选择 腾讯云 API 密钥 → 新建密钥,记下 SecretId 和 SecretKey
SecretId:A******441jEXeZJp9LL8kA22Bl9k8******
SecretKey:B*****FG4e1B7u52LSbcTHzdDl7******
自动化配置
可使用 GitHub Actions 工作流来触发定时任务完成证书的自动签发、上传与更新(省去云服务器)
创建 GitHub 仓库
在 GitHub 创建一个仓库
设置仓库 secrets
仓库 → Settings → Secrets and variables → Actions → New repository secret,分别添加:
Secret 名称
内容
来源
DP_Id
DNSPod ID
DNSPod 控制台
DP_Key
DNSPod Token
DNSPod 控制台
QCLOUD_SECRET_ID
腾讯云 SecretId
腾讯云访问管理
QCLOUD_SECRET_KEY
腾讯云 SecretKey
腾讯云访问管理
DOMAIN
你的备案域名
备案域名
ACME_EMAIL
随意一个真实邮箱
手动填写
创建工作流
仓库首页 → Add file → Create a new file: .github/workflows/acme-auto.yml,内容见 脚本下载 。
DOMAIN 与 www.DOMAIN 必须已经指向 CDN,且外网可访问到一张同时覆盖这两个域名的证书。工作流会验证证书有效期和域名覆盖范围;检查失败时会停止而不是盲目签发,避免网络或 CDN 配置故障导致重复申请。
运行与测试
已有 HTTPS 证书时 :仓库 → Actions → 选中左侧 Auto SSL Renew & Deploy → 点击右侧 Run workflow。正常结果应为“当前证书剩余超过 21 天”,不签发也不上传。
首次部署、尚未配置 CDN 证书 ,或需要人工故障恢复时:点击 Run workflow 后勾选 force_issue,再运行。该选项会跳过到期检查并立即签发,日常请不要勾选。
成功签发后,查看腾讯云:
CI
效率