从一次请求经过的入口、匹配、路径改写和上游开始定位。Caddyfile 能解析、配置已加载、线上响应符合预期,是三个分别验证的结果。
上游收到的路径可能已不同
正在呈现知识画面
剥离前缀后,上游看见哪个 URL 路径handle /api/* 保留路径;handle_path /api/* 剥离 /api。裸 /api 不满足 /api/*,会进入本示例的兜底分支。
{"id":"ops-caddy-path","title":"剥离前缀后,上游看见哪个 URL 路径","summary":"handle /api/* 保留路径;handle_path /api/* 剥离 /api。裸 /api 不满足 /api/*,会进入本示例的兜底分支。","height":600,"html":"<main><p class=\"quiet\" data-i18n=\"scope\"></p><div class=\"presets\" role=\"group\"><button type=\"button\" data-mode=\"handle\" aria-pressed=\"false\" data-i18n=\"keep\"></button><button type=\"button\" data-mode=\"handle_path\" aria-pressed=\"false\" data-i18n=\"strip\"></button></div><div class=\"presets\" role=\"group\"><button type=\"button\" data-url=\"/api/users\" aria-pressed=\"false\" data-i18n=\"users\"></button><button type=\"button\" data-url=\"/api\" aria-pressed=\"false\" data-i18n=\"bare\"></button><button type=\"button\" data-url=\"/api/\" aria-pressed=\"false\" data-i18n=\"slash\"></button><button type=\"button\" data-url=\"/web\" aria-pressed=\"false\" data-i18n=\"web\"></button></div><div class=\"flow\"><div class=\"row\"><h3 data-i18n=\"incoming\"></h3><p class=\"code\" id=\"incoming\"></p></div><div class=\"arrow\">↓ /api/*</div><div class=\"row\"><h3 data-i18n=\"upstream\"></h3><p class=\"code\" id=\"outgoing\"></p></div></div><div class=\"explain\"><div class=\"stable \"><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"yes\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"no\"></p><p id=\"message\" aria-live=\"polite\"></p></div></div></main>\n","css":"main {\n display: grid;\n gap: 18px;\n min-width: 0;\n}\np {\n overflow-wrap: anywhere;\n}\n.controls {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n align-items: center;\n}\n.presets {\n display: flex;\n flex-wrap: wrap;\n}\n.presets button {\n flex: 1 1 85px;\n font-size: 15px;\n}\n.cards {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 10px;\n}\n.card {\n border: 1px solid var(--rule);\n border-radius: 10px;\n padding: 14px;\n min-width: 0;\n background: var(--surface);\n}\n.card h3 {\n font-size: 15px;\n color: var(--muted);\n margin-bottom: 8px;\n}\n.card strong {\n font-size: 23px;\n font-weight: 500;\n overflow-wrap: anywhere;\n}\n.code {\n font-family: var(--viz-font-mono);\n font-size: 15px;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.stable {\n display: grid;\n min-width: 0;\n}\n.stable > * {\n grid-area: 1/1;\n margin: 0;\n}\n.stable > .reserve {\n visibility: hidden;\n pointer-events: none;\n}\n.explain {\n border-left: 3px solid var(--accent);\n padding: 12px 14px;\n background: var(--accent-soft);\n}\n.arrow {\n text-align: center;\n color: var(--accent);\n font-size: 25px;\n}\n.row {\n padding: 12px;\n border: 1px solid var(--rule);\n border-radius: 8px;\n min-width: 0;\n}\n.row.on {\n border-color: var(--accent);\n background: var(--accent-soft);\n}\n.row.off {\n color: var(--muted);\n}\n.bar {\n height: 18px;\n background: var(--surface);\n border: 1px solid var(--rule);\n border-radius: 5px;\n overflow: hidden;\n margin-top: 8px;\n}\n.bar > i {\n display: block;\n height: 100%;\n background: var(--accent);\n width: 0;\n transition: width 0.22s;\n}\n.checks {\n display: grid;\n gap: 8px;\n}\n.checks label {\n display: flex;\n gap: 10px;\n align-items: center;\n padding: 9px;\n border: 1px solid var(--rule);\n border-radius: 7px;\n font-size: 16px;\n}\n.checks input {\n width: 18px;\n height: 18px;\n accent-color: var(--accent);\n flex-shrink: 0;\n}\nlabel.range {\n display: block;\n font-size: 16px;\n}\nlabel.range output {\n float: right;\n font-variant-numeric: tabular-nums;\n}\n.pair {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 10px;\n}\n.flow {\n display: grid;\n gap: 6px;\n}\n.quiet {\n font-size: 14px;\n color: var(--muted);\n}\n.badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 5px;\n background: var(--accent-soft);\n color: var(--accent);\n}\nbutton {\n overflow-wrap: anywhere;\n}\n.large {\n font-size: 32px;\n color: var(--accent);\n font-weight: 500;\n}\n.layers {\n display: grid;\n gap: 12px;\n}\n.layer {\n border-left: 3px solid var(--rule);\n padding-left: 12px;\n}\n.layer.on {\n border-color: var(--accent);\n}\n@media (max-width: 520px) {\n .cards,\n .pair {\n grid-template-columns: 1fr;\n }\n .card {\n padding: 12px;\n }\n .card strong {\n font-size: 22px;\n }\n}\n#outgoing {\n min-height: 3.2em;\n}\n","js":"const $ = (s) => document.querySelector(s);\nconst $$ = (s) => [...document.querySelectorAll(s)];\nconst t = (k) => viz.t(k);\nconst put = (id, v) => ($(\"#\" + id).textContent = v);\nconst say = (k) => put(\"message\", t(k));\nconst state = (v) => ($(\"main\").dataset.state = JSON.stringify(v));\nconst press = (key, v) =>\n $$(\"[data-\" + key + \"]\").forEach((b) =>\n b.setAttribute(\"aria-pressed\", String(b.dataset[key] === v)),\n );\nlet mode = \"handle_path\",\n url = \"/api/users\";\nfunction draw() {\n const matched = url.startsWith(\"/api/\"),\n out = matched ? (mode === \"handle_path\" ? url.slice(4) : url) : null;\n put(\"incoming\", url);\n put(\"outgoing\", out === null ? \"∅ · \" + t(\"fallback\") : out);\n say(matched ? \"yes\" : \"no\");\n press(\"mode\", mode);\n press(\"url\", url);\n state({ mode, url, matched, out });\n}\n$$(\"[data-mode]\").forEach(\n (b) =>\n (b.onclick = () => {\n mode = b.dataset.mode;\n draw();\n }),\n);\n$$(\"[data-url]\").forEach(\n (b) =>\n (b.onclick = () => {\n url = b.dataset.url;\n draw();\n }),\n);\ndraw();\n","audio":false,"strings":{"bare":"/api","fallback":"兜底静态分支","incoming":"客户端请求","keep":"handle:保留","no":"没有匹配 /api/*,请求由本示例兜底分支处理,上游未收到请求。","scope":"固定 matcher /api/*,无额外 rewrite;选择路径直接比较,不发送请求。","slash":"/api/","strip":"handle_path:剥离","upstream":"上游收到","users":"/api/users","web":"/web","yes":"匹配成功;handle_path 在反代前剥离 /api。上游若期望完整前缀,应保留它。"}}
完整站点示例:静态文件与 /api/* 分流。将站点块合并到实际 Caddyfile,域名、root 和上游按部署替换。
example.com {
handle_path /api/* {
reverse_proxy 127.0.0.1:3000
}
handle {
root * /srv/www
file_server
}
log
}
该例让 /api/users 到上游变成 /users;若应用本身挂在 /api,改用 handle /api/* 保留前缀。/api/* 不匹配裸 /api,需按应用设计单独重定向或匹配。handle 组互斥,兜底静态分支只处理未命中请求;file_server 不默认开启目录列表,需明确 browse。
先从 Caddy 所在环境直连上游
curl -v http://127.0.0.1:3000/users
容器部署时 127.0.0.1 指容器自身,需在同一网络命名空间测试实际可达地址。核对状态与正文,先确认上游本身正常。
curl -v --resolve example.com:443:127.0.0.1 https://example.com/api/users
--resolve 的三项是名称、端口、入口 IP;这里与上一条是同一资源的两种路径。测试 /api/、/api 以及静态路径,检查边界是否符合应用设计。
journalctl -u caddy -b -n 100 --no-pager
区分运行日志与 access log;log 指令的输出目的地可能被配置为文件,不一定全部在 journal。从 502 的 upstream error 再定位协议或网络。
两个 URL 对照改写后的同一资源。--resolve 同时保留 URL 中的主机名与 TLS SNI,替换的 IP 需为实际入口;只改 Host header 不等于改变 TLS SNI。不要用 -k 把 TLS 故障隐藏为“反代已正常”。
| 现象 | 检查 |
| 502 | 上游监听地址、协议、容器命名空间、连接/TLS 错误;先从 Caddy 所在环境访问上游 |
| 404 | 哪一层返回、matcher 是否命中、前缀是否被剥离、静态 root 和权限 |
| 403 | 文件访问、认证/授权及上游日志,不能只按 HTTP 码猜测归属 |
多个后端的最小配置如下。健康探针必须返回能代表“可接请求”的状态;探针正常不代表所有业务依赖正常,单机多端口也不是跨主机容灾。
替代站点示例:整个站点转发给多个后端。它替换前一个同域名块,不能直接追加成重复站点;如需只代理 /api/*,保留对应 matcher。
example.com {
reverse_proxy 127.0.0.1:3000 127.0.0.1:3001 {
lb_policy round_robin
health_uri /health
health_interval 10s
}
}
先适配检查,再按实际服务重载
确认运行二进制、配置路径与服务身份
caddy version
插件还需 caddy list-modules 核对,安装了另一个二进制不代表服务正在使用它。
systemctl cat caddy.service
下文 /etc/caddy/Caddyfile 按实际替换。保存旧文件后编辑;三份配置示例各自是独立站点方案,不能把同域名块直接拼起来。
caddy fmt --diff /etc/caddy/Caddyfile
确认后如需格式化再单独加 --overwrite;fmt 不是语义校验。
caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile --pretty
核对路由顺序、match 与 rewrite;adapt 不启动监听,输出可能含机密配置。
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
尽量以实际服务身份和同一环境执行,检查文件可读性;失败先修复再进入 reload。
sudo systemctl reload caddy.service
此例要求 unit 支持 reload 且指向当前文件。检查返回值与日志;重载拒绝时确认旧配置是否仍在服务,而不是继续重启。
systemctl status caddy.service --no-pager
沿本页 /api/users、裸 /api、静态页和上游健康接口分别验证。需要回退时恢复旧文件,重新 validate / reload 并做同样请求。
fmt 是格式,adapt 是生成 JSON;validate 进一步加载/provision 模块,但仍不证明全部运行时依赖和网络路径。尽量用实际服务身份验证文件权限。确认 unit 的 ExecReload 指向当前文件后再 reload;不支持 reload 的自定义 unit 用部署规定的 caddy reload。保留上一版配置,失败时确认旧配置仍在服务,再修正或回退。
reload 支持配置切换,不承诺所有长连接都无感;WebSocket/流式连接的关闭与延迟策略需单独设置验证。Admin API 的 GET /config 可读实际 JSON,但可能含敏感配置;不要为了查看而把默认本机管理口暴露出去。标准 CLI 没有通用 list-certificates 子命令,用线上 TLS 与存储配置验证。
自动 HTTPS 与手工证书各有一个维护者
公开域名自动 HTTPS 需要正确 DNS、挑战可达性或 DNS 插件权限,以及可写且持久的证书存储。Caddy 可使用多个 issuer,不固定等于只用 Let’s Encrypt。手工 tls cert key 则需要外部续期、部署与 reload 链,见 证书。HTTP/3 还需实际入口 UDP 443 与客户端支持,TCP HTTPS 正常不能单独证明它可用。
独立 mTLS 站点示例:客户端 CA 文件必须存在且服务账户可读。配置发布按上节的校验与重载流程执行。
internal.example.com {
tls {
client_auth {
mode require_and_verify
trust_pool file /etc/caddy/client-ca.pem
}
}
reverse_proxy 127.0.0.1:3000
}
适用于支持 trust_pool 的 Caddy 版本;client-ca.pem 是签发客户端证书的受信任 CA,不是吊销列表。mTLS 先验证证书,再由应用完成业务授权;不要把受信任客户端证书文件当“黑名单”。标准 reverse_proxy 已处理常用 X-Forwarded-*,有可信前置代理时配置 trusted_proxies,避免任意覆盖并信任外来 X-Forwarded-For。
mTLS 用正反两个请求验证
仅在上述 mTLS 站点配置已加载后执行:先不带客户端证书测试应被拒绝,再用受信任的客户端身份请求。CA 文件与客户端私钥应来自该站点批准的配置。
curl -v https://internal.example.com/
本例预期 TLS 拒绝;若正常返回业务内容,检查是否访问错入口或未加载 client_auth。私有服务器 CA 需加 --cacert,不能用 -k。
curl -v --cert client.crt --key client.key https://internal.example.com/
TLS 成功后仍检查应用授权结果;持有受信任证书不等于具有全部业务权限。
handle_path · reverse_proxy · CLI · TLS
边缘入口的实际配置另见 WildWindStudio EdgeOne runbook。