以前部署 HTTPS,我印象最深的就是折腾 Nginx 加 certbot:写一长串配置,手动生成 CSR、提交验证、再把证书路径填进配置文件,最后还要处理续期 cron。直到换成 Caddy + Docker Compose 之后,整个流程才真的变成“把域名填进配置就完成部署”。Caddy 把 ACME 自动证书协商、文件监听、HTTP/2 这些都内建在进程里,配合 Docker Compose 做单机编排,几分钟就能拿到一张有效的 HTTPS 证书,省下的时间相当可观。这篇文章我会从选型思路讲起,再到 compose 文章节逐步搭建、ACME 原理、实际排错,最后补充一些进阶玩法,适合刚入门的新手,也适合正在做单机服务迁移的运维同学。
1. 为什么选 Caddy:不只是“自动证书”三个字
1.1 先回想一下手动申请证书的日子
在没有 Caddy 之前,给一台服务器上装 HTTPS 是一件挺“仪式感”的事情。假设你用的是 Nginx,流程大致是:
- 在服务器上生成私钥和 CSR 文件;
- 把 CSR 提交给证书服务商,同时配置一个验证用的临时文件或 DNS 记录;
- 等服务商确认你对域名有控制权;
- 下载签发的证书和中间链,放到某个目录;
- 修改 Nginx 的
ssl_certificate和ssl_certificate_private_key配置; - reload Nginx;
- 再写一个脚本放到 crontab,每隔一个月检查一次证书剩余有效期,到期前重新申请。
这套流程最大的问题不是每一步有多难,而是每一步都有可能出问题。CSR 格式不对、nginx 配置里证书路径写错、签发后忘记续期,这些和业务本身毫无关系的事故,往往就是运维消耗最多精力的地方。而且证书服务商通常只签发 90 天有效期,意味着一年要至少处理四次续期,机器多了以后非常崩溃。
1.2 容器环境下,Caddy 的“免 reload 逻辑”省了什么
Caddy 最核心的能力是把 ACME 客户端直接编进了进程里。它启动后会自动读取 Caddyfile,发现你写了example.com,就自己去和证书服务商协商申请证书,申请完成后自动把证书挂到对应站点上;证书快过期时,它也会在后台重新申请。整个过程不需要你写 cron,不需要你手动把证书路径填到某个ssl_certificate配置里。
这一点在 Docker Compose 场景下尤其舒服。容器本身是无状态的,但也意味着容器重建后你可能丢失手工配置的证书文件。Caddy 把证书、私钥、账户信息都放到自己的数据目录/data里,我们只要把这个目录挂成 volume,容器销毁重建后证书自动复用,根本不走重新签发流程。再加上 Caddy 配置语法写起来很直观,比如要反代一个 PHP 服务,就写reverse_proxy 127.0.0.1:9000,不需要像 Nginx 那样再用location块包一层。配置文件短了,reload 时出错的概率自然也就低了。
1.3 和 Nginx、Traefik 的选型对比
很多人在单机部署时都会犹豫 Caddy、Nginx、Traefik 到底选哪个。我自己的判断标准是:如果你主要想解决“自动 HTTPS”这件事,Caddy 是最直接的;如果你依赖大量 Nginx 社区配置和 Lua 扩展,那继续用 Nginx 也能理解;如果服务规模已经大到需要靠 Kubernetes 做服务发现,Traefik 的入口控制器模式会更匹配。
| 维度 | Caddy | Nginx + certbot | Traefik |
|---|---|---|---|
| 证书自动申请 | 内建 ACME,配置文件里写域名即可 | 借助 certbot,需要额外脚本配合 | 内建 ACME,依赖标签/动态配置 |
| 配置复杂度 | 低,语法贴近人类语言 | 中,需要拆多个 server 块 | 中高,依赖标签和定义规则 |
| 配置热更新 | caddy reload即可完成 | nginx -s reload | 动态发现能力最强 |
| 镜像体积 | 约几十 MB,Go 单二进制 | Nginx 镜像 + certbot 额外组件 | 较大,生态组件多 |
| 适用场景 | 单机、少量站点、希望快速落地 | 重度依赖 Nginx 生态 | 动态容器编排、边缘路由 |
在单机服务器上,我没有选 Traefik,是因为它面向动态服务发现的设计在这里有点“杀鸡用牛刀”。Caddy 的配置模型更适合固定编排:写清楚域名,反代到哪个容器,剩下的自动处理。
2. 搭建第一步:compose 文件、Caddyfile 和目录规划
2.1 目录结构:data 和 config 为什么必须独立
在写 docker compose 之前,先规划好宿主机上的项目目录。我自己习惯这样组织:
/opt/caddy/ ├── Caddyfile ├── docker-compose.yml ├── data/ # 证书、私钥、acme账户等 └── config/ # Caddy 运行的自动生成配置data目录对应容器里的/data,config目录对应容器里的/config。这是 Caddy 镜像约定的两个标准挂载点:/data保存所有需要持久化的状态,包括证书、私钥、ACME 账户;/config保存 Caddy 根据 Caddyfile 生成的实际运行配置。
为什么要分两个目录?因为证书和数据重启后绝对不能丢,而/config里的内容是 Caddy 运行时自动生成的,丢了也能重新生成。把证书单独放一个目录,也方便以后手动备份或者做其它运维操作。你如果觉得 bind mount 写绝对路径太长,也可以直接用 Docker 命名卷,例如caddy_data:/data,但我更喜欢 bind mount,因为出问题时可以直接进去看文件。
2.2 compose 写法:端口映射和数据卷复用
一个最小可用的docker-compose.yml长下面这样:
services: caddy: image: caddy:2-alpine container_name: caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./data:/data - ./config:/config这里有两个容易忽略的细节。第一个是端口映射,80:80和443:443必须同时保留。为什么?Caddy 自动申请证书时,会同时准备 HTTP-01 和 TLS-ALPN-01 两种验证方式,其中 HTTP-01 需要访问 80 端口,TLS-ALPN-01 需要访问 443 端口。只映射 443 可能会导致部分场景下验证失败,尤其你已经提前把服务器上 80 端口占用了。
第二个是 Caddyfile 的挂载方式,我加了:ro表示只读。因为容器内 Caddy 不会修改 Caddyfile,这个文件只是被读取一次;挂成只读可以防止容器进程意外改写配置文件。
2.3 Caddyfile 三行配置背后的语义
在/opt/caddy/Caddyfile里先写一个最简单的站点:
example.com { root * /srv file_server }example.com是这个站点的匹配域名,root * /srv表示所有请求都指向容器内的/srv目录,file_server开启静态文件服务。这里如果example.com是你的真实域名,并且 DNS 已经解析到了这台服务器,Caddy 启动后就会自动完成证书申请,不需要再写任何 ssl 相关配置。
如果想反代一个后端服务,Caddyfile 一般是这样的:
blog.example.com { reverse_proxy blog:8080 }注意这里的blog:8080是容器网络里的服务名,不是宿主机地址。Docker Compose 会自动创建一个默认网络,所有 service 之间可以通过服务名互相访问。所以在 compose 文件里服务名叫blog,Caddyfile 里就直接写blog,Caddy 容器能解析到该服务所在的容器 IP。
2.4 启动顺序和首次申请证书的日志判断
写好两个文件之后,在项目目录执行:
docker compose up -d首次启动时,Caddy 会检查 Caddyfile 里的域名,如果发现没有证书,会自动向证书服务商发起申请。通过日志可以观察整个过程:
docker logs -f caddy正常情况下会看到类似这样的记录:
2025/xx/xx 10:00:00 [INFO] [example.com] Obtain certificate: server response ... 2025/xx/xx 10:00:01 [INFO] [example.com] Certificate obtained successfully 2025/xx/xx 10:00:01 [INFO] Serving on HTTPS如果日志里出现server responded with error,或者challenge failed,就说明自动证书申请这步没走通。具体的排查方法我会在第 4 章里专门讲。
如果只是修改了 Caddyfile,不需要重启容器,执行:
docker exec caddy caddy reload这个命令会校验新的 Caddyfile 并平滑切换配置,不会中断现有连接。
3. 自动证书背后的 ACME 流程:Caddy 是怎么把 HTTPS 变成零成本的
3.1 ACME 挑战的本质:让 CA 相信域名属于你
自动证书看着像魔法,本质其实是一套 ACME(Automatic Certificate Management Environment)协议。Caddy 在和 CA 协商时,CA 不会直接把证书发给你,而是先给你一个“挑战”:你来证明你确实控制着这个域名。
最常见的证明方式有三种:
- HTTP-01:CA 要求你能在一个临时路径上提供指定内容,路径是
http://你的域名/.well-known/acme-challenge/xxx; - TLS-ALPN-01:CA 通过 443 端口发起一次特殊 TLS 握手,要求服务器在握手过程中提供约定的标识;
- DNS-01:CA 要求你给域名设置一条指定的 DNS TXT 记录。
Caddy 本质上是一个完备的 ACME 客户端,它收到挑战后,会根据自身的配置自动完成其中一个或多个。完成挑战后,CA 才会签发证书。这个过程在 Caddy 看来是无感的:用户只写了“域名”,剩下的是协议自动协商。
3.2 HTTP-01、TLS-ALPN-01、DNS-01,Caddy 默认用哪个
对于普通单机部署,Caddy 默认会同时启用 HTTP-01 和 TLS-ALPN-01 两种验证通道。这也是我前面反复强调要同时映射 80 和 443 端口的原因:两条路都通,CA 无论走哪条都能验证成功;如果只开放一条,虽然大多数情况下也能过,但个别 CA 或者网络环境下可能会遇到意外。
DNS-01 则适用于拿不到公网入口的情况,比如域名在防火墙后面,不想暴露 80/443;或者你需要申请*.example.com这种通配符证书。因为通配符域名不可能通过 HTTP 方式验证,必须走 DNS 验证。Caddy 的默认镜像不直接带各种域名服务商插件,但可以通过自定义镜像的方式扩展,这部分会在后续进阶章节展开。
3.3 续期、存储和重启后的证书找回
现在的公共 CA 签发的证书有效期一般是 90 天。Caddy 内部的任务调度器会在证书剩余时间不足约三分之一时,自动发起续期申请。也就是说,你可能完全感知不到证书过期这件事。前提是 Caddy 进程在运行,并且/data目录里的账户和私钥没有被删掉。
证书文件存在容器/data/caddy/certificates/下,每个域名一个目录。如果你对容器不熟悉,可能会担心容器重建后证书会不会丢。只要你在docker-compose.yml里正确挂了./data:/data,容器重建后,Caddy 会优先从磁盘加载已有证书,发现还有效就直接用,不会反复申请。
这里有一个很多人都踩过的坑:千万不要手忙脚乱地去手动修改证书目录里的文件。Caddy 有自己的状态文件来追踪证书与待办任务,手动替换证书文件容易让 Caddy 认为自己当前没有证书,反而触发重新申请。如果真需要更新证书,正确的做法是删掉对应域名的证书目录,然后执行caddy reload,让 Caddy 自己重新走一遍 ACME 流程。
3.4 为什么“不要手动拷贝证书文件”更省心
过去我们有很强烈的习惯,“把证书文件拷到服务器,再让 Web 服务器加载”。在 Caddy 的模式下,这个思路要反过来:证书是进程自己管理的,不是我们“安装”的。你只需要保证:
- 域名解析正确;
- 80/443 端口能公网访问;
- Caddyfile 里的域名和实际访问域名一致。
剩下的申请、续期、重载全部交给进程。如果你手动把证书插进去,反而破坏了 ACME 的自动化闭环。这也是单机场景下 Caddy 比“Nginx + 手动设置”更省时间的原因:你不需要把运维精力花在证书文件本身的管理上,而是要花在正确配置 Caddyfile 上。
4. 排错实录:域名、端口、容器网络三类问题的完整排查链路
4.1 证书申请报错的日志初判
真实部署时,大概率第一次启动不会全部顺利。我遇到过最多的情况是docker compose up -d之后,Caddy 日志里出现:
[ERROR] [example.com] obtain certificate: job failed: order status is invalid看到这个先别慌。这句话的意思是 ACME 订单被 CA 拒绝了,原因可能是验证失败,也可能是 CA 需要更多时间。接下来不是去重装 Caddy,而是按顺序检查三个地方。
第一步,看域名解析。在服务器上执行:
dig +short example.com如果显示的不是这台服务器的公网 IP,或者说根本没结果,那证书申请失败是必然的。Caddy 要申请证书,至少要让 CA 能通过域名访问到你的服务器。如果你只在/etc/hosts里把域名映射到本地,Caddy 自己倒是可以解析,但 CA 在公网无法访问,依然会失败。
第二步,检查端口被谁占用。在宿主机执行:
ss -tlnp | grep -E ':80|:443'如果已经有一个 Nginx 或其它服务占用了 80 端口,Docker 在映射端口时会提示bind: address already in use。这种情况要找出来是什么进程在监听,然后停掉它。千万不能为了躲过端口冲突把 Caddy 的映射端口改成8080:80和8443:443,因为那样会导致 CA 无法通过标准端口验证,证书申请永远失败。
第三步,确认外部网络确实能访问。这一步经常被忽略。在服务器上执行下面命令只是本机测试:
curl -I http://example.com/.well-known/acme-challenge/probe如果你的云服务商有安全组,防火墙默认可能会把 80 端口挡掉。很多用户查了半天服务器配置,最后发现是阿里云/腾讯云的安全组规则里没放行 80/443。正确做法是到云控制台的安全组里添加入方向规则,允许 TCP 80 和 TCP 443。
4.2 最隐蔽的问题:安全组关闭了 80 端口
前面说的是端口被程序占用,还有一种更隐蔽的情况:端口没被占用,本机 curl 也通,但 CA 来说验证失败。我排查过好几次,最后发现根因都是云安全组里只放行了 443,没有放行 80。
为什么本机 curl 会通?因为本机 curl 走的是服务器自己的网络栈,安全组规则作用于外部流量的入站方向,本机访问并不经过它的过滤。CA 验证则来自公网,一旦安全组不放行,就永远得不到响应。
所以每次部署 HTTPS 时,我建议做一个“外网视角检查”。可以在另一台机器上,或者用手机流量,访问试试:
curl -I http://你的域名/.well-known/acme-challenge/probe如果手机流量能正常返回响应头,说明公网路径是通的;如果超时,就基本可以确认是安全组或云防火墙的问题。
此外,TLS-ALPN-01 走的是 443 端口。虽然你已经映射了 443,但如果服务器上的 firewalld/ufw 没有放行 443,同样会失败。常见的场景是:服务器装了宝塔或云盾,默认自带防火墙策略,端口没在名单里,即使 Docker 映射了,外部也无法进来。
4.3 容器服务名与宿主机地址的混淆
Caddy 和它反代的后端服务都跑在 Docker Compose 网络里,最容易搞混的是“localhost”。比如你在 Caddyfile 里写:
blog.example.com { reverse_proxy localhost:8080 }这通常是不行的,因为此刻的localhost是 Caddy 容器自己的回环地址,而不是宿主机或另一个容器。正确写法是使用 compose 文件里定义的服务名:
services: caddy: ... depends_on: - blog blog: image: your-app然后 Caddyfile 里写:
blog.example.com { reverse_proxy blog:8080 }Docker Compose 会默认创建名为项目名_default的网络,所有 service 注册在该网络中,Caddy 可以通过服务名解析到其它容器。如果后端服务不在同一个 Compose 项目里,需要在 compose 文件里显式指定networks,让两个服务共享同一个自定义网络。
还有一种情况是后端服务跑在宿主机上,并不在容器里。这时候从 Caddy 容器访问宿主机,不能写localhost,需要额外处理。Linux 下最简单的方法是利用host.docker.internal,但需要先在 compose 文件里加:
extra_hosts: - "host.docker.internal:host-gateway"然后 Caddyfile 里写reverse_proxy host.docker.internal:8080。这个细节不解决,新手很容易在容器环境里绕半天。归根到底,要意识到 Caddy 容器是一个独立网络空间,它的网络视图和宿主机不完全一样。
4.4 配置变更后是否需要重启容器
Caddy 不是修改即生效的模型。你改了 Caddyfile 之后,如果直接跑docker compose restart,虽然也能生效,但会短暂断开连接,而且如果配置写错了,Caddy 可能起不来,导致线上直接不可用。我一般用下面两步:
docker exec caddy caddy validate docker exec caddy caddy reloadcaddy validate会先校验 Caddyfile 语法,比如是否少了闭合花括号,指令是否拼错。校验没问题再 reload。reload 时 Caddy 会原样加载新配置,如果新配置里某个反代地址解析不了,会在日志里提示,但不会中断现有服务。
如果你改了 compose 文件,比如修改了端口映射或镜像版本,那才需要docker compose up -d。它会重新创建需要变更的容器,并在不影响其它服务的前提下完成更新。记住“配置文件变动用 reload,compose 结构变动用 up -d”这一条原则,能省掉很多不必要的折腾。
5. 进阶方案:多站点、自定义镜像和反向代理的细节
5.1 多域名多站点管理:一个 Caddy 管全部
单机服务器上往往不止跑一个服务。用户博客、API 后端、管理后台,可能都在这台机器上。用 Caddy 管理多站点几乎零成本,只需要在 Caddyfile 里继续追加块:
example.com { root * /srv/site1 file_server } api.example.com { reverse_proxy api:3000 } admin.example.com { basic_auth { admin JDJhJDEyJ... } reverse_proxy admin:8080 }每个外层块以域名开头,内部是反向代理、静态资源、认证等指令。Caddy 会自动为每个站点申请对应域的证书。多个域之间互不干扰,一个证书申请失败,不会影响其它域的正常访问。
如果需要把www域名统一跳转到主域名,可以这样写:
www.example.com { redir https://example.com{uri} permanent }这种配置在 Nginx 里通常要写单独的 server 块加上return 301,在 Caddy 里一行指令搞定。
5.2 需要通配符证书时,自定义镜像加载 DNS 插件
普通自动证书只覆盖单一域名。如果你要为主域名的所有子域统一提供 HTTPS,比如*.example.com,就必须走 DNS-01 验证。这要求 Caddy 能操作你的 DNS 服务商,添加一条 TXT 记录。
官方caddy:2-alpine镜像不包含各家 DNS 服务商插件,需要自己构建。以 Cloudflare 为例,创建一个Dockerfile:
FROM caddy:2-builder AS builder RUN xcaddy build \ --with github.com/caddy-dns/cloudflare FROM caddy:2 COPY --from=builder /usr/bin/caddy /usr/bin/caddy然后在docker-compose.yml里,把image: caddy:2-alpine替换成build: .。启动前设置环境变量存放 Cloudflare API Token,并在 Caddyfile 里写成:
*.example.com { tls { dns cloudflare {env.CLOUDFLARE_API_TOKEN} } reverse_proxy app:8080 }构建会拉取xcaddy和对应插件,第一次耗时较长。之后启动 Caddy,它就能通过修改 DNS 记录完成验证,从而签发通配符证书。需要说明的是,各 DNS 插件配置细节略有不同,使用时以插件仓库的 README 为准。这个方案同样适用于内网环境——如果域名没有公网解析,只要你能操作权威 DNS,也能用 DNS-01 让 CA 验证。
5.3 反向代理时后端真实 IP 与协议头处理
Caddy 做反向代理时,默认会自动给上游请求加上X-Forwarded-For和X-Forwarded-Proto。大多数情况下不需要额外处理,但如果你后端的应用要做用户真实 IP 统计,或者需要知道当前请求是 HTTP 还是 HTTPS,就需要确认这些头有没有正确传递。
在 Caddyfile 里,reverse_proxy块下面可以通过header_up自定义传给后端的请求头:
api.example.com { reverse_proxy api:3000 { header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} } }{remote_host}是 Caddy 内置的占位符,表示客户端地址;{scheme}表示客户端连接是 http 还是 https。如果你用 Nginx 做过反代,会发现这套逻辑完全对得上,只是 Caddy 的写法更简洁。
要注意:如果 Caddy 和后端容器之间走的是 Docker 内部网络,默认是 HTTP 明文。也就是说,从客户端到 Caddy 是 HTTPS,从 Caddy 到后端是 HTTP,这种“边缘终止 TLS”的模式在单机部署里完全够用,不需要在后端容器再配证书。只有涉及等保、端到端加密等强制要求时,才需要把 TLS 延伸进内部网络。
5.4 我的几个收尾小经验
部署了一整圈之后,真正影响体验的往往是几个小细节。第一个是日志。默认 Caddy 的访问日志是写到标准输出,docker logs能看到,但不够结构化。我习惯在 Caddyfile 全局块里配置:
{ log { output file /data/logs/access.log format json } }这样日志集中在一个文件,后续接 Loki 或者直接 grep 都很方便。
第二个是备份。Caddy 的/data目录虽然不是每个小时都在变,但它里面的证书私钥和 ACME 账户很重要。我会把这个目录连同 Caddyfile 一起做定时快照。真到重置服务器时,把备份的data目录放回去,Caddy 就能直接复用证书,不会被 CA 的速率限制挡住。
第三个是不要忽略restart: unless-stopped。这个指令让 Caddy 容器在进程崩溃或服务器重启后自动启动,确保自动续期任务不会因为容器长期关闭而漏掉。如果你用的是restart: no,某次服务器重启后 Caddy 没起来,三个月后证书过期才发现,就会陷入“证书过期,导致网站不可用,用户才发现”的被动局面。
我自己的项目基本都是按这个模板落地:一个 Caddy 容器管理所有的对外入口,后端服务各自独立在 compose 项目里,需要新增站点时只改 Caddyfile,然后caddy reload。整套流程跑顺之后,HTTPS 自动证书部署就不再是每次都要复盘一遍的难题,而是一个运行稳定、几乎无感的默认配置了。