别再跟证书续期死磕了:Caddy 全自动 HTTPS + Nginx 风格日志 + Docker Compose 一条龙部署教程
看完这篇,你能得到一套可以直接抄走的生产配置: 域名写进配置就自动申请、自动续期 HTTPS 证书(含泛域名),永远不用再碰 cron; 访问日志从「看不懂的 JSON」改造成熟悉的 Nginx/Apache 纯文本格式,还能自动轮转; 全部服务用 Docker Compose 编排,改配置不断线热加载。
0. 先说痛点:为什么是 Caddy
运维过网站的朋友,大概率都被下面几件事折磨过:
- Let's Encrypt 证书只有 90 天有效期。用 certbot / acme.sh 申请完,还得写定时任务、写部署钩子,钩子漏写一个
reload,证书续了但服务还在用旧的,到期当天网站直接打不开。 - HTTP-01 验证要求 80 端口能从公网访问。源站躲在 CDN 后面、或者服务在内网/NAT 后面,根本验证不过;想申请泛域名证书
*.example.com,还只能走 DNS-01。 - Caddy 默认的访问日志是一大坨 JSON,机器友好但人不友好,想
tail -f看一眼谁在扫站、想丢给 GoAccess/fail2ban,都得先写脚本解析。 - 官方 Caddy 镜像不带 DNS 插件、不带日志格式化插件,很多人卡在「插件到底怎么装」这一步。
- 容器一重建证书就没了,重新申请几次就撞上 Let's Encrypt 的频率限制。
这篇教程用一套经过实盘验证的配置,把上面五个问题一次性全部解决。技术栈非常克制:Caddy 2 + 两个官方生态插件 + Docker Compose。
1. 最终效果与目录结构
先看最终的项目布局,后面每一步都对应这里的文件:
caddy/
├── Dockerfile # 用 xcaddy 定制镜像,编入两个插件
├── docker-compose.yml # 编排文件
├── .env # 腾讯云 API 密钥(不进 Git!)
├── pull.sh # 拉最新基础镜像并重新构建(升级用)
├── reload.sh # 不重启容器、不断连接口热加载配置
├── conf/
│ ├── Caddyfile # 主配置:全局选项 + 公共片段 + 兜底规则
│ └── Caddyfile.d/ # 每个站点一个文件,互不干扰
│ ├── index.conf # 主站(静态页 + 裸域 301 跳 www)
│ └── app.conf # 反向代理示例
├── data/ # 【关键】证书和运行数据,自动生成,必须持久化
├── log/ # 访问日志输出目录,自动生成
└── html/ # 静态网站文件
└── example.com/
└── index.html先把目录骨架建出来:
mkdir -p caddy/conf/Caddyfile.d caddy/data caddy/log caddy/html/example.com
cd caddy后面所有命令默认都在这个
caddy/目录下执行。
2. 30 秒搞懂原理:Caddy 是怎么把证书「变」出来的
2.1 自动 HTTPS 的完整链路
Caddy 是世界上第一个默认开启自动 HTTPS 的 Web 服务器。只要你的站点块里写了域名,它启动后会自动完成下面整条链路,全程无需人工干预:
配置里发现域名 example.com
│
▼
向 CA(默认 Let's Encrypt,也支持 ZeroSSL)发起 ACME 注册
│
▼
完成域名所有权验证(本方案用 DNS-01)
│
▼
拿到证书,存进 /data 目录,自动开启 HTTPS,并把 80 端口请求 301 到 443
│
▼
证书用到生命周期 2/3 时自动续期(90 天的证书 ≈ 剩余 30 天时开始)
│
▼
续期成功后进程内热替换,连接不中断;失败则自动退避重试两个数字记一下:
- Let's Encrypt 证书有效期 90 天;
- Caddy 默认的续期窗口比例
renewal_window_ratio是 1/3,也就是证书剩余 1/3 生命周期时开始持续尝试续期,换算下来大约是到期前 30 天就动手,留足了失败重试的缓冲。
除此之外,OCSP Stapling(封套)、现代 TLS 版本与加密套件、HTTP→HTTPS 跳转这些在 Nginx 里要手写一大段的东西,Caddy 默认全开。
2.2 三种 ACME 验证方式,为什么本方案选 DNS-01
| 验证方式 | 验证原理 | 需要公网入站端口 | 支持泛域名 | 适合场景 |
|---|---|---|---|---|
| HTTP-01 | CA 访问 http://你的域名/.well-known/... | 需要 80 端口 | 不支持 | 域名直接解析到本机 |
| TLS-ALPN-01 | TLS 握手中完成验证 | 需要 443 端口 | 不支持 | 443 独占的场景 |
| DNS-01 | 调用 DNS 服务商 API 增删一条 TXT 记录 | 完全不需要 | 支持 | CDN 回源、内网/NAT、泛域名,本方案采用 |
DNS-01 的爽点在于:证书申请完全不依赖任何入站流量,源站藏在 CDN 后面、甚至人在家里的内网,都能签出公网信任的证书;而且只有 DNS-01 能签发 *.example.com 泛域名证书。
代价是要给 Caddy 一个能操作 DNS 的 API 密钥,并且 DNS 提供商插件不在官方镜像里,需要自己定制镜像——这正是第四步要做的事。
3. 准备工作
3.1 服务器环境
- 一台 Linux 服务器,已安装 Docker Engine 与 Compose 插件(
docker compose version能输出版本即可,注意是带横杠的新版命令docker compose); - 安全组 / 防火墙放行 80/TCP、443/TCP、443/UDP(UDP 是给 HTTP/3 用的,后面会讲);
- 服务器时间准确(DNS-01 和证书校验都对时间敏感),
timedatectl确认已开启 NTP 对时。
3.2 域名与 DNS 解析
本方案以域名托管在腾讯云 DNSPod 为例(其他厂商在 4.3 节给出替换方法)。
到 DNSPod 控制台添加解析记录:
| 主机记录 | 记录类型 | 记录值 | 说明 |
|---|---|---|---|
@ | A | 你的服务器公网 IP | 主站 example.com |
www | A | 你的服务器公网 IP | www.example.com |
app | A | 你的服务器公网 IP | 反代示例 app.example.com |
* | A | 你的服务器公网 IP | 泛解析,兜底所有其他子域名 |
3.3 创建腾讯云 API 密钥
进入腾讯云控制台「访问管理 → 用户列表」,强烈建议新建一个子用户,只授予 DNSPod 相关权限(如 QcloudDNSPodFullAccess),不要用主账号密钥。拿到该子用户的 SecretId 和 SecretKey,第六步会通过环境变量传给 Caddy。
安全第一:密钥只放
.env文件,并把.env加进.gitignore,永远不要提交到代码仓库。
4. 第一步:定制 Caddy 镜像(把两个插件编进去)
4.1 为什么官方镜像不够用
官方 caddy:2-alpine 镜像只包含核心功能,我们需要额外编入两个插件:
| 插件 | 仓库地址 | 解决什么问题 |
|---|---|---|
| tencentcloud | github.com/caddy-dns/tencentcloud | 调用腾讯云 DNSPod API 完成 DNS-01 验证,自动签发/续期证书,泛域名全靠它 |
| transform-encoder | github.com/caddyserver/transform-encoder | 自定义日志模板,把 JSON 访问日志改写成 Nginx/Apache Combined Log 风格的纯文本 |
Caddy 官方提供了专门的定制构建工具 xcaddy,配合 Docker 的多阶段构建,我们可以在「编译阶段」把插件编进去,最终运行镜像仍然是干净的官方 alpine 镜像,不携带任何编译工具链。
4.2 编写 Dockerfile
在 caddy/ 目录下创建 Dockerfile:
# ---------- 阶段一:用 xcaddy 编译带插件的 Caddy ----------
FROM caddy:2-builder-alpine AS builder
# Go 模块代理走国内镜像源,海外机器可删掉这一行
ENV GOPROXY=https://mirrors.tencent.com/go/
RUN xcaddy build \
--with github.com/caddyserver/transform-encoder \
--with github.com/caddy-dns/tencentcloud
# ---------- 阶段二:干净的运行镜像 ----------
FROM caddy:2-alpine
# Alpine 软件源换成腾讯镜像并安装 tzdata(保证容器时区正确),海外机器可删掉 sed 那一段
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.cloud.tencent.com/g' /etc/apk/repositories && \
apk add --no-cache tzdata
# 把阶段一编译好的二进制拷进来,覆盖官方原版
COPY --from=builder /usr/bin/caddy /usr/bin/caddy逐行解释三个关键点:
caddy:2-builder-alpine是官方自带 Go 编译环境和 xcaddy 的构建镜像,--with参数后面接插件路径,想加几个插件就写几行;- 第二阶段
FROM caddy:2-alpine重新开始,只COPY一个编译产物,所以最终镜像体积和官方镜像几乎一样大; tzdata+ 后面的TZ=Asia/Shanghai保证容器内时间是东八区,排查问题时日志时间不会对不上。
4.3 用其他 DNS 厂商怎么办
把 Dockerfile 里的 --with 和第六步 Caddyfile 里的 acme_dns 换成对应插件即可,套路完全一样:
| DNS 厂商 | 插件路径 |
|---|---|
| 阿里云 DNS | github.com/caddy-dns/alidns |
| Cloudflare | github.com/caddy-dns/cloudflare |
| 华为云 DNS | github.com/caddy-dns/huaweicloud |
| 更多厂商 | 见 Caddy 官方组织 https://github.com/caddy-dns |
5. 第二步:编写 docker-compose.yml
5.1 密钥文件 .env
先在 caddy/ 目录创建 .env,填入 3.3 节拿到的密钥:
cat > .env <<'EOF'
TENCENTCLOUD_SECRET_ID=你的SecretId
TENCENTCLOUD_SECRET_KEY=你的SecretKey
EOF
chmod 600 .env
echo ".env" >> .gitignore5.2 docker-compose.yml
services:
caddy:
build: . # 用同目录的 Dockerfile 构建定制镜像
restart: always # 开机自启、崩溃自动拉起
container_name: caddy
ports:
- 80:80
- 443:443
- 443:443/udp # UDP 端口映射 = 开启 HTTP/3(QUIC)
volumes:
- ./conf:/etc/caddy # 配置目录,改配置不用重新构建镜像
- ./data:/data # 【最重要】证书持久化,容器重建不丢证书
- ./log:/srv/log # 访问日志写到宿主机,方便采集和归档
- ./html:/srv/html # 静态站点文件
environment:
- TZ=Asia/Shanghai
- HTML_PATH=/srv/html
# 从 .env 文件读取,Compose 会自动完成变量替换
- TENCENTCLOUD_SECRET_ID=${TENCENTCLOUD_SECRET_ID}
- TENCENTCLOUD_SECRET_KEY=${TENCENTCLOUD_SECRET_KEY}
extra_hosts:
# 允许容器内用 host.docker.internal 访问宿主机上的服务(反代宿主机服务时用)
- host.docker.internal:host-gateway重点解释三个最容易被忽略的地方:
./data:/data是整套方案的命门。Caddy 把证书、私钥、账户信息全部存在容器内的/data。没有这个挂载,每次docker compose up --force-recreate都是一张「新机器」,会把所有证书重新申请一遍,几次下来就撞 Let's Encrypt 频率限制(详见第 11 节)。443:443/udp:HTTP/3 基于 QUIC,跑在 UDP 上。映射了它,Caddy 会在响应头里自动宣告 HTTP/3 支持,浏览器下次访问直接走 HTTP/3,不需要任何额外配置。- 配置目录挂载:之后所有改动都发生在宿主机的
./conf,容器内即时可见,配合第八步的热加载,改配置连容器都不用重启。
如果你的 Caddy 需要反向代理到其他 Compose 项目里的容器(比如 Gitea、Vaultwarden),把它们放到同一个外部 Docker 网络里,然后在本服务加
networks: [webnet]即可,站点配置里直接用「容器名:端口」当上游。
6. 第三步:主配置 Caddyfile(证书 + 日志核心)
创建 conf/Caddyfile,这是全文最核心的文件,我们逐段拆解:
{
# ===== ① DNS-01:用腾讯云 API 自动完成证书验证 =====
acme_dns tencentcloud {
secret_id {env.TENCENTCLOUD_SECRET_ID}
secret_key {env.TENCENTCLOUD_SECRET_KEY}
}
# ===== ② 运行日志:输出到容器标准输出,JSON 格式 =====
# 启动信息、证书申请/续期记录、报错都在这里,用 docker logs 查看
# 访问日志被单独分流到文件,所以这里把访问日志命名空间排除掉
log default {
exclude http.log.access
format json
}
# ===== ③ 访问日志:Nginx Combined Log 风格纯文本,写文件并轮转 =====
log access-format {
include http.log.access
output file /srv/log/access.log {
roll_keep_for 180d # 轮转后的历史日志保留 180 天
}
format transform `{request>remote_ip} - [{ts}] ({request>host}) "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
time_format iso8601
}
}
}
# ===== ④ 公共片段:所有站点复用,写一次就够 =====
(common) {
encode zstd gzip # 自动压缩,优先 zstd,回退 gzip
log # 开启访问日志(裸 log 会进入 http.log.access 命名空间)
}
# 统一封禁爬虫的 robots.txt 片段
(robots-deny) {
handle /robots.txt {
respond <<EOF
User-agent: *
Disallow: /
EOF
}
}
# ===== ⑤ 安全兜底:直接用 IP 或未知域名访问 80/443,一律 403 =====
:80, :443 {
respond 403
}
# 未显式配置的子域名全部命中泛域名站点并直接断开连接(不回任何内容)
*.example.com {
abort
}
# ===== ⑥ 站点配置拆到 Caddyfile.d 目录,一个站点一个文件 =====
import Caddyfile.d/*.conf6.1 段落 ①:DNS-01 自动签证
acme_dns 是全局选项,写一次,所有站点都走腾讯云 DNS-01 验证。{env.XXX} 表示从环境变量取值,对应 compose 里传入的密钥——密钥不出现在配置文件里,配置文件可以放心进 Git。
因为走的是 DNS-01,Caddy 会自动获得签发泛域名证书的能力:*.example.com 只需要一张证书。从 Caddy 2.10 开始,配置里出现的具体子域名会直接复用这张泛域名证书,不再逐个单独申请(本教程镜像用的是滚动更新的 caddy:2 标签,天然满足),新增子域名时连证书都不用重新申请——这也是后面 *.example.com { abort } 兜底块能成立的前提。
6.2 段落 ②③:日志分流与格式改造(本文重点)
先看「改造前」:Caddy 默认访问日志长什么样
Caddy 默认输出 JSON,一条请求是这样一大行,人眼基本没法直接读:
{
"level": "info",
"ts": 1757040000.123456,
"logger": "http.log.access",
"msg": "handled request",
"request": {
"remote_ip": "1.2.3.4",
"method": "GET",
"host": "www.example.com",
"uri": "/",
"proto": "HTTP/2.0",
"headers": { "User-Agent": ["Mozilla/5.0 ..."], "Referer": ["https://t.co/"] }
},
"status": 200,
"size": 1024
}再看「改造后」:transform-encoder 的输出
同样一条请求,经过段落 ③ 的模板渲染后,log/access.log 里是你无比熟悉的样子,grep、awk、GoAccess、fail2ban 直接能用:
1.2.3.4 - [2026-09-05T10:00:00.000+0800] (www.example.com) "GET / HTTP/2.0" 200 1024 "https://t.co/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"模板字段逐个对照
| 模板占位符 | 含义 | 对应 Nginx 变量 |
|---|---|---|
{request>remote_ip} | 客户端 IP | $remote_addr |
{ts} | 时间戳,time_format iso8601 指定格式 | $time_local |
{request>host} | 请求的域名(本模板额外加上,方便多站点区分) | $host |
{request>method} | 请求方法 | $request_method |
{request>uri} | 请求路径(含查询串) | $request_uri |
{request>proto} | HTTP 协议版本 | $server_protocol |
{status} | 响应状态码 | $status |
{size} | 响应体字节数 | $body_bytes_sent |
{request>headers>Referer>[0]} | 来源页,[0] 取请求头数组第一个值 | $http_referer |
{request>headers>User-Agent>[0]} | 浏览器 UA | $http_user_agent |
语法说明:日志字段是嵌套 JSON 结构,用
>向下钻取;请求头是数组,用[0]取第一个值。想偷懒的话,Apache 通用日志格式可以直接写成format transform "{common_log}",插件内置了该模板。
两条 logger 是怎么分工的
这是很多人看不懂的地方,一张图讲透:
Caddy 进程产生的所有日志
│
┌─────────────────────────┴────────────────────────┐
▼ ▼
运行日志(tls、admin、pki…) 访问日志(站点块里的 log 指令产生)
namespace: tls / admin / ... namespace: http.log.access
│ │
▼ ▼
log default(段落②) log access-format(段落③)
exclude http.log.access include http.log.access
format json format transform 模板
│ │
▼ ▼
容器 stdout → docker logs caddy /srv/log/access.log(挂载到宿主机 ./log)
排障、看证书申请过程用它 归档、统计、分析用它,180 天自动轮转清理两个必须注意的细节(都是源码层面验证过的坑):
- 站点块里只写一个裸
log(见段落 ④ 的(common)片段),访问日志进入的命名空间是http.log.access;只有写成log 自定义名时才是http.log.access.自定义名(自动编号则是log0、log1)。所以段落 ② 的exclude和段落 ③ 的include都必须精确写http.log.access,写错一个字分流就会失效,访问日志会重复出现在两个地方。 include/exclude是按命名空间前缀、以点号为边界匹配的,http.log.access.foo不会误匹配http.log.access.foobar,放心用。
日志轮转
output file 默认在单文件达到 100MiB 时自动轮转、gzip 压缩;roll_keep_for 180d 表示压缩后的历史日志保留 180 天自动删除,不会把磁盘写爆。常用的还有:
output file /srv/log/access.log {
roll_size 100mb # 单文件大小阈值,默认 100MiB
roll_keep 20 # 最多保留多少个历史文件
roll_keep_for 180d # 历史文件保留多久
}注意一个官方文档明确说明的行为:修改已有日志文件的输出参数(如改路径)需要
restart容器才能完全生效,普通reload只对新增文件名生效。
6.3 段落 ④:公共片段(snippet)
(common) { ... } 括号开头的叫 snippet,相当于「函数」,站点块里 import common 一行调用。压缩、访问日志这种每个站都要写的东西,抽成片段后新增站点零重复配置。
6.4 段落 ⑤:两条安全兜底规则
:80, :443 { respond 403 }:当有人直接用服务器 IP(或未配置的域名)访问时,Host 匹配不上任何站点,就会落到这里,直接返回 403,不会暴露你的真实站点;*.example.com { abort }:所有「没有单独配置」的子域名(扫描器最爱乱猜的jenkins.example.com、test.example.com之类)统一命中这里,abort会直接掐断连接,一个字节都不返回;而你显式配置过的www、app因为匹配优先级更高,完全不受影响。
6.5 段落 ⑥:配置拆分
import Caddyfile.d/*.conf 会把目录下所有 .conf 拼进来。好处显而易见:站点多了之后,每个站点独立成文件,增删站点不碰主配置,代码仓库里 diff 清清楚楚。
7. 第四步:拆分站点配置 Caddyfile.d/
7.1 主站 index.conf:裸域跳转 + 静态文件
创建 conf/Caddyfile.d/index.conf:
# 裸域 example.com:只做一件事,301 永久跳转到 www.example.com
example.com {
tls force_automate
redir https://www.{host}{uri} 301
}
# www 主站:提供静态文件
www.example.com {
import common
root {env.HTML_PATH}/example.com
file_server
try_files {path} /
}两个细节:
- 注意:泛域名证书
*.example.com不覆盖裸域example.com(泛域名只匹配一级子域),所以裸域需要自己的证书。它本身作为 HTTPS 站点也会被自动签证,这里的tls force_automate是再加一道「双保险」——官方语义是「即使已有其他托管证书适用,也强制为本站自动管理证书」; {env.HTML_PATH}对应 compose 里的/srv/html,静态文件实际放在宿主机的html/example.com/下。
顺手放一个测试首页:
cat > html/example.com/index.html <<'EOF'
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Caddy OK</title></head>
<body><h1>It works — Caddy 自动 HTTPS 部署成功</h1></body>
</html>
EOF7.2 反代示例 app.conf:路径分流 + 其余一律 403
创建 conf/Caddyfile.d/app.conf:
app.example.com {
import common
import robots-deny
# 只把 /vault/ 路径反代给后端容器,后端不在本机就换成对应地址
handle /vault/* {
reverse_proxy vaultwarden:80 {
header_up X-Real-IP {remote_host}
}
}
# 其余所有路径一律 403,后端只暴露需要的入口
handle {
respond 403
}
}handle 是精确互斥匹配:命中 /vault/* 走反代,其他全部落到兜底的 handle { respond 403 }。后端地址的三种常见写法:
- 同一 Docker 网络里的容器:
容器名:端口,如gitea:3000; - 宿主机上跑的服务:
host.docker.internal:8080(依赖 compose 里的extra_hosts); - 局域网其他机器:
192.168.1.10:8080。
7.3 以后新增一个站点有多简单
# 1. 新建一个站点文件
cat > conf/Caddyfile.d/blog.conf <<'EOF'
blog.example.com {
import common
reverse_proxy 127.0.0.1:4000
}
EOF
# 2. 热加载,不断开任何现有连接(脚本见 8.3)
./reload.sh完事。证书申请、HTTPS、跳转、压缩、访问日志,全自动。
8. 第五步:启动、验证与日常运维
8.1 一键启动
# 首次会构建定制镜像(要拉 Go 依赖,耐心等几分钟,后续有缓存就是秒级)
docker compose up -d --build
# 跟踪启动日志,重点看证书申请过程
docker compose logs -f caddy看到类似下面的日志,就说明 DNS-01 验证通过、证书签发成功:
{"level":"info","logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"www.example.com"}8.2 逐项验证效果
# ① 验证 HTTPS:看到 HTTP/2 200 和证书链即成功
curl -vI https://www.example.com 2>&1 | grep -E "HTTP/|subject:|issuer:|expire"
# ② 验证裸域 301 跳转
curl -I http://example.com
# ③ 验证 IP 直连兜底 403
curl -k -I https://你的服务器IP
# ④ 确认两个插件确实编进了镜像
docker exec caddy caddy list-modules | grep -Ei 'tencentcloud|transform'
# 期望输出(两个模块各出现一行):
# dns.providers.tencentcloud
# caddy.logging.encoders.transform
# ⑤ 查看访问日志纯文本效果
tail -f log/access.log
# ⑥ 查看 Caddy 持久化下来的证书文件
docker exec caddy find /data/caddy/certificates -name "*.crt"浏览器打开 https://www.example.com,点地址栏小锁查看证书:签发者 Let's Encrypt、有效期 90 天,之后的续期完全不用你管。
8.3 两个运维脚本
热加载配置 reload.sh(改完任何 .conf 后执行,不中断服务):
cat > reload.sh <<'EOF'
#!/bin/bash
# -w 指定工作目录为 /etc/caddy,保证 Caddyfile 里的相对路径 import Caddyfile.d/*.conf 能找到文件
docker exec -w /etc/caddy caddy caddy reload
EOF
chmod +x reload.sh热加载前可以先做语法校验,配置写错也不会影响线上:
docker exec -w /etc/caddy caddy caddy validate --config Caddyfile --adapter caddyfile
升级 Caddy pull.sh(Caddy 更新活跃,建议每隔一两个月跑一次):
cat > pull.sh <<'EOF'
#!/bin/bash
# --pull 拉取最新的 caddy:2-builder / caddy:2-alpine 基础镜像后重新构建
docker compose build --pull && docker compose up -d
EOF
chmod +x pull.sh证书在 ./data 里持久化,重建镜像、重建容器都不会触发重新签证,这就是第 5 节反复强调挂载 /data 的原因。
8.4 常用命令速查
| 操作 | 命令 |
|---|---|
| 启动 | docker compose up -d |
| 停止 | docker compose down |
| 看运行日志 | docker compose logs -f caddy |
| 看访问日志 | tail -f log/access.log |
| 校验配置 | docker exec -w /etc/caddy caddy caddy validate --config Caddyfile --adapter caddyfile |
| 热加载 | ./reload.sh |
| 看已装插件 | docker exec caddy caddy list-modules |
| 进容器排查 | docker exec -it caddy sh |
9. 这套方案到底解决了哪些痛点
| # | 传统做法的痛点 | 本方案的解法 |
|---|---|---|
| 1 | 证书 90 天到期,靠 cron + 脚本 + reload 钩子续命,哪环断了都翻车 | Caddy 进程内自动续期(剩余约 30 天开始),成功后热替换,无需 cron、无需钩子 |
| 2 | HTTP-01 要暴露 80 端口,CDN 回源/内网无法签证,泛域名更别想 | 全局 acme_dns 走 DNS-01,零入站依赖,一张 *.example.com 泛域名证书覆盖所有子域名(裸域自动另签一张) |
| 3 | 容器重建证书全丢,反复申请撞 CA 频率限制 | ./data:/data 持久化证书与账户,重建容器直接复用 |
| 4 | 默认访问日志是 JSON,没法直接 tail/grep,分析工具接不上 | transform-encoder 输出 Nginx Combined 风格纯文本,生态工具零成本接入 |
| 5 | 运行日志和访问日志混在一起,量一大没法看 | include/exclude 按命名空间分流:运行日志进 docker logs,访问日志进文件 |
| 6 | 日志文件无限增长怕写爆磁盘 | output file 自动按大小轮转 + gzip 压缩 + roll_keep_for 180d 自动清理 |
| 7 | 官方镜像缺 DNS/日志插件,不知道怎么装 | xcaddy 多阶段构建,--with 一行一个插件,运行镜像依然干净小巧 |
| 8 | 站点都堆在一个配置文件里,改完要重启,牵一发动全身 | import Caddyfile.d/*.conf 一站一文件,caddy reload 毫秒级热加载、连接不断 |
| 9 | IP 直连、扫描器乱猜子域名,源站信息容易暴露 | :80,:443 兜底 403 + *.example.com { abort } 双层兜底 |
| 10 | 国内服务器构建慢、容器时区不对 | Go/Alpine 腾讯镜像源加速,tzdata + TZ 对齐东八区 |
| 11 | HTTP/3 部署门槛高 | 映射一个 443/udp 即自动启用,浏览器自动协商 |
10. 横向对比:Caddy 和 ACME.sh 到底怎么选
很多人最早接触自动证书都是从 acme.sh 开始的:一个纯 Shell 写的 ACME 客户端,配合 Nginx 使用。我们把两条路线完整摆出来对比。
10.1 acme.sh + Nginx 的典型工作流
# 1. 安装 acme.sh(会自动往 crontab 写一条每天执行的检查任务)
curl https://get.acme.sh | sh -s email=you@example.com
# 2. 配置 DNS 商密钥并签发(泛域名同样走 DNS-01)
export Tencent_SecretId="xxx" Tencent_SecretKey="yyy"
acme.sh --issue --dns dns_tencent -d example.com -d '*.example.com'
# 3. 把证书安装到 Nginx 目录,并声明「续期后执行的动作」
acme.sh --install-cert -d example.com \
--key-file /etc/nginx/ssl/example.com.key \
--fullchain-file /etc/nginx/ssl/example.com.crt \
--reloadcmd "nginx -s reload"
# 4. 手写 Nginx 站点配置:ssl_certificate、ssl_protocols、OCSP、80→443 跳转……它的自动化链路是:cron 每天检查 → 到期前 30 天内重新签发 → 执行 --install-cert 拷贝证书 → 执行 reloadcmd 重载 Nginx。
10.2 Caddy 的工作流
Caddyfile 里写上域名 → docker compose up -d → 结束申请、存储、续期、续期后生效、跳转、TLS 安全基线,全部内建在同一个进程里。
10.3 正面对比表
| 维度 | acme.sh(+ Nginx) | Caddy(本方案) |
|---|---|---|
| 定位 | 纯 ACME 客户端,只负责证书,不管流量 | Web 服务器 / 反向代理,ACME 能力内建 |
| 与 Web 服务的关系 | 解耦,Nginx、OpenResty、邮件服务、数据库 TLS 都能用 | 证书与 Caddy 进程绑定,Caddy 自己就是边缘入口 |
| 续期触发方式 | 安装时自动写入 crontab,每天跑一次检查 | 进程内协程自动管理,没有定时任务这一说 |
| 续期时机 | 到期前 30 天内 | 生命周期过 2/3(90 天证书约剩 30 天),失败自动退避重试 |
| 续期后如何生效 | 必须正确配置 reloadcmd/部署钩子,漏配就等于白续 | 进程内热加载新证书,连接不中断,无需任何钩子 |
| 泛域名 / DNS-01 | 支持,DNS 商插件覆盖极全(Shell 实现) | 支持,DNS 插件需 xcaddy 编入镜像(Go 实现) |
| HTTPS 跳转、TLS 基线、OCSP Stapling | 全部手写配置 | 默认开启,且默认值随版本持续加固 |
| 配置体量 | 签发脚本 + 安装钩子 + Nginx 站点 SSL 段,三处维护 | 一个 Caddyfile 全局段写一次 |
| 失败可见性 | cron 静默执行,要靠日志/邮件主动发现 | 直接进容器标准输出,docker logs 一眼可见 |
| 跨机器分发证书 | 强项,可把证书部署到任意多台机器 | 不擅长,Caddy 只管自己这台 |
10.4 选型建议
- 选 Caddy:新部署的个人站、自建服务反代入口、Docker 环境、不想维护证书脚本和 Nginx SSL 段、希望「写个域名就完事」。这也是本教程的场景。
- 选 acme.sh:已经有成熟的 Nginx/OpenResty 体系不想换;证书要分发给多台机器或非 Web 服务(PostgreSQL、邮件服务器、gRPC 网关等);团队已经沉淀了 acme.sh 的部署流水线。
- 两者并不对立:acme.sh 集中签发、通过部署钩子把证书分发给各服务;Caddy 在边缘统一自动签证,都是业界常见架构。对绝大多数单机自托管玩家,Caddy 的「一体化省心」是碾压级体验——少一个组件,就少一类故障。
11. 常见坑与 FAQ
Q1:调试期间证书申请失败了好几次,会不会被 Let's Encrypt 封号?
有频率限制:每个注册域名每周最多 50 张证书、同一组域名每周最多重复签发 5 次。调试阶段建议在全局块加一行切到 Let's Encrypt 的测试环境(证书不被浏览器信任,但无限额),调通后删掉再签正式证书:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
# ……其余配置
}Q2:日志里报 DNS 验证超时 / TXT 记录没找到?
DNS-01 需要等 TXT 记录在公网生效。先在服务器上 dig TXT _acme-challenge.example.com 确认记录确实存在;DNSPod 一般几秒内生效,若你用了多厂商 DNS 级联,适当等待后再 ./reload.sh 触发重试即可。Caddy 会自动反复重试,不用慌。
Q3:密钥对,却报权限错误?
确认子用户有 DNSPod 操作权限;另外腾讯云国际站与国内站账号体系不互通,密钥和域名要在同一个账号体系下。
Q4:一定要开 80 端口吗?
DNS-01 签证本身不需要 80。但 Caddy 默认会把 http:// 请求 301 到 https://,保留 80 可以让手滑只输了 http:// 的用户自动跳转到 HTTPS;安全组只开 443 也能正常签证。
Q5:怎么确认证书真的会自动续?
不用做任何事。可以在运行日志里观察,Caddy 在证书进入续期窗口后会自动出现 tls.renew 相关日志;也可以手动执行 docker exec caddy caddy list-modules 确认环境正常。证书文件在 /data/caddy/certificates/,续期后文件会原地更新。
Q6:改了日志文件路径/轮转参数,reload 后没变化?
这是预期行为:日志 output 的变更需要 docker compose restart caddy 才能完全生效;普通站点配置改动用 ./reload.sh 即可。
Q7:想接收证书到期提醒邮件?
在全局块加 email 你的邮箱@example.com,Caddy 会用该邮箱向 CA 注册。
Q8:想换成 ZeroSSL 或者同时配置多个 CA 怎么做?
全局块使用 acme_ca https://dv.acme-v02.api.pki.goog/directory(ZeroSSL 的 EAB 密钥按 ZeroSSL 官网指引获取)即可;也可以在 TLS 自动化策略里配置多个 issuer 做故障转移,按需查官方文档。
Q9:Caddy 镜像需要 NET_ADMIN 这类特权吗?
本方案(静态站 + 反代 + DNS-01 + HTTP/3)不需要,compose 里不用加 cap_add,遵循最小权限原则即可。
12. 参考资料
- Caddy 官方文档 · Automatic HTTPS:https://caddyserver.com/docs/automatic-https
- Caddy 官方文档 · log 指令:https://caddyserver.com/docs/caddyfile/directives/log
- transform-encoder 插件(自定义日志格式):https://github.com/caddyserver/transform-encoder
- caddy-dns/tencentcloud 插件(腾讯云 DNSPod):https://github.com/caddy-dns/tencentcloud
- xcaddy 定制构建工具:https://github.com/caddyserver/xcaddy
- Caddy 官方 Docker 镜像:https://hub.docker.com/_/caddy
- acme.sh 项目:https://github.com/acmesh-official/acme.sh
- Let's Encrypt 频率限制说明:https://letsencrypt.org/docs/rate-limits/
收个尾:这套配置的核心哲学就一句话——把「证书」这件事从你的运维清单里彻底删掉。 域名写进配置,Caddy 负责后面的一切;日志改造成你熟悉的样子,排障不再抓瞎;Compose 把环境固化下来,换台机器也是两条命令起服务。 动手抄一遍,你会回不去的。
