7030 字
35 分钟
用 Docker 部署 Caddy:自动 HTTPS 反向代理
2026-11-15

写在前面:先分清 Caddy 和 Caddy 2#

Caddy 是一个用 Go 写的 Web 服务器,最出名的本事就一句话:HTTPS 全自动。你只要把域名指给它,它自己去 Let’s Encrypt(或 ZeroSSL)申请证书、自己续期、自己把 HTTP 跳转到 HTTPS,不需要 Certbot、不需要写续期定时任务、不需要操心证书文件放哪。

但网上搜 Caddy 教程有个大坑:Caddy v1 和 Caddy 2 是两个配置语法完全不兼容的版本。v1 早已停止维护,它的配置写法(比如 proxy 指令、没有大括号的站点写法)拿到现在根本跑不起来。现在还在维护、大家实际在用的都是 Caddy 2:官方镜像就是 caddy:2(也可以用 caddy:latest,指向的也是 2.x),配置文件叫 Caddyfile,用的是 v2 语法。本文写的全部是 Caddy 2,后面凡是说「Caddy」,指的都是 Caddy 2。

上一篇 《用 Docker 部署 Nginx 反向代理与自动 HTTPS》 写了 Nginx 的两条路线(原生 Nginx + Certbot、Nginx Proxy Manager)。这篇的 Caddy 是另一条平行路线:没有图形后台,配置全靠一个文本文件,但自动化程度是三者里最高的。两篇可以对照着看,文末有选型建议。

本文的主线:先讲清 Caddy 和 Nginx 的区别,再用 Docker Compose 把它部署起来,然后把 Caddyfile 从全局块到每个常用指令逐条讲透,最后是日常运维命令和一整套排障流程。Compose 的基础用法不熟悉的,先看 《如何在Linux部署Docker&Compose》。

Caddy 和 Nginx 反代的核心区别#

对比项Caddy 2Nginx(原生)
自动 HTTPS开箱即用:写个域名就自动申请、续期证书,HTTP 自动跳 HTTPS要自己配 Certbot 申请证书、写定时任务续期、手动写跳转规则
配置文件Caddyfile,一行就能反代一个站点nginx.conf,一段 server 配置动辄二三十行
热重载caddy reload 平滑重载,连接不中断nginx -s reload,也不中断,但要先进容器执行
HTTP/3默认开启(需要放行 UDP 443)要特定编译版本和额外配置,麻烦
WebSocket 反代默认支持,零配置要手动加 Upgrade/Connection 头
静态文件性能够用,略逊于 Nginx久经考验,高并发静态场景更强
生态与资料相对小,中文资料少极大,搜什么都有现成答案
图形后台无(有 Admin API,但不是给人点着用的)无(NPM 是第三方套壳)

一句话总结:Caddy 赢在省心,Nginx 赢在生态和极限性能。个人服务器、家用 NAS、给自己的几个服务套域名,Caddy 的体验明显更好;公司生产环境、复杂规则、团队都熟 Nginx 的,还是 Nginx。

前置准备#

和 Nginx 篇的口径完全一致,三件事:

域名解析到服务器#

在域名服务商处把 A 记录指向服务器公网 IP,比如 blog.example.com、api.example.com。Caddy 申请证书时会通过 HTTP-01 或 TLS-ALPN-01 挑战验证域名归属,解析没生效就签不下来,先确认:

Terminal window
# 在任意机器上查解析结果,应该返回你的服务器 IP
dig +short blog.example.com

放行 80 和 443 端口#

云服务器要在云厂商安全组里放行 TCP 80、TCP 443、UDP 443(UDP 443 是 HTTP/3 用的,Nginx 篇没提,Caddy 需要多放行这一条)。本机开了防火墙的再放行一遍:

Terminal window
# ufw
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
# firewalld
sudo firewall-cmd --permanent --add-port=80/tcp
sudo firewall-cmd --permanent --add-port=443/tcp
sudo firewall-cmd --permanent --add-port=443/udp
sudo firewall-cmd --reload

注意:如果宿主机上已经跑着 Nginx、Apache 或者另一个 Caddy,80/443 已经被占用,新 Caddy 容器会起不来,先 sudo ss -tlnp | grep -E ':80|:443' 看一眼谁在占。

被反代的服务先「藏好」#

反代的意义就是让外部只从 Caddy 这一个入口进来,后端服务本身不该再直接暴露公网。后端如果是 Compose 部署的,要么和 Caddy 在同一个 Compose 项目里用服务名互通(下面会讲),要么把端口只绑在 127.0.0.1 上,别写成 0.0.0.0 裸奔。

Docker Compose 部署 Caddy#

目录结构#

/opt/caddy/
├── docker-compose.yml
└── Caddyfile

docker-compose.yml#

services:
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 用,不要漏
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:

逐项说明:

  • image: caddy:2:官方镜像,锁大版本 2,既能自动拿到小版本更新,又不会哪天被大版本变更打个措手不及。
  • 443:443/udp:Caddy 2 默认开启 HTTP/3,走 UDP 443。这行不写,HTTP/3 用不了(浏览器会自动回落到 HTTP/2,不报错,但白瞎了这个特性),云安全组里也要同步放行 UDP 443。
  • ./Caddyfile:/etc/caddy/Caddyfile:ro:把宿主机上的 Caddyfile 以只读方式挂进容器官方约定的配置路径。改配置就在宿主机上改文件,然后让 Caddy 热重载(后面讲),不用进容器。
  • caddy_data:/data:最重要的一行。Caddy 把申请到的证书、私钥、ACME 账户信息全存在 /data 里,必须持久化到命名卷。
  • caddy_config:/config:Caddy 运行时自动生成的配置(autosave)放这里,同样持久化。
WARNING

caddy_data 这个卷是 Caddy 的命门,千万别删。证书和 ACME 账户都在里面,卷一丢,Caddy 重启后会把所有证书当成新证书重新申请——Let’s Encrypt 对同一域名每周签发的新证书数量有明确上限(还有限制失败验证次数),反复重签很容易撞限额,撞上之后就是整整一周签不出证书,所有站点一起裸奔 HTTP。所以:不要手贱 docker volume rm,不要用 docker compose down -v(-v 会连卷一起删),迁移服务器时记得把这个卷一起备份带走,备份方法可以参考本系列的数据备份篇。

先写一个最小 Caddyfile 把容器拉起来#

Caddyfile 的具体语法下一节细讲,这里先放一个能跑的最小版本:把 blog.example.com 反代到本机 3000 端口的一个服务。

blog.example.com {
reverse_proxy 172.17.0.1:3000
}

就这么两行。保存后启动:

Terminal window
cd /opt/caddy
docker compose up -d
docker compose logs -f caddy

日志里会看到 Caddy 依次做这些事:向 Let’s Encrypt 注册/加载账户、发起验证、最后打出 certificate obtained successfully。这时浏览器打开 https://blog.example.com,应该是小绿锁直接到位,输入 http://blog.example.com 会自动 301 跳到 HTTPS——这些在 Nginx 那边要写好几段配置的事,这里一行都没写,全是默认行为。

Caddyfile 从零讲透#

Caddyfile 的结构分三层:全局块(可选,最多一个)、站点块(一个域名一个块)、指令(写在站点块里,决定这个站点干什么)。

全局块:邮箱一定要写#

全局块写在文件最顶上,用一对大括号包起来,放全站生效的选项。个人部署最该写的是 email:

{
email admin@example.com
}
blog.example.com {
reverse_proxy 172.17.0.1:3000
}

email 是你在 ACME 证书机构(Let’s Encrypt 等)那边的账户邮箱,用途有两个:一是证书快过期又自动续期失败时,官方会往这个邮箱发告警邮件(虽然 Caddy 续期很稳,但留个后手没坏处);二是万一账户出问题,这是找回的凭据。不写也能签证书,但强烈建议写,而且要写你真实能收到信的邮箱。

全局块里其他偶尔会用到的:debug(打开调试日志,排障时临时加)、default_sni(给那些没带 SNI 的老客户端兜底,一般用不上)。记住一个原则:全局块只放全站统一的东西,单个站点的行为写进各自的站点块。

站点块:第一行是地址#

站点块的第一行是「站点地址」,几种常见写法:

example.com { ... } # 域名:自动 HTTPS,自动跳转
www.example.com { ... } # 子域名同理
example.com, www.example.com { ... } # 多个地址共用一份配置,逗号分隔
http://example.com { ... } # 显式带 http:// 前缀:只走 HTTP,不签证书
:8080 { ... } # 只写端口:任意域名/IP 访问这个端口都响应,纯 HTTP
localhost { ... } # 本地名:自动用内部 CA 签证书(见后面 tls internal)

这里藏着 Caddy 自动 HTTPS 的触发规则:站点地址是正经公网域名 → 自动走公网证书;是 localhost、内网域名或 IP → 自动用内部 CA。不用你声明,它自己判断。

核心指令:reverse_proxy 一行反代#

reverse_proxy 是整篇用得最多的指令,最简形式就是「指令 + 后端地址」:

api.example.com {
reverse_proxy 127.0.0.1:8080
}

和 Nginx 那边对比一下就知道有多省事:Nginx 要写 proxy_pass 再加四行 proxy_set_header,而 Caddy 的 reverse_proxy 默认就帮你做好了这些:自动设置 X-Forwarded-For、X-Forwarded-Proto、X-Forwarded-Host 三个头、原样透传客户端的 Host、WebSocket 直接支持。所以绝大多数场景,一行就是完整配置,不要画蛇添足。

一份完整的多站点 Caddyfile#

把前面讲的拼起来,一台服务器跑四个站点的真实配置长这样:

{
email admin@example.com
}
# 主站:静态文件由 Caddy 自己托管
example.com {
root * /srv/www
encode zstd gzip
file_server
}
# 博客:反代到本机 3000 端口的服务
blog.example.com {
reverse_proxy 172.17.0.1:3000
}
# 网盘:反代到同一 Compose 里的服务(服务名直达,下面细讲)
drive.example.com {
reverse_proxy app:8080
}
# 老域名整体跳转到新域名
old.example.com {
redir https://example.com{uri} permanent
}

注意 root * /srv/www 里的 * 是匹配符(表示匹配所有请求),是 Caddyfile 里 root 指令的固定写法,不是笔误。静态托管需要把宿主机的网站目录挂进容器,在 compose 里给 caddy 服务加一行 - ./www:/srv/www:ro。

这份文件保存后不用重启容器,执行一次热重载就生效(运维命令那一节讲)。新增一个站点,就是往文件里多加一个站点块再 reload 一次,如此而已。

常用指令逐条讲#

Caddyfile 的指令(directive)有几十个,日常真正高频的就下面这些。每个都给可直接抄的例子。

encode:开 gzip / zstd 压缩#

blog.example.com {
encode zstd gzip
reverse_proxy 172.17.0.1:3000
}

给文本类响应开压缩,网页、JSON 接口都能明显变小。zstd 比 gzip 压缩率更好、速度更快,现代浏览器都支持;两个都写上,Caddy 会按浏览器支持情况自动选,老浏览器回落 gzip。后端如果自己已经开了压缩,这里可以不写,避免双重压缩。

header:加安全响应头#

blog.example.com {
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
Referrer-Policy strict-origin-when-cross-origin
-Server
}
reverse_proxy 172.17.0.1:3000
}

header 块里每行一个头。最后一个 -Server 是特殊写法:减号表示删掉这个响应头,把 Caddy 默认带出去的 Server: Caddy 去掉,少给扫描器一点信息。后端应用自己设置的同名头优先级更高,不会被这里覆盖掉需要时可以用 header 的替换语法细调,日常上面四行够用了。

redir:跳转#

example.com {
redir https://www.example.com{uri} permanent
}
www.example.com {
reverse_proxy 172.17.0.1:3000
}

这是「裸域名跳 www」的经典写法:{uri} 是占位符,代表原始请求的完整路径(含查询字符串),保证 example.com/a?x=1 跳过去还是 /a?x=1。permanent 表示 301 永久跳转,不写默认是 302 临时跳转。注意带了域名地址的站点块会自动做 HTTP→HTTPS 跳转,这里写的是域名之间的跳转,两回事,别混。

站内路径跳转也一样:

blog.example.com {
redir /old-post /new-post permanent
reverse_proxy 172.17.0.1:3000
}

handle_path:剥掉路径前缀再转发#

有些后端服务自己不知道自己挂在子路径下,比如一个只认根路径的面板,你想用 example.com/panel/ 访问它:

example.com {
handle_path /panel/* {
reverse_proxy 172.17.0.1:9000
}
reverse_proxy 172.17.0.1:3000
}

handle_path 会先把 /panel 前缀剥掉再转发:请求 /panel/login 到后端时变成 /login,后端按根路径处理毫无感知;块外面那条 reverse_proxy 兜底处理其余所有请求。对照组是 handle 指令——用法一样但不剥前缀,后端收到的还是 /panel/login,后端本身就部署在子路径时用它。两个指令别用混,这是新手最常踩的点之一。

basicauth:给站点加一层账号密码#

临时给某个管理后台挡一道最简单的门:

admin.example.com {
basicauth {
admin $2a$14$uJ8exampleHashValueDoNotCopyThisOne9eKxYzAbCdEfGhIjKlM
}
reverse_proxy 172.17.0.1:9000
}

密码不能写明文,必须是 bcrypt 哈希。用官方命令生成:

Terminal window
docker compose exec caddy caddy hash-password
# 按提示输入两遍密码,输出的一串 $2a$14$... 就是要填进配置的哈希

生成好填进配置再 reload,访问站点会先弹浏览器的账号密码框。说明清楚:basicauth 只是「简单挡一下」,强度有限,真正的管理后台还应该有自己的登录体系,它适合套在已经有登录的后台外面做双保险,或者给纯静态的内部页面用。

file_server:托管静态站#

docs.example.com {
root * /srv/docs
encode zstd gzip
file_server
}

把目录挂进容器(compose 里加 - ./docs:/srv/docs:ro),三行就是一个带自动 HTTPS 的静态站。想让目录可浏览(显示文件列表)就写 file_server browse;想做单页应用(SPA)那种「路径不存在就回 index.html」,再加一行 try_files {path} /index.html 并用 handle 包一下,日常静态托管用不上这么细,知道有这个能力即可。

tls internal:给内网签内部证书#

有些服务只在家里/内网用,没有公网域名,只有 nas.home 这种内网名字,或者干脆用 IP 访问。公网 CA 不可能给这种名字签证书,Caddy 的解法是自己当 CA:

nas.home {
tls internal
reverse_proxy 172.17.0.1:5000
}

加上 tls internal 后,Caddy 用它内置的本地 CA 给这个站点签证书,HTTPS 照样成立。其实对 localhost、IP 地址这类站点,Caddy 默认就会自动用内部 CA,tls internal 是把这件事写明、或在域名看起来像公网域名时强制走内部签发。

代价在客户端这一侧:浏览器不认识这个自建 CA,第一次访问会报证书不受信任。把根证书装进设备就一劳永逸了——根证书文件在数据卷里:

/data/caddy/pki/authorities/local/root.crt

从容器里把它拷出来,装到手机/电脑的受信任根证书里,之后所有 tls internal 的站点都是绿锁:

Terminal window
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
WARNING

这张根证书的私钥就躺在 caddy_data 卷里,它能签出任意域名的「可信」证书。装了它的设备,等于无条件信任这台服务器冒充任何网站。所以根证书只装自己的设备,卷文件别外传——这也是 caddy_data 不能丢、更不能泄的第二个理由。

反代进阶#

反代到同一个 Compose 里的服务:用服务名#

如果后端和 Caddy 写在同一个 docker-compose.yml 里(或者接入了同一个 Docker 网络),直接用服务名当地址:

services:
caddy:
image: caddy:2
# ...同前,略
app:
image: myapp:latest
# 注意:app 不需要写 ports,它根本不用对外露头
app.example.com {
reverse_proxy app:8080
}

app:8080 里的 8080 是容器内部的端口,不是宿主机映射端口。这种写法下后端连 ports 都不用写,外部完全摸不到它,只能经 Caddy 进,干净又安全,是 Caddy(以及所有 Docker 反代)最推荐的组网方式。

反代到宿主机上的服务:172.17.0.1#

后端不在容器里、直接跑在宿主机上(比如宿主机进程监听的 3000 端口),或者在另一个 Compose 项目里已经映射到了宿主机端口,这时在 Caddyfile 里写:

blog.example.com {
reverse_proxy 172.17.0.1:3000
}

172.17.0.1 是 Docker 默认网桥 docker0 在宿主机一侧的网关地址,从容器里访问它,就等于访问宿主机本人。这和 Nginx 篇里给 NPM 填上游时用 172.17.0.1:<端口> 是同一个道理:不用把反代容器硬塞进每个业务的 Docker 网络里,反代和业务解耦,各管各的。两个注意点:

  • 后端服务监听地址不能是 127.0.0.1——那样只有宿主机自己能连,容器经网桥进来会被拒。要监听 0.0.0.0 或干脆监听 172.17.0.1,同时用防火墙挡住公网方向(云安全组不放行该端口即可,Docker 映射端口绕过 ufw 的坑在网络篇里讲过)。
  • 如果你改过默认网桥网段或用的是自定义网络,网关地址会变,进容器执行 ip route 看 default via 后面的地址,那就是宿主机。

header_up:给后端补头#

前面说过 X-Forwarded 三件套 Caddy 默认就带,但有些老应用认 X-Real-IP,或者你想额外传个自定义头,这时在 reverse_proxy 里套一个块,用 header_up:

api.example.com {
reverse_proxy 127.0.0.1:8080 {
header_up X-Real-IP {remote_host}
header_up X-Custom-Flag internal
}
}

{remote_host} 是占位符,表示客户端 IP;同类常用的还有 {host}(请求的域名)、{scheme}(http/https)。反过来 header_down 是改写后端返回的响应头,日常用得少。

还有一个进阶场景顺带提一句:如果 Caddy 前面还套了一层 CDN 或另一层代理,{remote_host} 拿到的是上一层代理的 IP 而不是真实用户 IP,需要在 reverse_proxy 块里用 trusted_proxies 声明信任的代理网段,Caddy 才会采信对方传来的 X-Forwarded-For。没套 CDN 的不用管这段。

WebSocket:默认就支持,什么都不用写#

再强调一遍因为太多人从 Nginx 过来会习惯性找这个配置:Caddy 的 reverse_proxy 天生支持 WebSocket,连接升级(Upgrade)是自动透传的。不需要任何额外指令,Nginx 那边要加的三行头在 Caddy 这边不存在。你的应用有实时推送、在线终端这类长连接,直接反代就是能用的。

负载均衡:多后端就写多个地址#

app.example.com {
reverse_proxy 172.17.0.1:3001 172.17.0.1:3002 172.17.0.1:3003 {
lb_policy round_robin
health_uri /healthz
health_interval 10s
}
}

同一行空格分隔写多个后端地址,Caddy 自动在它们之间分发请求。lb_policy 是分发策略,round_robin(轮询)最常用,还有 least_conn(谁空闲给谁)、ip_hash(同一用户固定落到同一后端)等。health_uri + health_interval 开主动健康检查:Caddy 每 10 秒探一次每个后端的 /healthz,探不通的自动摘掉、恢复了再放回来。哪怕不用负载均衡,单个后端也可以只开健康检查图个安心——不过个人部署多后端是少数,这一节知道写法即可。

进阶:DNS 验证与通配符证书#

前面用的都是默认的 HTTP-01 / TLS-ALPN-01 验证,前提是 80/443 能从公网摸到。两种情况这条路走不通:

  • 你想要一张 通配符证书(*.example.com,所有子域名共用一张)——CA 规定通配符只能走 DNS 验证;
  • 服务器的 80 端口被运营商封了,或者机器压根没有公网入口。

DNS 验证的原理是让 Caddy 调用域名服务商的 API,自动往 DNS 里塞一条 TXT 记录证明域名是你的。但官方镜像不带任何 DNS 服务商插件,需要用官方提供的构建方式把插件编进自己的镜像,这就是 xcaddy 的用途——不用怕,它就是官方镜像的标准扩展姿势:

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

把 compose 里的 image: caddy:2 换成 build: .(Dockerfile 放同目录),再在全局块里声明用哪家 DNS 验证:

{
email admin@example.com
acme_dns cloudflare {env.CF_API_TOKEN}
}
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy 172.17.0.1:3000
}

API Token 通过 compose 的 environment 以 CF_API_TOKEN 注入,别把令牌明文写进 Caddyfile 和 Git。各家服务商的插件名和参数不一样(阿里云、DNSPod 都有社区插件),用之前去 Caddy 官方的下载页确认插件仓库名,不要照抄来路不明的镜像——带插件的第三方预构建镜像等于把证书私钥托付给陌生人,这类供应链风险在安全篇里专门讲过。个人部署如果只是 80 被封,更简单的办法是先想办法放行端口,DNS 验证留给真需要通配符的人。

日常运维命令#

Caddy 的运维全部围绕一个原则:改宿主机上的 Caddyfile → 校验 → 热重载,容器不用重启,正在传输的连接不会断。命令都通过 docker compose exec caddy 在容器里执行官方 CLI。

改完先校验:validate 和 adapt#

Terminal window
# 语法校验:配置有错会明确指出第几行什么问题,没问题输出 Valid configuration
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
# 把 Caddyfile 翻译成 Caddy 实际运行的 JSON 配置(预览用)
docker compose exec caddy caddy adapt --config /etc/caddy/Caddyfile --pretty

validate 是改配置后的第一道保险,先跑它再 reload,能避免把一份写错的配置推上线。adapt 则把人类友好的 Caddyfile 展开成底层 JSON——Caddy 内部真正跑的是 JSON,Caddyfile 只是它的「方言」。排障时怀疑某条指令没按预期生效,把 adapt 的输出拿出来看最踏实。顺带记住:既然能 adapt 成 JSON,Caddyfile 不是唯一写法,只是对人最友好的那一种。

热重载:reload 不中断#

Terminal window
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

reload 会让 Caddy 平滑换上新配置:新连接走新配置,老连接在旧配置上自然结束,证书该复用的复用,不会重新申请。养成习惯——改完 Caddyfile 永远先 validate、再 reload,永远不要用重启容器代替 reload。重启虽然也能加载新配置,但会断开所有连接,而且一旦新配置有硬错误,容器直接起不来,站点全灭;reload 遇到坏配置则会拒绝应用、继续跑旧配置,安全得多。

WARNING

如果你把 Caddyfile 以只读(:ro)方式挂载(本文的 compose 就是这么写的),caddy fmt --overwrite 这类要回写文件的命令在容器里会失败,这是正常的,不是配置坏了。格式化直接在宿主机上对源文件做,或者不带 --overwrite 只看输出。只读挂载的好处是容器被攻破也改不了你的配置源文件,这个取舍值得。

格式化:caddy fmt#

Terminal window
# 输出格式化后的内容(不改文件),先看看它想把缩进改成什么样
docker compose exec caddy caddy fmt /etc/caddy/Caddyfile
# 在宿主机上直接格式化源文件(推荐姿势)
docker run --rm -v ./Caddyfile:/tmp/Caddyfile caddy:2 caddy fmt --overwrite /tmp/Caddyfile

Caddyfile 对缩进不敏感,但统一格式能让多站点配置一眼看清结构。多人维护或站点多了以后,改完顺手 fmt 一次,diff 会非常干净。

看日志:证书都签了什么#

Terminal window
docker compose logs -f caddy
docker compose logs --tail 100 caddy | grep -i "certificate"

Caddy 的日志是结构化 JSON,信息量很大。重点认这几行:

日志关键字含义
certificate obtained successfully签发成功,后面会带域名
certificate expires不是报错,只是告知到期时间(续期是自动的,看到它不用慌)
renewed certificate自动续期完成

平时不用盯,真出问题时日志基本能直接告诉你卡在哪一步——是 DNS 没解析、是验证被拒、还是限额了。

排障全流程#

证书签不下来#

站点打开是证书错误,或者日志里反复出现获取证书失败,按这个顺序查:

  1. 解析生效了吗:dig +short 你的域名,返回的必须是这台服务器的公网 IP。刚改的解析等 10 分钟再试,别拿缓存结果误判。用了 CDN 代理(比如 Cloudflare 小黄云)的,先切回仅 DNS 模式签一次,签好再开代理,或者改用 DNS 验证方式,这是最高频的翻车点。
  2. 80/443 真的通吗:在另一台机器上 curl -v http://你的域名,看能不能摸到服务器。云安全组、本机防火墙、运营商封 80,任何一层都能让验证失败。Caddy 的 HTTP-01 验证走 80,TLS-ALPN-01 走 443,两个都堵死必然签不下来。
  3. 端口被别人占了吗:sudo ss -tlnp | grep -E ':80|:443'。宿主机上还跑着一个没关的 Nginx 是经典剧本——Caddy 容器日志里会有 bind: address already in use。
  4. 是不是撞限额了:日志里出现 too many certificates already issued 或 rateLimited 字样,就是 Let’s Encrypt 限额。最常见的触发方式就是前面 warning 里说的:反复删 caddy_data 卷反复重签。没有别的办法,只能等窗口过去(以天和周计),期间把卷保护好,别再折腾。

访问站点返回 502#

Caddy 本身活着、证书也没问题,但页面是 502,说明它联系不上后端:

  1. 后端起了吗:docker compose ps 或到后端所在项目里看容器状态,后端自己挂了 Caddy 当然只能 502。
  2. 地址写对了吗:日志里搜 dial tcp,后面的报错最说明问题——connection refused 是目标端口没人听;no such host 是服务名解析不了(多半是两个容器根本不在同一个 Docker 网络里,或者服务名写错);连到 127.0.0.1 却想找宿主机服务,是忘了容器里的 127.0.0.1 是容器自己,该用 172.17.0.1。
  3. 后端监听地址对吗:后端只监听 127.0.0.1 时,同网络的 Caddy 用服务名也连不上它(它只认本机回环)。让后端监听 0.0.0.0,暴露面靠「不映射 ports + 只经 Caddy 入口」来控制,而不是靠绑回环。

配置改了不生效#

十有八九是这三件事之一:

  1. 改完没 reload。Caddy 不会监视文件变化,改 Caddyfile 后必须执行 caddy reload(或重启容器)。这是新手第一大坑,没有之一。
  2. 改错了文件。确认你编辑的就是 compose 里挂载的那个 Caddyfile,docker compose config 能看到实际挂载路径;进容器 cat /etc/caddy/Caddyfile 对一眼内容是不是你以为的那份。
  3. reload 的其实是旧配置。caddy reload 要带对 --config 路径;图省事直接 docker compose restart caddy 也能加载新配置(代价是断连接),两种方式都无效,就该怀疑改的根本不是同一份文件了。

HTTP/3 的小坑#

HTTP/3 默认开着,一般不用管。唯一常见的现象是:某些网络环境(公司网、部分运营商)会拦截或劣化 UDP 流量,表现为个别访客打开网站慢半拍——浏览器会自动回落到 HTTP/2/TCP,不会真打不开,属可接受范围。真遇到整片网络都异常,在全局块里关掉即可:全局块加一行 servers { protocols h1 h2 } 只保留 HTTP/1.1 和 HTTP/2。这属于极端情况的兜底,正常部署别关,自找麻烦。

和 Nginx 怎么选:选型小结#

把这一篇和 Nginx 篇 合起来看,选型其实很简单:

场景推荐
个人服务器 / NAS,就想给几个服务套域名上 HTTPSCaddy:一行配置,证书全自动,省心到底
频繁加新站点、改配置Caddy:加个站点块 + reload,半分钟的事
内网服务也要 HTTPSCaddy:tls internal 是 Nginx 方案里没有的顺手
需要图形后台、点点鼠标就完成Nginx Proxy Manager:Caddy 没有官方 GUI
高并发静态资源、复杂 rewrite/缓存规则Nginx:生态和调优空间更大
团队协作、大家只会 NginxNginx:工具再好,也得团队会用才行

小结#

  • 现在用的都是 Caddy 2,镜像 caddy:2,配置是 Caddyfile v2 语法;网上 v1 老教程直接跳过。
  • Caddy 的核心竞争力是自动 HTTPS 开箱即用:公网域名自动签发续期、HTTP 自动跳 HTTPS、WebSocket 和 X-Forwarded 头默认就带,Nginx 那边一摞配置在这里通常只剩一行 reverse_proxy。
  • 部署时两个卷必须持久化,其中 caddy_data 是命门:丢了会反复重签撞 Let’s Encrypt 限额,内部 CA 的根证书也在里面,只能装自己的设备。
  • Caddyfile 三层结构记牢:全局块写 email、站点块第一行是地址、块里放指令;reverse_proxy、encode、header、redir、handle_path、basicauth、file_server、tls internal 这八条覆盖日常九成需求。
  • 反代组网优先用服务名直达(后端连 ports 都不用开),反代宿主机服务用 172.17.0.1:<端口> 与业务解耦。
  • 运维铁律:改配置先 caddy validate 再 caddy reload,别拿重启容器当重载;排障先看日志,证书、502、不生效三大类按本文顺序查基本都能定位。
用 Docker 部署 Caddy:自动 HTTPS 反向代理
https://blog.anexns.com/posts/docker-caddy-https/
作者
Anexns
发布于
2026-11-15
许可协议
CC BY-NC-SA 4.0