Caddy 本地 HTTPS 与反向代理工具手册
从两个端口开始收拢开发入口
一个常见的联调现场是这样的:前端运行在 5173,API 运行在 3000,第二个 API 实例又占了 3001。开发者记得每个端口,浏览器却把它们视为不同来源;Secure Cookie、本地 OAuth 回调和 WebSocket 也因此表现得与共享环境不同。
接下来把入口统一成 app.localhost。第一步只代理 HTTP,确认路由链没有问题;随后让同一个地址切换到自动 HTTPS,再逐步加入第二实例、健康检查、上游 TLS 和可回滚配置变更。
Caddy 可以用系统软件包、官方二进制或容器运行。需要长期作为系统服务时,应从 官方安装入口 选择对应平台的软件仓库;需要把配置和版本随项目交付时,官方容器更容易复现。无论采用哪一种方式,先记录实际二进制和模块集合:
caddy version
caddy list-modules --packages第一条命令应输出 Caddy 版本,第二条用于确认当前构建是否包含 DNS Provider 等非标准模块。找不到 caddy 表示安装路径尚未进入 PATH;模块列表中没有目标 Provider 时,继续写 DNS challenge 配置也不会成功。自定义构建应按 扩展与 xcaddy 管理插件来源、Caddy 版本和构建产物摘要。
下面使用 Docker Compose 建立可重复现场,镜像固定为 caddy:2.11.4。升级时先查看 Caddy 官方 release 和官方镜像标签,再记录目标平台实际拉取的 digest,避免同一份配置在不同机器上得到不同二进制。
name: caddy-entry-lab
services:
caddy:
image: caddy:2.11.4
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./caddy:/etc/caddy
- caddy_data:/data
- caddy_config:/config
networks: [gateway]
app-v1:
image: hashicorp/http-echo:1.0.0
command: ["-listen=:5678", "-text=version=v1"]
networks: [gateway]
app-v2:
image: hashicorp/http-echo:1.0.0
command: ["-listen=:5678", "-text=version=v2"]
networks: [gateway]
networks:
gateway:
name: caddy-entry-lab-gateway
volumes:
caddy_data: {}
caddy_config: {}Admin API 保持默认的容器内 localhost:2019,不发布到宿主机,也不暴露给 gateway 网络中的应用容器。能访问 Admin API 的主体可以替换整个运行配置;文件工作流通过 docker compose exec caddy caddy reload ... 在容器内部调用该回环入口。
创建 caddy/Caddyfile,先显式使用 HTTP:
http://app.localhost {
reverse_proxy app-v1:5678
}启动之前先让 Caddy 在真实容器中解析配置:
docker compose run --rm caddy caddy fmt --diff /etc/caddy/Caddyfile
docker compose run --rm caddy caddy adapt --config /etc/caddy/Caddyfile --pretty
docker compose run --rm caddy caddy validate --config /etc/caddy/Caddyfile
docker compose up -d
curl -i http://app.localhost/fmt --diff 只报告格式差异;adapt 把 Caddyfile 转成原生 JSON,并把适配警告写到标准错误;validate 还会加载和 provision 模块,因此能发现证书文件不存在、模块缺失等问题。最后一个请求应返回 200 和 version=v1。若返回连接拒绝,先检查 80 端口是否被其他进程占用;若返回 502,进入 Caddy 容器解析 app-v1 并访问 5678,不要先修改浏览器代理。
docker compose ps
docker compose logs --tail=100 caddy
docker compose exec caddy wget -qO- http://app-v1:5678/让同一域名进入自动 HTTPS
把站点地址中的 http:// 删除:
app.localhost {
reverse_proxy app-v1:5678
}主机名会激活 Caddy 的 Automatic HTTPS。公开 DNS 名通常通过 ACME 获取公共 CA 证书;localhost、IP 地址和不能获得公共证书的内部名称由本地 CA 签发。自动 HTTPS还会建立 HTTP 到 HTTPS 的重定向,因此 auto_https off 并不等同于“改为提供 HTTP”;要提供明文 HTTP,应像上一节那样显式写 http:// 或 HTTP 端口。
先校验再热加载:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
curl -vk https://app.localhost/
curl -I http://app.localhost/第一次请求应完成 TLS 握手并返回 version=v1,但宿主机尚未信任容器创建的根 CA 时,去掉 -k 会出现证书链错误。第二个请求应收到到 HTTPS 的重定向。-k 只能用于证明“路由通而信任链未通”,不能留在项目脚本里充当成功标准。
tls internal 可以把单个站点明确固定到内部 issuer:
app.localhost {
tls internal
reverse_proxy app-v1:5678
}对 .localhost 来说,这通常与自动选择本地 CA 的结果一致;它的价值是把该站点的签发意图写清楚。全局 local_certs 会影响所有符合条件的站点,影响面更大。公共域名不能把 ACME 故障“兜底”为内部 CA,否则外部客户端会在故障时突然收到不受信任的证书。
信任容器真正使用的根 CA
官方容器把证书、私钥和 PKI 状态放在 /data,自动保存的 JSON 配置放在 /config。两者都应使用持久卷,但安全含义不同:删除 /config 主要影响 API 工作流的恢复;删除 /data 会改变本地 CA 身份并可能触发重新签发。
从运行实例导出根证书,而不是在宿主机另起一个 Caddy 后执行 caddy trust:
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy/root.crtWindows 导入当前用户信任库不需要管理员权限;导入本机所有用户信任库则必须使用管理员终端:
# 当前用户
certutil -user -addstore -f Root .\caddy\root.crt
# 本机所有用户,需管理员终端
certutil -addstore -f Root .\caddy\root.crtLinux 发行版和 macOS 的导入方式不同,官方容器运行说明 给出了对应命令。浏览器、Java、Node.js、移动设备和 CI 镜像可能使用独立信任库,宿主浏览器成功不能证明所有调用方都已建立信任。
导入后必须去掉 -k 复验:
curl -v https://app.localhost/成功证据是证书名称匹配、证书链验证通过并返回 version=v1。只允许分发 root.crt;/data/caddy/pki/authorities/local/private/root.key 是根私钥,泄露后应按 PKI 事件处理,不能靠删除浏览器缓存解决。
把第二实例放进流量池
将两个静态 upstream 放入同一个 reverse_proxy:
app.localhost {
reverse_proxy app-v1:5678 app-v2:5678 {
lb_policy least_conn
health_uri /
health_interval 10s
health_timeout 2s
health_status 200
health_fails 2
health_passes 2
fail_duration 30s
max_fails 2
unhealthy_status 500 502 503
lb_try_duration 2s
lb_retry_match method GET HEAD
}
}主动健康检查按时钟请求 health_uri;被动检查从真实代理请求中累计失败。fail_duration 大于零才会启用被动失败记忆,max_fails 决定窗口内多少次失败后暂时摘除 upstream。健康路径应代表实例已经能接收当前类型的流量,但不应把偶发的外部依赖抖动直接放大成所有实例同时离池。
重试范围故意限制在 GET 和 HEAD。对支付、下单等非幂等写请求,连接在“上游已处理但响应丢失”时重试会产生重复副作用。更复杂的重放判断需要业务幂等键,而不是继续放宽网关 matcher。
验证两个实例都能获得请求:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
for i in 1 2 3 4 5 6; do curl -sS https://app.localhost/; echo; done输出中应同时出现 version=v1 和 version=v2。least_conn 根据并发连接选择 upstream,不承诺六次短请求严格五五分;验证重点是两个版本均可达,而不是小样本比例。
用两种故障观察健康状态
先停止一个容器:
docker compose stop app-v1
for i in 1 2 3 4 5; do curl -sS https://app.localhost/; echo; done
docker compose logs --since=2m caddy等待两个主动检查周期后,请求应稳定返回 version=v2,日志中应留下 app-v1 的解析、连接或健康状态证据。若仍间歇 502,检查健康参数是否已进入 active config,而不是只看磁盘上的 Caddyfile。
恢复实例:
docker compose start app-v1
for i in 1 2 3 4 5 6; do curl -sS https://app.localhost/; echo; done连续通过 health_passes 后,version=v1 应重新出现。还应测试“进程存活但健康端点失败”的场景;在真实应用中临时让 /health/ready 返回 503,确认端口仍可连接而 Caddy 已停止分配业务流量。这样才能区分容器退出、连接失败和 readiness 失败。
Caddy Admin API 还提供 upstream 状态入口,但默认回环绑定不应为了取证改成业务网络可达。这个实验使用结构化日志、连续请求和容器状态保存故障证据;需要自动抓取 active upstream 状态时,由只挂载权限化 Admin Unix socket 的管理 sidecar 执行。
动态 upstream 模块与静态列表的行为并不完全相同。官方 reverse_proxy 说明明确指出主动健康检查不运行在动态 upstream 上;接入 SRV、A/AAAA 等动态来源时,应让发现源尽量只返回可用目标,并重新设计健康与负载策略。reverse_proxy 健康检查与负载均衡
路径、长连接和受控灰度
同一入口承载 Web 与 API 时,先用互斥 handle 保留原始路径:
app.localhost {
handle /api/* {
reverse_proxy app-v1:5678 app-v2:5678
}
handle {
reverse_proxy web:5173
}
}无 matcher 的 handle 是 fallback。改成 handle_path /api/* 会在转发前剥离 /api,应用重定向、Cookie Path、OpenAPI 地址和静态资源引用都要一起复验。只有确实需要禁止 Caddyfile 指令重排时才使用 route,否则手工顺序会增加审查负担。
小流量验证可以使用独立域名、Header 或 Cookie matcher,把测试身份路由到 v2;稳定流量仍走 v1。不要依赖连接级负载策略表达业务灰度比例,也不要在 access log 中遗漏命中的版本。Caddy 标准模块不覆盖所有限流和复杂流量治理需求;引入第三方模块时,插件构建、漏洞修复和 Caddy 升级必须作为同一个供应链单元维护。
WebSocket 能自动完成 Upgrade,但默认会在旧配置卸载时关闭关联流。连接规模较大时,可评估 stream_close_delay,让旧流在配置 reload 后延迟关闭:
reverse_proxy ws-app:8080 {
stream_close_delay 5m
}延迟关闭减少重连风暴,也意味着旧配置关联连接会继续存在一段时间。故障修复要求立即切断旧流时,这个延迟反而会拖慢处置;应以连接数量、会话时长和变更风险决定,而不是全局复制固定值。
校验 HTTPS 上游而不是关闭验证
当 app-v2 自身改为 HTTPS,并由企业 CA 为 api.internal.example 签发证书时,Caddy 必须同时验证 CA、证书名称和 SNI:
app.localhost {
reverse_proxy https://api.internal.example {
transport http {
tls_trust_pool file /etc/caddy/upstream-ca.pem
tls_server_name api.internal.example
}
}
}先从 Caddy 所在网络验证上游证书,而不是在宿主机上验证另一条路径:
docker run --rm --network caddy-entry-lab-gateway \
-v "$PWD/caddy/upstream-ca.pem:/ca.pem:ro" \
alpine:3.23 sh -ec \
'apk add --no-cache openssl >/dev/null &&
openssl s_client -connect api.internal.example:443 \
-servername api.internal.example -CAfile /ca.pem </dev/null'预期结果是 Verify return code: 0 (ok)。出现 unable to get local issuer certificate 应补正确 CA 链;出现名称不匹配应修正上游 DNS、证书 SAN 或 tls_server_name。tls_insecure_skip_verify 会同时关闭名称和链路验证,不应作为长期修复。
Caddy 2.11.0 起,reverse_proxy 连接 HTTPS upstream 时会自动把上游 Host 调整为上游地址对应的主机;更早版本通常需要显式设置:
reverse_proxy https://api.internal.example {
header_up Host {upstream_hostport}
}升级前先执行 caddy version,再让上游回显实际 Host,同时抓取 TLS SNI 和证书名称。不要把 2.11+ 行为复制到旧二进制,也不要在新版本中保留与业务虚拟主机冲突的旧覆盖。当前语义可在 HTTPS upstream 官方说明 复核。
让配置变更留下可恢复证据
caddy reload 通过 Admin API 原子加载新配置。新配置 provision 失败时,旧配置继续运行;但新配置成功加载后才暴露出的业务错误,不会自动替团队判断并回退。因此每次变更都要保存磁盘配置、active config 和冒烟结果。
cp ./caddy/Caddyfile ./caddy/Caddyfile.previous
docker compose exec caddy caddy adapt \
--config /etc/caddy/Caddyfile --pretty > ./caddy/adapted.previous.json
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
curl -fsS https://app.localhost/成功证据至少包括:validate 退出码为零、reload 返回成功、HTTPS 请求通过信任校验、关键路径和 WebSocket 都正常。若冒烟失败,立即恢复上一版文件并再次走校验与 reload:
cp ./caddy/Caddyfile.previous ./caddy/Caddyfile
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
curl -fsS https://app.localhost/若 Caddyfile 已经不可用,也可以把之前适配并保存的原生 JSON 复制进容器,再通过容器内回环 Admin API 整体加载:
docker compose cp ./caddy/adapted.previous.json caddy:/tmp/adapted.previous.json
docker compose exec caddy caddy validate --config /tmp/adapted.previous.json
docker compose exec caddy caddy reload --config /tmp/adapted.previous.json
curl -fsS https://app.localhost/必须由外部自动化执行细粒度 API 修改时,改用权限化 Unix socket,并只把 socket 挂载给受控管理 sidecar。API 修改还要处理 ETag 并发:收到 412 Precondition Failed 就重新读取和计算,不得覆盖另一位操作者刚提交的配置。不要为了让自动化方便而把 TCP Admin 端口开放给业务网络。
每次 API 变更默认会把最新 JSON 保存到配置目录。官方容器中通常可在 /config/caddy/autosave.json 找到它:
docker compose exec caddy ls -l /config/caddy/autosave.json以 Caddyfile 启动的进程仍会优先加载指定文件;--resume 才会从 autosave 恢复,且会覆盖 --config。因此团队必须选择一种主要所有权模型:文件工作流以仓库 Caddyfile 为事实源,API 工作流以 active config、ETag、autosave 和 --resume 为事实源。两套方式同时写而没有同步规则,会让 Git 内容无法代表运行状态。详细语义见 Caddy Admin API 与 命令行说明。
把证书状态与配置回滚分开
配置回滚通常只恢复路由和模块参数;/data 包含私钥、证书、OCSP 和本地 CA,不应随着每次配置发布一起覆盖。恢复旧 /data 可能倒退已续期证书或重新启用已撤销材料,而删除它会改变内部根身份。
团队至少应定期完成以下演练:备份 /data,在隔离环境恢复,以原域名启动 Caddy,确认内部根指纹、叶证书签发和客户端信任保持一致。根私钥疑似泄露时,处置顺序是隔离存储、轮换 CA、重新签发、撤销旧信任并更新所有客户端,不是简单恢复昨天的数据卷。
Admin API 只绑定受控回环或权限化 Unix socket;DNS challenge token 通过受限环境变量或密钥系统注入,不写入 Caddyfile、镜像和日志。前方存在 CDN 或负载均衡器时,trusted_proxies 必须使用真实、持续维护的 CIDR,并优先在全局 server 选项中配置;否则攻击者可能伪造 X-Forwarded-For,让鉴权、限流和审计得到错误客户端地址。
完成联调后,保留以下可复验结果:实际 Caddy 版本与模块清单、适配警告、active config、证书链与根指纹、两个 upstream 的健康状态、故障注入日志、长连接 reload 结果以及上一版配置恢复后的冒烟输出。它们共同证明入口可运行、可解释,也确实能退回已知状态。
