本ページの位置づけ
Caddyは自宅ラボの「ゼロトラスト統合基盤」において、DMZ側でHTTPSを一元的に受け止める要のコンポーネントです。詳細な設計思想はAlmaLinux側の「ゼロトラスト基盤構築ガイド(v5決定版)」のPhase 4にも記載していますが、本ページはRocky Linux 9(Caddyが実際に稼働するホスト)の視点から、より詳しい手順として独立してまとめたものです。
目次
0. Phase 4 の目的と全体像を理解する
作業を始める前に、Phase 4で何を構築するのか・なぜそれが必要なのかを理解してください。手順の意味が分かると、トラブル発生時の対処が格段に楽になります。
0-1. Caddy とは何か
プロ技・解説:Caddyの役割を一言で言うと
Caddy(キャディ)は「インターネットからのHTTPS通信を受け取り、適切な内部サーバーへ振り分けるプログラム」です。具体的には以下の3つの仕事をします。
- ① HTTPS通信の暗号を解除する(TLS終端):ブラウザとの暗号化通信(https://)をCaddyが担当。内部サーバー(Headscale/Authentik)は暗号化不要になります。
- ② ホスト名によって振り分ける(リバースプロキシ):hs.yourdomain.jp → Headscale(192.168.2.13)/auth.yourdomain.jp → Authentik(192.168.2.16)/www.yourdomain.jp → 既存Webサーバー(172.16.1.2)
- ③ SSL証明書を自動取得・自動更新する:Let's Encryptから証明書を自動取得し、90日ごとの更新も自動で行う(手動作業は一切不要)。
0-2. なぜCaddyをDMZ(172.16.1.18)に置くのか
設計ポイント:DMZ配置の重要性
DMZとは何か:DMZ(Demilitarized Zone:非武装地帯)とは、インターネットとSERVERセグメントの間に設ける中間ネットワークです。外部からアクセスされる可能性があるサーバーをここに置き、内部を守ります。
なぜ内部に置いてはいけないのか:もしCaddyをSERVERセグメント(192.168.2.x)に置くと、インターネット→FortiGate→SERVER(直接)となり、インターネットの生トラフィックが直接内部に入ってきます。これはPhase 1で構築したセグメント分離を無意味にします。
DMZに置くと何が良いのか:インターネット→FortiGate→DMZ:Caddy→(FW制御下)→SERVERとなり、SERVERにはCaddyからの必要最小限の通信しか届きません。万が一Caddyが攻撃されても、内部は守られます。
0-3. Phase 4 が解決する4つの問題
| # | 解決する問題 | 解決方法 |
|---|---|---|
| ① | ポート競合:Rocky Linuxが:80/:443を占有しているためHeadscaleが公開できない | Caddyが:80/:443を受け取りホスト名(SNI)で振り分ける → Rocky Linuxと共存可能 |
| ② | 証明書管理の手間:certbot + cronでの手動管理が必要 | CaddyがLet's Encryptから自動取得・自動更新 → 一切手動作業不要 |
| ③ | 将来の拡張性:新サービスを追加するたびに複雑な設定が必要 | Caddyfileに1ブロック追記するだけ → Grafana/OpenZitiも簡単に追加 |
| ④ | セキュリティ設計:インターネットが直接内部に届く | CaddyをDMZに配置 → 内部には制御された通信のみ |
0-4. Phase 4 完成後の全体通信フロー
※ Caddy→SERVERへの通信はFortiGateのポリシーで制御されています(送信元: 172.16.1.18のみ許可/ポート: 8080と9000のみ)
1. 事前準備と確認事項
作業を始める前に、以下を確認してください。Phase 3の修正点も含まれています。
1-1. Rocky Linux 9 に SSH でログインする
Phase 4の作業はすべてRocky Linux 9(DMZ: 172.16.1.2)で行います。AlmaLinux(SERVER)ではありません。作業するホストを間違えないよう注意してください。
# Windowsのコマンドプロンプトまたは PowerShell から
ssh ユーザー名@172.16.1.2
# ログイン後、ホスト名を確認する
hostname
# Rocky Linux 9 のホスト名が表示されればOK
1-2. Phase 3(Headscale)が正常稼働しているか確認する
CaddyはHeadscaleに通信を転送します。Headscaleが動いていないとCaddyを起動しても意味がありません。AlmaLinuxから確認します。
ポイント:確認場所に注意
この確認コマンドはAlmaLinux 9(SERVER)で実行します。Rocky Linux(DMZ)ではありません。別のターミナルウィンドウを開いてAlmaLinuxにSSHログインしてから実行してください。
# AlmaLinux 9(SERVER)で実行する
sudo podman ps
# 以下の3つが Up と表示されていれば Phase 3 は正常稼働中
# NAMES STATUS
# headscale-pod-infra Up XX minutes
# headscale-db Up XX minutes
# headscale Up XX minutes (healthy) ← (healthy) が重要
注意:headscale が (healthy) になっていない場合
Phase 3のトラブルシューティング記録を参照して、問題③(noise.private_key_path)と問題④(private_key_path)の修正が適用されているか確認してください。
確認コマンド(AlmaLinuxで実行):sudo grep private_key_path /srv/containers/headscale/config/config.yaml
以下の2行が表示されれば修正済み:private_key_path: /var/lib/headscale/private.key と noise: private_key_path: /var/lib/headscale/noise_private.key
1-3. DMZ ネットワークのブリッジ名を確認する(Phase 3 修正点)
Phase 3のトラブルシューティングで判明したように、ネットワークインターフェース名は環境によって異なります。Phase 4でも同様に、作業前に必ず確認が必要です。
プロ技・解説:なぜブリッジ名を確認するのか
DMZのネットワーク定義(local_dmz.network)では、Caddyコンテナをどの物理ネットワークに接続するかを指定します。Phase 3では「SERVER用のNIC名(eth0)」が環境と一致しなかったため、ネットワークが起動できませんでした。Phase 4でも同様に、DMZ用のブリッジ名を事前に確認してから設定ファイルに記述する必要があります。
# Rocky Linux 9(DMZ)で実行する
# ── ブリッジの一覧を表示 ─────────────────────────
ip link show type bridge
# 出力例(環境により異なる):
# 5: br0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ... ← br0 を使う
# または
# 5: br-dmz: <BROADCAST,MULTICAST,UP,LOWER_UP> ... ← br-dmz を使う
# ブリッジが見つからない場合は以下でも確認できる
ip link show | grep -E "^[0-9]+:"
# 表示された名前をメモしておく(後で使います)
注意:ブリッジが見つからない場合
ip link show type bridge で何も表示されない場合は、DMZ用のブリッジが作成されていない可能性があります。Phase 1(インフラ基盤構築)のDMZネットワーク設定が完了しているか確認してください。
または、以下のコマンドですべてのNICを確認し、DMZに使うNICを特定してください:ip addr show(172.16.1.xのアドレスが付いているNIC名を使います)
1-4. DDNS の確認
CaddyがLet's Encryptから証明書を取得するには、ドメイン名がサーバーの外部IPに正しく紐づいている必要があります。
# インターネットに繋がっている端末(自分のPC等)から実行する
# ドメインのIPを確認する
nslookup hs.yourdomain.mydns.jp
# または
ping hs.yourdomain.mydns.jp
# 返ってきたIPがご自宅のWAN(外部)IPと一致していればOK
2. 作業開始:Caddy の構築手順
ディレクトリと Caddyfile の作成(Rocky Linux 9 側)
コンテナが削除・再作成されてもLet's Encryptの証明書やCaddyの設定が消えないよう、ホストOS(Rocky Linux 9)に保存領域を作成します。
ポイント:ディレクトリの役割
/srv/containers/caddy/Caddyfile → プロキシのルール//srv/containers/caddy/data/ → Let's Encrypt証明書のキャッシュ(再起動しても残る)//srv/containers/caddy/config/ → Caddyの内部設定キャッシュ
sudo mkdir -p /srv/containers/caddy/config
sudo mkdir -p /srv/containers/caddy/data
# SELinuxコンテキストを正しく設定する
sudo restorecon -Rv /srv/containers/caddy
# 作成できたか確認する
ls -la /srv/containers/caddy/
Caddyfileは「どのドメインへのアクセスを、どのサーバーに転送するか」を定義するファイルです。Caddyの設定の心臓部です。
注意:2か所を必ず書き換えてください
yourdomain.mydns.jp → ご自身のDDNSドメイン名/172.16.1.2 → Rocky Linuxの実際のIPアドレス(確認方法: ip addr show | grep 172.16)
sudo tee /srv/containers/caddy/Caddyfile << 'EOF'
# ════════════════════════════════════
# Caddyfile - Phase 4 設定
# ════════════════════════════════════
# ── Headscale(Phase 3 / SERVER: 192.168.2.13)──
hs.yourdomain.mydns.jp {
encode gzip zstd
reverse_proxy 192.168.2.13:8080 {
header_up Upgrade {http.request.header.Upgrade}
header_up Connection {http.request.header.Connection}
}
}
# ── 既存Webサーバー(Rocky Linux DMZ: 172.16.1.2)──
www.yourdomain.mydns.jp {
encode gzip zstd
reverse_proxy 172.16.1.2:80 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
}
}
# ── Authentik(Phase 5 / SERVER: 192.168.2.16)──
auth.yourdomain.mydns.jp {
encode gzip zstd
reverse_proxy 192.168.2.16:9000
}
# ── 将来追加予定(Phase 6: Grafana)──
# grafana.yourdomain.mydns.jp {
# encode gzip zstd
# reverse_proxy 192.168.2.12:3000
# }
EOF
cat /srv/containers/caddy/Caddyfile
ポイント:Phase 3 トラブルシューティングで学んだこと
Phase 3では設定ファイルの不備(private_key_path未設定)が原因で何度も起動・失敗を繰り返しました。Caddyには設定ファイルの文法を事前確認できる機能があります。コンテナを起動する前に確認する習慣をつけることで、同様のトラブルを防ぐことができます。
sudo podman run --rm \
-v /srv/containers/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z \
docker.io/library/caddy:<version> \
caddy validate --config /etc/caddy/Caddyfile
# 成功時: Valid configuration
# エラー時: Error: parsing caddyfile tokens: ...
Quadlet ファイルの作成と起動
Phase 3と同様に、Quadlet(systemdネイティブ)でコンテナを管理します。
注意:ブリッジ名を確認してから実行してください
「1-3 DMZネットワークのブリッジ名を確認する」で確認したブリッジ名を使います。このガイドではbr0を例として使います。ご自身の環境がbr-dmzやvirbr0などの場合は置き換えてください。
sudo tee /etc/containers/systemd/local_dmz.network << 'EOF'
[Network]
# ★ 重要: bridge=br0 の "br0" は実際のブリッジ名に書き換えてください
# 確認方法: ip link show type bridge
Driver=bridge
Options=bridge=br0
Subnet=172.16.1.0/24
Gateway=172.16.1.1
EOF
cat /etc/containers/systemd/local_dmz.network
プロ技・解説:ダイジェスト固定とは何か
タグ(caddy:<version>)は人が読みやすいが同じタグで中身が変わる可能性があります。ダイジェスト(caddy@sha256:...)はイメージの内容から計算した固有の値で、同じダイジェストなら必ず同じイメージです。本番・学習環境を問わず、ダイジェスト固定は再現性の高い構成を保つためのベストプラクティスです。
sudo podman pull docker.io/library/caddy:<version>
CAD_DIGEST=$(sudo podman image inspect docker.io/library/caddy:<version> \
--format '{{index .RepoDigests 0}}')
echo "Caddy digest: ${CAD_DIGEST}"
# 出力例: Caddy digest: docker.io/library/caddy@sha256:a1b2c3d4...
# ⚠️ この変数はシェルを閉じると消えます。Quadletファイル作成まで同じターミナルで続けてください
# CAD_DIGEST 変数が設定されているか確認してから実行
echo ${CAD_DIGEST}
sudo tee /etc/containers/systemd/caddy.container << EOF
[Unit]
Description=Caddy Edge Reverse Proxy (DMZ)
After=network-online.target
[Container]
Image=${CAD_DIGEST}
ContainerName=caddy
Network=local_dmz.network
IP=172.16.1.18
Volume=/srv/containers/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z
Volume=/srv/containers/caddy/data:/data:Z
Volume=/srv/containers/caddy/config:/config:Z
HealthCmd=caddy version || exit 1
HealthInterval=30s
[Service]
Restart=always
[Install]
WantedBy=multi-user.target
EOF
cat /etc/containers/systemd/caddy.container
sudo /usr/libexec/podman/quadlet -dryrun 2>&1 | grep -E "caddy|error|Error"
プロ技・解説:Phase 3の修正点:enableではなくstartを使う
Quadletで生成されたユニットは/run/systemd/generator/に置かれる一時ファイルのため、enableコマンドは使えません。startコマンドで起動します。OS再起動後の自動起動はQuadletが自動的に処理するため、enableは不要です。
sudo systemctl daemon-reload
sudo systemctl list-unit-files | grep caddy
# caddy.service generated と表示されれば認識成功
sudo systemctl start caddy
sudo systemctl status caddy --no-pager
# Active: active (running) と表示されれば成功
sudo journalctl -u caddy -f --no-pager
# 成功時のログ例:
# {"level":"info","msg":"obtained certificate","domain":"hs.yourdomain.mydns.jp"}
# {"level":"info","msg":"serving initial configuration"}
# エラーのみ抽出して確認
sudo journalctl -u caddy --no-pager | grep -i "error\|failed\|certificate" | tail -20
注意:証明書取得に失敗する主な原因
- 原因1 FortiGateのVIP設定が未完了 → WAN:80/WAN:443が172.16.1.18に転送されていないと取得できません。STEP 3を先に完了させてから再試行してください。
- 原因2 DDNSが更新されていない →
nslookup hs.yourdomain.mydns.jpで返ってくるIPを確認してください。 - 原因3 同じドメインで短時間に何度も試行した → Let's Encryptには失敗回数の制限があります(1時間に5回まで)。エラーが続く場合は1時間待ってから再試行してください。
FortiGate の設定(VIP / ファイアウォールポリシー)
FortiGateの設定はPhase 4の中で最も重要な作業です。この設定が正しくないとCaddyが証明書を取得できず、外部からのアクセスも届きません。
設計ポイント:FortiGateに設定する2種類のルール
① VIP(バーチャルIP):「インターネットから来た特定のポートへの通信を、内部のどのIPに転送するか」を定義します。
WAN:443 → 172.16.1.18:443(CaddyへのHTTPS転送)/WAN:80 → 172.16.1.18:80(Let's Encrypt証明書更新用)
② ファイアウォールポリシー:「どのネットワークからどのネットワークへの通信を許可するか」を定義します。VIPを作成しただけでは通信は通りません。A. WAN→DMZ(インターネットからCaddyへの通信を許可)/B. DMZ→SERVER(CaddyからHeadscale/Authentikへの通信を許可)
| VIP名(任意) | 外部IP(WAN) | 外部ポート | 転送先IP(DMZ) | 転送先ポート | 用途 |
|---|---|---|---|---|---|
| VIP_ReverseProxy_HTTPS | WAN側グローバルIP | TCP 443 | 172.16.1.18 | 443 | Headscale/AuthentikのHTTPS通信 |
| VIP_ReverseProxy_HTTP | WAN側グローバルIP | TCP 80 | 172.16.1.18 | 80 | Let's Encrypt証明書取得・更新用 |
ポイント:FortiGate GUI でのVIP作成手順
- Policy & Objects → Virtual IPs → Create New → Virtual IP
- Name: VIP_ReverseProxy_HTTPS(任意)/Interface: WAN/External IP Address: WAN側グローバルIP/Map to IPv4 Address: 172.16.1.18/Port Forwarding: 有効/Protocol: TCP/External Service Port: 443/Map to IPv4 Port: 443 を入力してOK
- 同様にVIP_ReverseProxy_HTTP(ポート80)も作成
| 設定項目 | 設定値 | 補足説明 |
|---|---|---|
| Name | Policy_WAN_to_Proxy | 任意の名称 |
| Incoming Interface | WAN | 外部インターフェース |
| Outgoing Interface | DMZ | DMZインターフェース |
| Source | all | 送信元は制限しない(インターネット全体) |
| Destination | VIP_ReverseProxy_HTTPS と VIP_ReverseProxy_HTTP | 作成したVIPを両方選択 |
| Service | HTTP, HTTPS | またはCustomでTCP 80と443を指定 |
| Action | ACCEPT | 通信を許可 |
| IPSプロファイル | default等 | 侵入防御を有効にする(推奨) |
| ログ | すべてのセッション | トラブル時の調査のため有効化 |
セキュリティ:この設定がゼロトラスト境界の核心です
この設定を正しく行わないと、①CaddyからHeadscale/Authentikに通信できずリバースプロキシが動かない、②設定を緩めすぎると他のDMZ機器(Rocky Linux等)からもSERVERに直接アクセスできてしまいセキュリティが崩れる、という2つの問題が発生します。
「送信元をCaddy(172.16.1.18)のみ」「ポートを必要最小限(8080と9000のみ)」この2点を厳守してください。
| 設定項目 | 設定値 | 補足説明 |
|---|---|---|
| Name | Policy_Proxy_to_Internal | 任意の名称 |
| Incoming Interface | DMZ | DMZインターフェース |
| Outgoing Interface | SERVER | SERVERインターフェース |
| Source | 172.16.1.18(Caddyのアドレスオブジェクト) | Caddyのみ許可。Rocky Linux等は含めない |
| Destination | 192.168.2.13(Headscale)/192.168.2.16(Authentik) | SERVER内の転送先2台のみ |
| Service | カスタムTCP 8080(Headscale用)/9000(Authentik用) | 他のポートは許可しない |
| Action | ACCEPT | 通信を許可 |
| IPSプロファイル | 有効(推奨) | 内部通信もIPSで監視 |
| ログ | すべてのセッション | 必ず有効化(セキュリティ監査のため) |
ポイント:FortiGateでアドレスオブジェクトを作成する方法
1. Policy & Objects → Addresses → Create New → Address/Name: Caddy_DMZ/Type: Subnet/IP/Netmask: 172.16.1.18/32(/32で単一IPを指定)
2. 同様にHeadscale_SERVER(192.168.2.13/32)、Authentik_SERVER(192.168.2.16/32)も作成
動作テスト
FortiGateの設定が完了したら、外部からのアクセスと証明書取得が正しく機能しているか確認します。
sudo systemctl is-active caddy
sudo systemctl status caddy --no-pager
sudo journalctl -u caddy --no-pager | grep -i "certificate"
# 3つのドメイン分(hs / auth / www)が表示されれば証明書取得完了
sudo ls -la /srv/containers/caddy/data/
# certificates/ ディレクトリが作成されていれば成功
モバイル回線から実行してください
自宅のWi-Fiからは内部通信になるため正確なテストになりません。モバイル回線(LTE/5G)に接続したスマートフォンやノートPCから実行してください。
curl -v https://hs.yourdomain.mydns.jp 2>&1 | grep -E "SSL|certificate|Connected|HTTP"
# 成功時の出力例:
# * SSL connection using TLSv1.3 ...
# * Server certificate: subject: CN=hs.yourdomain.mydns.jp
# * issuer: C=US; O=Let's Encrypt; CN=R11
# < HTTP/2 200
curl -v https://www.yourdomain.mydns.jp 2>&1 | grep -E "SSL|certificate|HTTP"
確認ポイント:SSL証明書エラーが出ない=成功
①curlの出力にSSL connection using TLSv1.3が表示される/②issuerが"Let's Encrypt"になっている/③SSL証明書エラー(ERR_CERT_NOT_TRUSTED等)が出ない。ブラウザでアクセスした場合はアドレスバーに鍵アイコンが表示されれば成功です。
curl -s http://192.168.2.13:8080/ 2>&1 | head -3
# 何らかの応答があれば疎通成功(FortiGateのDMZ→SERVERポリシーが正しく設定されている)
3. Phase 3 の修正が Phase 4 に与える影響(重要)
Phase 3のトラブルシューティングで発見した問題のうち、Phase 4およびPhase 5(Authentik)の設計に影響するものをまとめます。
3-1. OIDC の issuer URL の変更
プロ技・解説:旧設計と新設計の違い
旧設計(v3マニュアルまで):HeadscaleからAuthentikへのOIDC通信に内部IPを直接使用(issuer: "https://192.168.2.16:9443/application/o/headscale-app/")。問題点:自己署名証明書のためinsecure_skip_verify: trueが必要だった。
新設計(v5/Phase 4導入後):CaddyがLet's Encryptの正規証明書でHTTPSを終端するため、issuer: "https://auth.yourdomain.mydns.jp/application/o/headscale-app/"。メリット:正規の証明書を使うためinsecure_skip_verifyは不要。セキュリティが向上する。
3-2. Phase 5(Authentik)の config.yaml への影響
注意:Phase 5 作業時の注意
Phase 5でHeadscaleにOIDC設定を追加する際、以下の新設計に従ってください。
oidc:
# 正しい:Caddy経由の正規HTTPS URLを使う
issuer: "https://auth.yourdomain.mydns.jp/application/o/headscale-app/"
client_id: "(Authentikで発行されたClient ID)"
client_secret: "(Authentikで発行されたClient Secret)"
scope: ["openid", "profile", "email"]
strip_email_domain: true
# insecure_skip_verify は不要(削除する)
# 古い設計(使わない):
# issuer: "https://192.168.2.16:9443/..." ← 内部IP直接指定は使わない
# insecure_skip_verify: true ← 不要になったため削除
4. Phase 4 完了チェックリスト
以下をすべて確認できればPhase 4は正常に完了しています。1つでも確認できない場合は、対応するステップに戻って確認してください。
◆ Caddyコンテナの動作確認
- Caddyコンテナが起動しているsudo systemctl is-active caddy → active と表示される
- Let's Encrypt証明書が取得されているsudo journalctl -u caddy --no-pager | grep "obtained certificate" → 各ドメイン分が表示される
- 証明書ファイルが保存されているsudo ls /srv/containers/caddy/data/ → certificates/ ディレクトリが存在する
◆ 外部アクセスの確認(モバイル回線から実行)
- HTTPSでHeadscaleにアクセスできるcurl -v https://hs.yourdomain.mydns.jp → SSLエラーなし/Let's Encrypt証明書
- HTTPSでAuthentik管理画面にアクセスできるcurl -v https://auth.yourdomain.mydns.jp → SSLエラーなし
- 既存Webサイトが引き続きHTTPSで表示されるhttps://www.yourdomain.mydns.jp がブラウザで正常表示される
◆ FortiGateポリシーの確認
- WAN → DMZ ポリシーが有効になっているFortiGateのログで外部からのアクセスが記録されていることを確認
- DMZ → SERVER ポリシーがCaddy(172.16.1.18)のみに制限されている172.16.1.2(Rocky Linux)から192.168.2.13へpingが届かないことを確認
◆ Phase 3 修正点の反映確認
- systemctl start でCaddyを起動している(enableは使っていない)Quadletユニットはenable不可のためstartを使う
- ブリッジ名(br0等)を環境に合わせて設定しているcat /etc/containers/systemd/local_dmz.network で Options=bridge= を確認
Phase 4 完了
CaddyがDMZでHTTPSを一元管理し、Let's Encrypt証明書が自動取得されました。既存のRocky Linux(Web/Mail)と共存しながら、Headscaleが標準ポート443で公開されています。次はPhase 5(Authentikによる MFA追加)に進みます。