跳转至

用腾讯云 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-hongkongap-singapore):无强制下载,不需备案
    • 国内区域(ap-shanghaiap-chengdu 等):默认强制下载,必须绑备案域名才能解除
  • 访问权限:公有读私有写

建好后:

  • 基础配置 → 静态网站 → 启用
  • 索引文档:index.html
  • 错误文档:404.html

第二步:本地 Zensical 项目

mkdir wiki && cd wiki
uv init --no-readme
uv add zensical
mkdir docs
echo "# 首页" > docs/index.md

最简 mkdocs.yml

site_name: 我的 Wiki
theme:
  name: material
  language: zh
nav:
  - 首页: index.md

构建 + 预览:

uv run zensical build   # 产物在 site/
uv run zensical serve   # 本地预览 http://127.0.0.1:8000

第三步:写部署脚本同步到 COS

coscmd

uv add --dev 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 看到响应正常:

HTTP/1.1 200 OK
Content-Type: text/html

curl -i(GET)看到 GET 请求多了两行:

Content-Disposition: attachment
x-cos-force-download: true

这是腾讯云为了响应监管"未备案不能搭网站"的策略:国内区域 bucket 通过 *.myqcloud.com 域名访问 HTML 一律强制下载,跟 bucket 配置无关。解除方式只有:

  • 绑定中国大陆 ICP 备案过的自定义域名,通过那个域名访问就豁免
  • 或换到海外区域 bucket

我选了"绑备案域名"。

第五步:绑定自定义域名

前提:liuhetian.work 已经 ICP 备案。注意备案对象是主域 liuhetian.work,不是 www.liuhetian.work,所有子域(wikiapi 等)自动覆盖。

绑定要做两件事,缺一不可 —— 这是我反复踩坑的关键:

6.1 在 COS 控制台添加自定义源站域名

控制台 → bucket → 域名与传输管理 → 自定义源站域名 → 添加

  • 域名:wiki.liuhetian.work
  • 源站类型:必须选 静态网站源站(不是默认源站!否则访问 / 会触发 ListBucket API,返回 AccessDenied
  • 是否启用 HTTPS:先关,证书后面再补

保存后,控制台会展示一个 CNAME 目标

wiki-1307341066.cos.ap-chengdu.myqcloud.com

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"]}],
    },
)
  1. 重要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:

7 16 * * * "/home/lht/.acme.sh"/acme.sh --cron --home "/home/lht/.acme.sh" > /dev/null

每天 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

完整代码

下面是本项目所有文件的完整内容,全部折叠默认收起。

项目结构(一览)
zensical-wiki/
├── docs/                           ← 文档内容
│   ├── index.md
│   └── posts/cos-wiki-deploy.md
├── deploy-cert/                    ← 证书自动化
│   ├── deploy_cert_to_cos.py
│   └── install.sh
├── mkdocs.yml                      ← 站点配置
├── deploy.sh                       ← 内容部署脚本
├── .env                            ← 凭证(不入版本控制)
├── .gitignore
└── pyproject.toml                  ← uv 项目文件
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
.gitignore — 关键排除项
# Python
__pycache__/
*.py[oc]

# uv
.venv

# 构建产物
site/

# 凭证(务必排除!)
.env
.env.*
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 一行,证书永久无需操心。


  1. Zensical —— Material for MkDocs 团队的下一代静态站点生成器,Rust 内核 + 兼容 mkdocs.yml。 

  2. Let's Encrypt - Decreasing Certificate Lifetimes to 45 Days —— 官方公告,2026 年 5 月 13 日起默认证书有效期从 90 天缩短到 45 天。 

  3. acme.sh DNS API 列表 —— 各家 DNS 服务商对应的 acme.sh 插件名(Cloudflare/阿里云/AWS Route53 等都有)。 

  4. 腾讯云 COS Go SDK 测试代码 —— BucketPutDomainCertificateOptions 的标准结构体定义,明确给出了 CertificateInfoCertType + CustomCert 的嵌套层级。