从 nginx 迁移到 Caddy:证书管理的自动化实践
背景与动机
在 VPS 上长期运行着基于 Docker Compose 的多服务架构,使用 nginx-proxy + Let's Encrypt companion 管理反向代理和 TLS 证书。虽然这套方案稳定,但随着服务增多,逐渐暴露出一些痛点:
证书管理的复杂性
- 证书、私钥、nginx 配置三者需要协同管理
- 续期依赖外部容器(letsencrypt-companion),引入额外依赖
- 手动调整 SSL 配置(HSTS、cipher suite)容易遗漏
资源占用
- nginx-proxy + companion 两个常驻容器
- 证书存储分散在多个 volume
配置冗余
- 每个服务需要配置环境变量(VIRTUAL_HOST, LETSENCRYPT_HOST)
- SSL 安全配置需要在模板中重复
基于"能自动化的不手动"原则,决定评估 Caddy 作为替代方案。
核心差异:声明式 vs 过程式
nginx 的证书管理模型是过程式的:
- 部署 nginx 配置(声明需要证书)
- certbot 获取证书(执行过程)
- 定时任务续期(维护过程)
- reload nginx(应用变更)
Caddy 的模型是声明式的:
example.com {
reverse_proxy backend:8080
}这三行隐含的操作:
- 自动申请 Let's Encrypt 证书
- 监听 80/443 端口
- HTTP 自动重定向 HTTPS
- 证书到期前自动续期
- 配置现代 TLS 参数(TLS 1.3, OCSP stapling)
本质差异:nginx 要求你管理证书生命周期,Caddy 把证书视为配置的副作用。
迁移实践
架构决策
我的场景是单 VPS + Cloudflare Tunnel,流量路径:
Internet → Cloudflare → Tunnel → Caddy → Docker 后端服务关键问题:Caddy 需要管理证书吗?
Cloudflare Tunnel 已经提供 TLS 终止,理论上 Caddy 只需做 HTTP 反向代理。但实际测试发现某些服务(如 Nextcloud)会检查协议头,强制跳转 HTTPS,导致重定向循环。
最终选择:Caddy 启用内部 TLS,但使用自签名证书,避免依赖外部 ACME 验证。
Docker Compose 集成
最小化配置:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- frontend
# 后端服务示例
app:
image: myapp:latest
expose:
- "8080"
networks:
- frontend
volumes:
caddy_data: # 持久化证书存储
caddy_config:
networks:
frontend:关键点:
caddy_datavolume 必须持久化,存储证书和 ACME 账户信息- 服务用
expose而非ports,只暴露给内部网络 - Caddyfile 挂载为只读,防止容器内修改
Caddyfile 模式演进
初版(单服务):
:80 {
reverse_proxy app:8080
}改进(多服务路由):
{
auto_https off # Tunnel 场景不需要公网证书
}
:80 {
@nextcloud host nextcloud.internal.example.com
handle @nextcloud {
reverse_proxy nextcloud:80
}
@blog host blog.internal.example.com
handle @blog {
reverse_proxy typecho:80
}
}最终版(启用内部 TLS):
{
auto_https internal # 自签名证书
}
https://nextcloud.internal.example.com {
reverse_proxy nextcloud:80
}
https://blog.internal.example.com {
reverse_proxy typecho:80
}踩过的坑
1. 证书数据丢失
现象:容器重启后重新申请证书,触发 Let's Encrypt 频率限制
原因:未正确配置 volume,数据存储在容器层
解决:
# 检查 volume 是否挂载
docker inspect caddy | grep -A 10 Mounts
# 确认证书位置
docker exec caddy ls -la /data/caddy/certificates2. DNS 挑战配置失败
场景:尝试用 Cloudflare DNS-01 挑战(内网服务获取公网证书)
问题:官方镜像不含 DNS provider 插件
解决:自定义构建镜像
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy教训:评估是否真的需要公网证书,内网用自签名更简单
3. 反向代理头缺失
现象:后端服务获取的客户端 IP 都是容器 IP
原因:Caddy 默认不传递 X-Forwarded-For
解决:显式配置
reverse_proxy backend:8080 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}4. 配置语法差异
nginx → Caddy 常见翻译错误:
# nginx
location /api {
proxy_pass http://backend:8080/v1;
}# Caddy 错误写法
reverse_proxy /api/* backend:8080/v1
# 正确写法(注意路径重写)
route /api/* {
uri strip_prefix /api
reverse_proxy backend:8080/v1
}性能与资源对比
内存占用(Docker Stats):
- nginx-proxy + letsencrypt-companion: ~150MB
- Caddy: ~30MB
镜像大小:
- nginx:alpine (24MB) + certbot (需要额外容器)
- caddy:2-alpine (46MB,包含 ACME 客户端)
配置复杂度:
- nginx: 50+ 行(SSL 配置 + location 块 + 证书路径)
- Caddy: 3-5 行(声明式路由)
证书续期:
- nginx: 外部 cron + certbot renew + nginx reload
- Caddy: 内置,到期前自动处理
适用场景建议
适合 Caddy 的场景:
- 服务数量 < 20,配置变更频繁
- 追求配置简洁,自动化优先
- 对内存敏感的 VPS 环境
- HTTP/2, HTTP/3 需求(Caddy 原生支持)
仍然选择 nginx 的场景:
- 极致性能优化(静态文件服务)
- 复杂的流量控制(rate limiting, geo blocking)
- 已有成熟的 nginx 配置生态(如 nginx-proxy-manager)
- 团队对 nginx 配置更熟悉
总结
迁移到 Caddy 的核心收益不是性能,而是降低认知负担。证书管理从"我需要记得续期"变成"它会自己处理",配置从"我需要理解 SSL 参数"变成"我只需声明域名"。
这符合基础设施即代码的理念:配置应该描述期望状态,而非执行步骤。
对于个人 VPS 或小型项目,Caddy 的自动化收益远大于学习成本。但对于大规模生产环境,nginx 的成熟生态和精细控制能力仍然不可替代。
选择工具的关键不是"哪个更好",而是"哪个更适合当前约束条件"。我的约束是单人维护 + 资源受限 + 配置频繁变更,Caddy 是更优解。
参考资料:
- Caddy 官方文档:https://caddyserver.com/docs/
- Automatic HTTPS 原理:https://caddyserver.com/docs/automatic-https
- Docker 集成最佳实践:https://github.com/lucaslorentz/caddy-docker-proxy