从 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 的证书管理模型是过程式的:

  1. 部署 nginx 配置(声明需要证书)
  2. certbot 获取证书(执行过程)
  3. 定时任务续期(维护过程)
  4. 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_data volume 必须持久化,存储证书和 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/certificates

2. 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 是更优解。


参考资料

最后修改:2026 年 01 月 03 日
如果觉得我的文章对你有用,请随意赞赏