入口・match・path 変換・upstream の順に一つの request を調べる。Caddyfile の解析成功、読込成功、期待通りの応答をそれぞれ確認する。
upstream が受け取る path は変わり得る
図を準備しています
prefix 除去後に upstream が見る pathhandle は path を保持し handle_path は /api を除去する。/api 単体は /api/* に一致せず fallback へ進む。
{"id":"ops-caddy-path","title":"prefix 除去後に upstream が見る path","summary":"handle は path を保持し handle_path は /api を除去する。/api 単体は /api/* に一致せず fallback へ進む。","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":"静的 fallback","incoming":"client request","keep":"handle:保持","no":"/api/* に不一致。fallback が処理し upstream に届かない。","scope":"matcher は /api/*、追加 rewrite なし。通信せず path を選んで比較する。","slash":"/api/","strip":"handle_path:除去","upstream":"upstream 受信","users":"/api/users","web":"/web","yes":"一致。handle_path は転送前に /api を除去する。upstream が必要なら保持する。"}}
完全な site 例:static file と /api/* を分ける。実 Caddyfile に統合し domain・root・上流を変更する。
example.com {
handle_path /api/* {
reverse_proxy 127.0.0.1:3000
}
handle {
root * /srv/www
file_server
}
log
}
例では /api/users が /users になる。アプリが prefix を期待するなら handle を使う。/api/* は /api 単体には一致しない。handle は排他的で fallback が残りを処理する。directory listing は明示的 browse が必要。
Caddy の環境から上流へ直接接続する
curl -v http://127.0.0.1:3000/users
コンテナ内の loopback は自身。同じ network namespace から実到達先を試し、状態・本文を確認する。
curl -v --resolve example.com:443:127.0.0.1 https://example.com/api/users
--resolve は名前・port・IP。同じ resource の二経路を比較し、/api/・裸 /api・static も確認する。
journalctl -u caddy -b -n 100 --no-pager
runtime log と access log を分ける。出力先が file の場合もある。502 の upstream error から protocol/network を調べる。
変換後の同じ資源を比較する。--resolve は URL の名前・SNI を保って入口 IP を指定する。Host header だけでは SNI は変わらない。-k で TLS 問題を隠さない。
| 現象 | 確認 |
| 502 | upstream address・protocol・namespace・TLS を Caddy 側から確認 |
| 404 | 応答元・matcher・prefix・root・権限 |
| 403 | ファイル権限・認証・upstream log。code だけで原因を決めない |
複数 backend では request を受けられる状態を表す health endpoint を使う。probe 成功は全依存の保証ではなく、同一 host の複数 port は host 障害への冗長化ではない。
別 site 例:全 site を複数上流へ転送する。前の同 domain block を置き換える。/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
}
}
adapt・validate 後に実 service を再読込
binary・設定 path・実行主体を確認する
caddy version
plugin は caddy list-modules で確認する。別 binary を入れても service が使うとは限らない。
systemctl cat caddy.service
実 path に合わせ、旧 file を保存して編集する。例は独立の方式で、同じ domain block をそのまま連結しない。
caddy fmt --diff /etc/caddy/Caddyfile
確認後、必要なら別途 --overwrite。整形と意味検証は別。
caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile --pretty
route 順・match・rewrite を読む。adapt は待ち受けを起動せず、機密設定を含み得る。
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
可能なら service と同じ主体・環境で file access も検証する。失敗を直してから reload。
sudo systemctl reload caddy.service
reload 対応で実 file を参照する unit が前提。終了・log を読み、拒否時は旧設定の稼働を確認する。
systemctl status caddy.service --no-pager
API・裸 prefix・static・health を個別検証する。戻す場合は旧 file を復元し validate/reload と同じ request を行う。
fmt は整形、adapt は JSON 生成、validate は module 初期化まで行うが全実依存を試験しない。service の権限と ExecReload の参照先を確認する。旧設定を保持し失敗時の稼働版を確認する。
reload でも全長時間接続の無停止は保証されない。WebSocket 等の close 方針を検証する。Admin GET /config は機密を含み得るため公開しない。標準 list-certificates ではなく配信 TLS と storage 設定を調べる。
証明書の管理主体を一つにする
自動 HTTPS は DNS・challenge または DNS plugin 権限と永続 storage が前提。issuer は変更可能。手動証明書には外部更新・配置・reload が必要。証明書参照。HTTP/3 は UDP と client 対応も要る。
独立 mTLS site:client CA file が存在し service から読めること。上節の検証・reload で配備する。
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 対応版を使う。client-ca.pem は client 発行 CA で失効リストではない。mTLS はアプリ認可を代替しない。既定の転送 header 処理を理解し既知前段だけ trusted_proxies に設定する。
mTLS は拒否・許可の両方を試す
上記の mTLS 設定を読込後、client 証明書なしの拒否と承認済み identity の成功を試す。CA・秘密鍵は承認されたものを使う。
curl -v https://internal.example.com/
TLS 拒否が期待結果。正常応答なら別入口・client_auth 未反映を疑う。私有 server CA は --cacert を使い -k にしない。
curl -v --cert client.crt --key client.key https://internal.example.com/
TLS 成功後もアプリの認可を確認する。信頼された証明書でも全業務権限とは限らない。
handle_path · reverse_proxy · CLI · TLS
edge 入口の実設定は WildWindStudio EdgeOne runbook を参照。