写在前面:先分清 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 2 | Nginx(原生) |
|---|---|---|
| 自动 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 挑战验证域名归属,解析没生效就签不下来,先确认:
# 在任意机器上查解析结果,应该返回你的服务器 IPdig +short blog.example.com放行 80 和 443 端口
云服务器要在云厂商安全组里放行 TCP 80、TCP 443、UDP 443(UDP 443 是 HTTP/3 用的,Nginx 篇没提,Caddy 需要多放行这一条)。本机开了防火墙的再放行一遍:
# ufwsudo ufw allow 80/tcpsudo ufw allow 443/tcpsudo ufw allow 443/udp
# firewalldsudo firewall-cmd --permanent --add-port=80/tcpsudo firewall-cmd --permanent --add-port=443/tcpsudo firewall-cmd --permanent --add-port=443/udpsudo 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└── Caddyfiledocker-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}就这么两行。保存后启动:
cd /opt/caddydocker compose up -ddocker 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 访问这个端口都响应,纯 HTTPlocalhost { ... } # 本地名:自动用内部 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 哈希。用官方命令生成:
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 的站点都是绿锁:
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crtWARNING这张根证书的私钥就躺在
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 builderRUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2COPY --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
# 语法校验:配置有错会明确指出第几行什么问题,没问题输出 Valid configurationdocker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
# 把 Caddyfile 翻译成 Caddy 实际运行的 JSON 配置(预览用)docker compose exec caddy caddy adapt --config /etc/caddy/Caddyfile --prettyvalidate 是改配置后的第一道保险,先跑它再 reload,能避免把一份写错的配置推上线。adapt 则把人类友好的 Caddyfile 展开成底层 JSON——Caddy 内部真正跑的是 JSON,Caddyfile 只是它的「方言」。排障时怀疑某条指令没按预期生效,把 adapt 的输出拿出来看最踏实。顺带记住:既然能 adapt 成 JSON,Caddyfile 不是唯一写法,只是对人最友好的那一种。
热重载:reload 不中断
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfilereload 会让 Caddy 平滑换上新配置:新连接走新配置,老连接在旧配置上自然结束,证书该复用的复用,不会重新申请。养成习惯——改完 Caddyfile 永远先 validate、再 reload,永远不要用重启容器代替 reload。重启虽然也能加载新配置,但会断开所有连接,而且一旦新配置有硬错误,容器直接起不来,站点全灭;reload 遇到坏配置则会拒绝应用、继续跑旧配置,安全得多。
WARNING如果你把 Caddyfile 以只读(
:ro)方式挂载(本文的 compose 就是这么写的),caddy fmt --overwrite这类要回写文件的命令在容器里会失败,这是正常的,不是配置坏了。格式化直接在宿主机上对源文件做,或者不带--overwrite只看输出。只读挂载的好处是容器被攻破也改不了你的配置源文件,这个取舍值得。
格式化:caddy fmt
# 输出格式化后的内容(不改文件),先看看它想把缩进改成什么样docker compose exec caddy caddy fmt /etc/caddy/Caddyfile
# 在宿主机上直接格式化源文件(推荐姿势)docker run --rm -v ./Caddyfile:/tmp/Caddyfile caddy:2 caddy fmt --overwrite /tmp/CaddyfileCaddyfile 对缩进不敏感,但统一格式能让多站点配置一眼看清结构。多人维护或站点多了以后,改完顺手 fmt 一次,diff 会非常干净。
看日志:证书都签了什么
docker compose logs -f caddydocker compose logs --tail 100 caddy | grep -i "certificate"Caddy 的日志是结构化 JSON,信息量很大。重点认这几行:
| 日志关键字 | 含义 |
|---|---|
certificate obtained successfully | 签发成功,后面会带域名 |
certificate expires | 不是报错,只是告知到期时间(续期是自动的,看到它不用慌) |
renewed certificate | 自动续期完成 |
平时不用盯,真出问题时日志基本能直接告诉你卡在哪一步——是 DNS 没解析、是验证被拒、还是限额了。
排障全流程
证书签不下来
站点打开是证书错误,或者日志里反复出现获取证书失败,按这个顺序查:
- 解析生效了吗:
dig +short 你的域名,返回的必须是这台服务器的公网 IP。刚改的解析等 10 分钟再试,别拿缓存结果误判。用了 CDN 代理(比如 Cloudflare 小黄云)的,先切回仅 DNS 模式签一次,签好再开代理,或者改用 DNS 验证方式,这是最高频的翻车点。 - 80/443 真的通吗:在另一台机器上
curl -v http://你的域名,看能不能摸到服务器。云安全组、本机防火墙、运营商封 80,任何一层都能让验证失败。Caddy 的 HTTP-01 验证走 80,TLS-ALPN-01 走 443,两个都堵死必然签不下来。 - 端口被别人占了吗:
sudo ss -tlnp | grep -E ':80|:443'。宿主机上还跑着一个没关的 Nginx 是经典剧本——Caddy 容器日志里会有bind: address already in use。 - 是不是撞限额了:日志里出现
too many certificates already issued或rateLimited字样,就是 Let’s Encrypt 限额。最常见的触发方式就是前面 warning 里说的:反复删caddy_data卷反复重签。没有别的办法,只能等窗口过去(以天和周计),期间把卷保护好,别再折腾。
访问站点返回 502
Caddy 本身活着、证书也没问题,但页面是 502,说明它联系不上后端:
- 后端起了吗:
docker compose ps或到后端所在项目里看容器状态,后端自己挂了 Caddy 当然只能 502。 - 地址写对了吗:日志里搜
dial tcp,后面的报错最说明问题——connection refused是目标端口没人听;no such host是服务名解析不了(多半是两个容器根本不在同一个 Docker 网络里,或者服务名写错);连到127.0.0.1却想找宿主机服务,是忘了容器里的 127.0.0.1 是容器自己,该用172.17.0.1。 - 后端监听地址对吗:后端只监听
127.0.0.1时,同网络的 Caddy 用服务名也连不上它(它只认本机回环)。让后端监听0.0.0.0,暴露面靠「不映射 ports + 只经 Caddy 入口」来控制,而不是靠绑回环。
配置改了不生效
十有八九是这三件事之一:
- 改完没 reload。Caddy 不会监视文件变化,改 Caddyfile 后必须执行
caddy reload(或重启容器)。这是新手第一大坑,没有之一。 - 改错了文件。确认你编辑的就是 compose 里挂载的那个 Caddyfile,
docker compose config能看到实际挂载路径;进容器cat /etc/caddy/Caddyfile对一眼内容是不是你以为的那份。 - 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,就想给几个服务套域名上 HTTPS | Caddy:一行配置,证书全自动,省心到底 |
| 频繁加新站点、改配置 | Caddy:加个站点块 + reload,半分钟的事 |
| 内网服务也要 HTTPS | Caddy:tls internal 是 Nginx 方案里没有的顺手 |
| 需要图形后台、点点鼠标就完成 | Nginx Proxy Manager:Caddy 没有官方 GUI |
| 高并发静态资源、复杂 rewrite/缓存规则 | Nginx:生态和调优空间更大 |
| 团队协作、大家只会 Nginx | Nginx:工具再好,也得团队会用才行 |
小结
- 现在用的都是 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、不生效三大类按本文顺序查基本都能定位。