用腾讯云 COS 部署 wiki + Let's Encrypt 自动续期 SSL
这篇记录我把这个 wiki 从无到有部署到腾讯云 COS、并配上全自动 HTTPS 证书续期的完整过程。静态站点生成器选的是 Zensical1,包含中途踩到的几个非常隐蔽的坑(国内 bucket 强制下载、CNAME 指错域名、reloadcmd 参数格式等)。
为什么不用纯服务器方案
最直接的方案当然是:服务器跑 nginx 或 Caddy,rsync 一份 HTML 上去。问题在于:
- 我那台服务器带宽小,未来还想放更多项目和作品集
- Caddy 反代 COS 也得让流量经过服务器,瓶颈一样
所以选 COS 直出 + 自定义域名:用户访问 wiki,DNS 直接指到 COS,服务器完全不在请求链路上。服务器只用来跑证书续期脚本。
整体架构
两条链路完全分开 —— 这是理解整个方案的关键:
flowchart TB
subgraph 流量["用户访问链路(线上流量)"]
U[浏览器]
D[wiki.liuhetian.work]
C[COS bucket<br>带绑定证书]
U -- HTTPS 443 --> D
D -. DNS CNAME .-> C
end
subgraph 续期["证书续期链路(每 60 天自动)"]
A[服务器 acme.sh<br>cron 触发]
L[Let's Encrypt]
P[DNSPod API]
T[腾讯云 COS API]
A -- 1.申请 --> L
L -- 2.挑战 --> A
A -- 3.加 TXT --> P
L -- 4.查 TXT --> P
L -- 5.签证书 --> A
A -- 6.推证书 --> T
T -- 7.更新绑定 --> C
end
服务器只在续期链路里出现,用户访问完全不经过它。
第一步:建 COS bucket
腾讯云控制台 → 对象存储 → 创建 bucket:
- 名称:随便起,比如
wiki-1307341066 - 地域:这里有个关键选择(详见后面"强制下载"章节)
- 海外区域(
ap-hongkong、ap-singapore):无强制下载,不需备案 - 国内区域(
ap-shanghai、ap-chengdu等):默认强制下载,必须绑备案域名才能解除
- 海外区域(
- 访问权限:公有读私有写
建好后:
- 基础配置 → 静态网站 → 启用
- 索引文档:
index.html - 错误文档:
404.html
第二步:本地 Zensical 项目
最简 mkdocs.yml:
构建 + 预览:
第三步:写部署脚本同步到 COS
装 coscmd:
写 .env(记得加进 .gitignore):
COS_BUCKET=wiki-1307341066
COS_REGION=ap-chengdu
COS_SECRET_ID=AKID...
COS_SECRET_KEY=...
COS_DOMAIN=wiki.liuhetian.work
deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_DIR"
set -a; source .env; set +a
COSCMD="$PROJECT_DIR/.venv/bin/coscmd"
"$COSCMD" config -a "$COS_SECRET_ID" -s "$COS_SECRET_KEY" \
-b "$COS_BUCKET" -r "$COS_REGION"
uv run zensical build
cd "$PROJECT_DIR/site"
"$COSCMD" upload -rs ./ /
踩坑
最早我写的是 cd site && uv run --directory .. coscmd upload -rs ./ /,但 uv run --directory 会把工作目录切回项目根,导致 coscmd 把整个项目(包括 .venv/ 和 .env!)都往 COS 传。绝对不能这样写。正确做法是 cd 进 site 后直接用绝对路径调用 venv 里的 coscmd。
第四步:踩坑 —— 国内 bucket 的强制下载
第一次访问 bucket 默认域名时,浏览器直接把 HTML 当成文件下载。curl -I 看到响应正常:
但 curl -i(GET)看到 GET 请求多了两行:
这是腾讯云为了响应监管"未备案不能搭网站"的策略:国内区域 bucket 通过 *.myqcloud.com 域名访问 HTML 一律强制下载,跟 bucket 配置无关。解除方式只有:
- 绑定中国大陆 ICP 备案过的自定义域名,通过那个域名访问就豁免
- 或换到海外区域 bucket
我选了"绑备案域名"。
第五步:绑定自定义域名
前提:liuhetian.work 已经 ICP 备案。注意备案对象是主域 liuhetian.work,不是 www.liuhetian.work,所有子域(wiki、api 等)自动覆盖。
绑定要做两件事,缺一不可 —— 这是我反复踩坑的关键:
6.1 在 COS 控制台添加自定义源站域名
控制台 → bucket → 域名与传输管理 → 自定义源站域名 → 添加:
- 域名:
wiki.liuhetian.work - 源站类型:必须选 静态网站源站(不是默认源站!否则访问
/会触发 ListBucket API,返回AccessDenied) - 是否启用 HTTPS:先关,证书后面再补
保存后,控制台会展示一个 CNAME 目标:
6.2 在 DNSPod 加一条 CNAME 记录
| 主机 | 类型 | 值 |
|---|---|---|
wiki |
CNAME | wiki-1307341066.cos.ap-chengdu.myqcloud.com |
等 5-30 分钟节点同步,访问 http://wiki.liuhetian.work/ 不会再有强制下载头。
6.3 容易绕进去的两个概念
CNAME 目标 和 源站类型 是独立的两件事
CNAME 目标始终是 <bucket>.cos.<region>.myqcloud.com 这一个值,不管"源站类型"选什么,腾讯云给的 CNAME 目标都不变。
"源站类型"是后台的逻辑配置,决定 COS 接到请求后按什么模式处理:
- 默认源站:API 模式,访问
/等于 ListBucket,匿名 → 403 AccessDenied - 静态网站源站:把请求映射到
index.html,访问/直接出首页
两个开关分别在两个地方配,都要对才行。我最早只改了 DNS 没改"源站类型",结果一直 AccessDenied。
为什么 CNAME 不能指 cos-website.* 域名
<bucket>.cos.<region>.myqcloud.com 是 COS 主入口,所有 bucket 路由都从这里进;<bucket>.cos-website.<region>.myqcloud.com 是辅助域名,只在你直接用这个域名访问时走静态网站模式,后台不接受作为自定义 CNAME 目标。腾讯云在主入口查 Host 头 → 查后台"该域名绑了哪个 bucket、什么模式" → 按那个模式处理。
push 上传走的根本不是自定义域名
coscmd / SDK 推内容时,URL 永远是 <bucket>.cos.<region>.myqcloud.com(API 域名 + SigV4 签名),跟 wiki.liuhetian.work 无关。push 走 API 域名,访问走自定义域名,两条独立链路。试图用自定义域名做 PUT 会被拒(自定义域名走静态网站模式,不接受写鉴权)。
第六步:HTTPS 自动化(重头戏)
证书有两种来源:
| 方案 | 有效期 | 续期方式 |
|---|---|---|
| 腾讯云免费 DV 证书 | 3 个月(不是 1 年) | 控制台手动点"快速续期" + 手动重新绑定 |
| Let's Encrypt + acme.sh | 90 天(2026 年 5 月起默认 45 天2) | acme.sh 自带 cron,全自动 |
我选 Let's Encrypt + acme.sh。但有个意外:
acme.sh 没有内置腾讯云 hook
acme.sh 的 80 个 deploy hook 里有阿里云 CDN、七牛云,但没有任何腾讯云产品。意味着"签证书"是自动的,但"把证书推到 COS"要自己写脚本。
acme.sh + DNS-01 挑战
# 装 acme.sh,切默认 CA 为 Let's Encrypt
curl https://get.acme.sh | sh -s email=you@example.com
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
# 用 DNSPod 插件做 DNS-01(其他 DNS 服务商对应换插件名[^acme-dns])
export DP_Id=634183
export DP_Key=cac428...
~/.acme.sh/acme.sh --issue --dns dns_dp -d wiki.liuhetian.work --keylength ec-256
DNS-01 挑战是什么
sequenceDiagram
participant 你 as acme.sh
participant LE as Let's Encrypt
participant DP as DNSPod
你->>LE: 我要 wiki.liuhetian.work 的证书
LE->>你: 在 _acme-challenge.wiki.liuhetian.work 的 TXT 里写 xyz789
你->>DP: 调 DNSPod API 加 TXT 记录
LE->>DP: 查 _acme-challenge.wiki.liuhetian.work
DP-->>LE: xyz789
LE->>你: 验证通过,发证书
你->>DP: 删除 TXT 验证记录
实质上是临时在 liuhetian.work 下创建一个四级子域名 _acme-challenge.wiki.liuhetian.work 的 TXT 记录,记录值就是 Let's Encrypt 出的题。验证完立刻删。
为什么必须 DNS-01 不能 HTTP-01:HTTP-01 要求域名指向的 80 端口能放挑战文件,但 wiki.liuhetian.work 指向的是 COS,不是我们能控制的 web 服务器。
自己写 hook 推证书到 COS
deploy_cert_to_cos.py:
import os
from pathlib import Path
from qcloud_cos import CosConfig, CosS3Client
cert = Path(os.environ["CERT_FULLCHAIN_PATH"]).read_text() # acme.sh 注入
key = Path(os.environ["CERT_KEY_PATH"]).read_text()
client = CosS3Client(CosConfig(
Region=os.environ["COS_REGION"],
SecretId=os.environ["TENCENT_SECRET_ID"],
SecretKey=os.environ["TENCENT_SECRET_KEY"],
Scheme="https",
))
client.put_bucket_domain_certificate(
Bucket=os.environ["COS_BUCKET"],
DomainCertificateConfiguration={
"CertificateInfo": { # (1)
"CertType": "CustomCert",
"CustomCert": {"Cert": cert, "PrivateKey": key},
},
"DomainList": [{"DomainName": os.environ["COS_DOMAIN"]}],
},
)
- 重要:
CertificateInfo这一层包装绝对不能漏。我第一版按 Python SDK 测试文件示例写少了这层,腾讯云返回InvalidArgument。真正的 XML 结构以 Go SDK 测试代码为准4。
注册 acme.sh reloadcmd
~/.acme.sh/acme.sh --install-cert -d wiki.liuhetian.work --ecc \
--reloadcmd "set -a; . '/path/to/.env'; set +a; \
export TENCENT_SECRET_ID=\"\$COS_SECRET_ID\" \
TENCENT_SECRET_KEY=\"\$COS_SECRET_KEY\"; \
/path/to/.venv/bin/python /path/to/deploy_cert_to_cos.py"
acme.sh 把这条命令存到 ~/.acme.sh/wiki.liuhetian.work_ecc/wiki.liuhetian.work.conf,每次续期成功后自动跑。
第七步:永久自动续期循环
acme.sh 装的时候自动加了 cron:
每天 16:07 跑一次:
flowchart LR
Cron[每天 16:07 cron] --> Check{证书剩余<br>≤ 30 天?}
Check -- 否 --> Sleep[继续睡到明天]
Check -- 是 --> Issue[acme.sh 发起<br>DNS-01 挑战]
Issue --> Hook[reloadcmd 触发<br>Python 脚本]
Hook --> Push[推到 COS 绑定]
Push --> Sleep
90 天证书每 60 天自动续一次。你不需要做任何事,包括 5 月之后 Let's Encrypt 默认切到 45 天证书,acme.sh 也会自动改成 30 天续一次。
几个值得记的关键认知
DNS 记录"值"是什么
DNS 不是"动作",是个 key-value 表,查表得到值:
| 记录类型 | 值 |
|---|---|
| A | IPv4 地址(43.134.81.54) |
| AAAA | IPv6 地址 |
| CNAME | 另一个域名(xxx.cos.ap-chengdu.myqcloud.com) |
| TXT | 任意字符串(ACME 挑战值 PpWCFq...、SPF、DMARC 等用这个) |
| MX | 邮件服务器 |
| NS | 权威 DNS 服务器 |
ACME 挑战必须用 TXT,因为挑战值是随机字符串 —— 既不是 IP 也不是域名。
服务器只是 runner,不参与流量
这个方案里我的服务器(43.134.81.54)从头到尾不在用户请求链路上。它只是个"定时跑续期脚本的机器",跟 GitHub Actions runner 完全等价。未来想换成 GitHub Actions,Python 脚本能直接复用,只是触发方式从 cron 换成 workflow。
Content-Disposition 头是 GET 才加
腾讯云的 x-cos-force-download 只在 GET 请求时追加,HEAD 请求看不到。所以验证 bucket 是否被国内强制下载策略影响,必须 curl -i(发 GET),不能只 curl -I(HEAD)。
总成本
| 项 | 成本 |
|---|---|
| COS 存储 + 流量 | 个人 wiki 流量基本免费 |
| 域名 | 已有,不算 |
| 证书 | Let's Encrypt 免费 |
| acme.sh | 开源 |
| 服务器 | 已有,且只跑 cron |
| 配置时间 | 第一次约 1 小时(含踩坑) |
后续维护:0。证书到期前 30 天 acme.sh 自动续 + 自动推到 COS。一年之内不需要登录腾讯云控制台。
完整文件清单
zensical-wiki/
├── docs/
│ ├── index.md
│ └── posts/cos-wiki-deploy.md ← 本文
├── deploy-cert/
│ ├── deploy_cert_to_cos.py ← 续期 hook
│ └── install.sh ← 首次配置脚本
├── mkdocs.yml
├── deploy.sh ← 内容部署脚本
├── .env ← 凭证(不入版本控制)
├── .gitignore
└── pyproject.toml
完整代码
下面是本项目所有文件的完整内容,全部折叠默认收起。
项目结构(一览)
mkdocs.yml — Zensical 站点配置
site_name: liuhetian's wiki
site_url: https://wiki.liuhetian.work/
theme:
name: material
language: zh
features:
- navigation.tabs
- navigation.indexes
- content.code.copy
- content.code.annotate
markdown_extensions:
- admonition
- attr_list
- footnotes
- md_in_html
- pymdownx.details
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.tabbed:
alternate_style: true
nav:
- 首页: index.md
- 文章:
- 用腾讯云 COS 部署 wiki: posts/cos-wiki-deploy.md
- 工具:
- 语法速查: syntax.md
.env 模板 — 凭证占位(绝对不要 commit)
# ----- 腾讯云 COS:部署 wiki 内容 + 给证书 hook 用 -----
COS_BUCKET=wiki-1307341066
COS_APPID=1307341066
COS_REGION=ap-chengdu
COS_SECRET_ID=AKID...你的 SecretId
COS_SECRET_KEY=你的 SecretKey
COS_DOMAIN=wiki.liuhetian.work
# ----- acme.sh:自动 SSL 证书 -----
ACME_EMAIL=you@example.com
# DNSPod API Token:去 https://console.dnspod.cn/account/token 创建后填
DP_Id=634183
DP_Key=你的 DNSPod Token
deploy.sh — 构建 + 同步内容到 COS
#!/usr/bin/env bash
# 构建 + 同步到腾讯云 COS
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_DIR"
# 1. 读凭证
set -a
source .env
set +a
COSCMD="$PROJECT_DIR/.venv/bin/coscmd"
# 2. 写 coscmd 配置
"$COSCMD" config \
-a "$COS_SECRET_ID" \
-s "$COS_SECRET_KEY" \
-b "$COS_BUCKET" \
-r "$COS_REGION"
# 3. 构建静态站点
uv run zensical build
# 4. 同步 site/ 到 bucket 根
# 注意:必须 cd 到 site/ 后直接用绝对路径的 coscmd,
# 不能用 `uv run --directory` —— 它会把 CWD 切回 project root,
# 导致 coscmd 把整个项目(含 .venv、.env!)都往 COS 传。
cd "$PROJECT_DIR/site"
"$COSCMD" upload -rs ./ /
echo
echo "✅ 部署完成"
echo "访问: https://${COS_DOMAIN}/"
deploy-cert/install.sh — 首次配置 acme.sh + 注册 hook
#!/usr/bin/env bash
# 首次配置:装 acme.sh、申请证书、注册自动续期 hook
# 之后续期由 acme.sh 内置 cron 触发,无需再跑此脚本
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
set -a
source .env
set +a
# 必填项校验
need() {
if [ -z "${!1:-}" ]; then
echo "ERROR: .env 缺少 $1" >&2
exit 1
fi
}
need DP_Id
need DP_Key
need ACME_EMAIL
need COS_SECRET_ID
need COS_SECRET_KEY
need COS_BUCKET
need COS_REGION
need COS_DOMAIN
ACME="$HOME/.acme.sh/acme.sh"
PYTHON="$PROJECT_DIR/.venv/bin/python"
DEPLOY_SCRIPT="$PROJECT_DIR/deploy-cert/deploy_cert_to_cos.py"
# 1. 装 acme.sh
if [ ! -x "$ACME" ]; then
echo ">>> 安装 acme.sh ..."
curl -fsSL https://get.acme.sh | sh -s email="$ACME_EMAIL"
fi
# 2. 切到 Let's Encrypt
"$ACME" --set-default-ca --server letsencrypt
# 3. DNS-01 申请证书
echo ">>> 申请证书 for $COS_DOMAIN ..."
"$ACME" --issue --dns dns_dp -d "$COS_DOMAIN" --keylength ec-256
# 4. 注册 reloadcmd(续期成功后自动推到 COS)
RELOADCMD="set -a; . '$PROJECT_DIR/.env'; set +a; \
export TENCENT_SECRET_ID=\"\$COS_SECRET_ID\" TENCENT_SECRET_KEY=\"\$COS_SECRET_KEY\"; \
'$PYTHON' '$DEPLOY_SCRIPT'"
"$ACME" --install-cert -d "$COS_DOMAIN" --ecc --reloadcmd "$RELOADCMD"
# 5. 首次强制跑一遍 reloadcmd
echo ">>> 首次部署证书到 COS ..."
"$ACME" --renew -d "$COS_DOMAIN" --ecc --force
echo
echo "✅ 完成"
crontab -l 2>/dev/null | grep -i acme || true
deploy-cert/deploy_cert_to_cos.py — acme.sh 调用的证书推送脚本
#!/usr/bin/env python3
"""
acme.sh 续期后调用此脚本,把新证书绑定到 COS 自定义域名。
acme.sh 通过 reloadcmd 调用时会注入:
CERT_FULLCHAIN_PATH / CERT_KEY_PATH / Le_Domain 等
本脚本额外要求的环境变量(由 .env 提供):
TENCENT_SECRET_ID / TENCENT_SECRET_KEY
COS_REGION / COS_BUCKET / COS_DOMAIN
"""
import os
import sys
from pathlib import Path
from qcloud_cos import CosConfig, CosS3Client
def must_env(name: str) -> str:
v = os.environ.get(name)
if not v:
sys.exit(f"ERROR: missing required env var: {name}")
return v
def main() -> None:
secret_id = must_env("TENCENT_SECRET_ID")
secret_key = must_env("TENCENT_SECRET_KEY")
region = must_env("COS_REGION")
bucket = must_env("COS_BUCKET")
domain = must_env("COS_DOMAIN")
cert_path = os.environ.get("CERT_FULLCHAIN_PATH") or os.environ.get("CERT_PATH")
key_path = os.environ.get("CERT_KEY_PATH")
if not cert_path or not key_path:
sys.exit(
"ERROR: CERT_FULLCHAIN_PATH and CERT_KEY_PATH must be set "
"(acme.sh injects them automatically via --reloadcmd)"
)
cert = Path(cert_path).read_text()
key = Path(key_path).read_text()
client = CosS3Client(CosConfig(
Region=region,
SecretId=secret_id,
SecretKey=secret_key,
Scheme="https",
))
client.put_bucket_domain_certificate(
Bucket=bucket,
DomainCertificateConfiguration={
"CertificateInfo": {
"CertType": "CustomCert",
"CustomCert": {"Cert": cert, "PrivateKey": key},
},
"DomainList": [{"DomainName": domain}],
},
)
print(f"OK: bound new cert to {domain} on bucket {bucket}")
if __name__ == "__main__":
main()
pyproject.toml — uv 依赖清单
[project]
name = "zensical-wiki"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"zensical>=0.0.46",
]
[dependency-groups]
dev = [
"coscmd>=1.9",
]
其中 coscmd 会带来 cos-python-sdk-v5 作为传递依赖,这是 deploy_cert_to_cos.py 用到的。
一次性安装命令(拷下来直接跑)
# 1. 项目初始化
mkdir wiki && cd wiki
uv init --no-readme
uv add zensical
uv add --dev coscmd
# 2. 把上面所有文件写好 + 填好 .env
# 3. 首次部署内容
./deploy.sh
# 4. 首次配置 SSL 自动化
./deploy-cert/install.sh
之后每次写完文章只跑 ./deploy.sh 一行,证书永久无需操心。
-
Zensical —— Material for MkDocs 团队的下一代静态站点生成器,Rust 内核 + 兼容
mkdocs.yml。 ↩ -
Let's Encrypt - Decreasing Certificate Lifetimes to 45 Days —— 官方公告,2026 年 5 月 13 日起默认证书有效期从 90 天缩短到 45 天。 ↩
-
acme.sh DNS API 列表 —— 各家 DNS 服务商对应的 acme.sh 插件名(Cloudflare/阿里云/AWS Route53 等都有)。 ↩
-
腾讯云 COS Go SDK 测试代码 ——
BucketPutDomainCertificateOptions的标准结构体定义,明确给出了CertificateInfo→CertType+CustomCert的嵌套层级。 ↩