资讯动态

Caddy + Docker Compose:轻松实现HTTPS自动证书部署

发布时间:2026/9/26 16:48:30 来源:尧图企业网站定制
以前部署 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 的入口控制器模式会更匹配。维度CaddyNginx certbotTraefik证书自动申请内建 ACME配置文件里写域名即可借助 certbot需要额外脚本配合内建 ACME依赖标签/动态配置配置复杂度低语法贴近人类语言中需要拆多个 server 块中高依赖标签和定义规则配置热更新caddy reload即可完成nginx -s reload动态发现能力最强镜像体积约几十 MBGo 单二进制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目录对应容器里的/dataconfig目录对应容器里的/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 文件里服务名叫blogCaddyfile 里就直接写blogCaddy 容器能解析到该服务所在的容器 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 相信域名属于你自动证书看着像魔法本质其实是一套 ACMEAutomatic Certificate Management Environment协议。Caddy 在和 CA 协商时CA 不会直接把证书发给你而是先给你一个“挑战”你来证明你确实控制着这个域名。最常见的证明方式有三种HTTP-01CA 要求你能在一个临时路径上提供指定内容路径是http://你的域名/.well-known/acme-challenge/xxxTLS-ALPN-01CA 通过 443 端口发起一次特殊 TLS 握手要求服务器在握手过程中提供约定的标识DNS-01CA 要求你给域名设置一条指定的 DNS TXT 记录。Caddy 本质上是一个完备的 ACME 客户端它收到挑战后会根据自身的配置自动完成其中一个或多个。完成挑战后CA 才会签发证书。这个过程在 Caddy 看来是无感的用户只写了“域名”剩下的是协议自动协商。3.2 HTTP-01、TLS-ALPN-01、DNS-01Caddy 默认用哪个对于普通单机部署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。它会重新创建需要变更的容器并在不影响其它服务的前提下完成更新。记住“配置文件变动用 reloadcompose 结构变动用 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 为例创建一个DockerfileFROM caddy:2-builder AS builder RUN xcaddy build \ --with github.com/caddy-dns/cloudflare FROM caddy:2 COPY --frombuilder /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 自动证书部署就不再是每次都要复盘一遍的难题而是一个运行稳定、几乎无感的默认配置了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑