本ページは「Phase 3 構築トラブルシューティング完全記録」で判明した3つの不具合(Quadletユニットの起動方法/物理NIC名の決め打ち/config.yamlの秘密鍵設定漏れ)を修正済みです。このページの手順通りに進めれば、同じ問題は発生しません。修正箇所には ✓ 修正済み の印を付けています。
目次
Ⅰ. 設計思想とアーキテクチャ
なぜ Caddy を DMZ に置くのか
インターネットからのトラフィックは必ず DMZ(境界セグメント)で一度受け止める必要があります。v4以前のガイドでは Caddy を SERVER(192.168.2.x)に配置していましたが、これはインターネットの生トラフィックが DMZ を素通りして SERVER に直接届く設計であり、Phase 1 で構築したセグメント分離の意味を根本から損なうものでした。
v5 では Caddy を DMZ(172.16.1.18)に正しく配置します。証明書の取得・HTTPS終端・リバースプロキシをすべて DMZ 内で完結させ、SERVER内の Headscale・Authentik には FortiGate で制御された内部通信のみを通します。
図① 全体トポロジー(ネットワーク構成図)
DMZ セグメント
172.16.1.0/24SERVER セグメント
192.168.2.0/24(信頼ゾーン)図② TLS終端と通信フロー
重要
Caddy が HTTPS を一元管理するため、Headscale / Authentik は TLS 設定が不要(HTTPのみ)。証明書の自動更新も Caddy が担当し、insecure_skip_verify: true も不要(Caddy 経由の正規 HTTPS URL を使用)。
図③ OIDC認証シーケンス
図④ FortiGateセグメント境界(厳格ルール)
| 設定項目 | 値 |
|---|---|
| VIP: WAN:80 | → DMZ:172.16.1.18:80(Let's Encrypt証明書更新用) |
| VIP: WAN:443 | → DMZ:172.16.1.18:443(HTTPS通信) |
| DMZ→SERVER 送信元 | 172.16.1.18(Caddyのみ許可、他のDMZ機器は不可) |
| DMZ→SERVER 宛先ポート | TCP 8080(Headscale)、TCP 9000(Authentik)のみ |
| IPS(侵入防御) | 有効 |
| ログ | すべてのトラフィックを記録 |
SERVER: Headscale / Authentik / Grafana は、DMZからの非認可通信をすべてDROPします。
検証済みバージョン一覧
| サービス | コンテナイメージ | 役割 |
|---|---|---|
| Caddy | docker.io/library/caddy:<version> | TLS終端・リバースプロキシ |
| Headscale | docker.io/headscale/headscale:<version> | VPNコントロールプレーン |
| Authentik | ghcr.io/goauthentik/server:2026.4 | MFA/OIDC認証基盤 |
| PostgreSQL | docker.io/library/postgres:<version>-alpine | Headscale/Authentik用DB |
| Redis | docker.io/library/redis:7.2.5-alpine | Authentikセッションキャッシュ |
Ⅱ. 事前準備・ファイルパス対応表・初期硬化
ファイルパス対応表(全フェーズ)
| サービス | ホストOS側のパス | コンテナ内パス | 役割 |
|---|---|---|---|
| Headscale | /srv/containers/headscale/config/config.yaml | /etc/headscale/config.yaml | メイン設定 |
| Headscale | /srv/containers/headscale/config/acl.hujson | /etc/headscale/acl.hujson | ACL(アクセス制御) |
| Headscale | /srv/containers/headscale/data/ | /var/lib/headscale/ | 鍵・データ |
| Headscale | /srv/containers/headscale/.env | (環境変数として読込) | DBパスワード |
| Headscale | /srv/containers/headscale/postgres/ | /var/lib/postgresql/data | Headscale用DB |
| Caddy | /srv/containers/caddy/Caddyfile | /etc/caddy/Caddyfile | プロキシ設定 |
| Caddy | /srv/containers/caddy/data/ | /data | 証明書キャッシュ |
| Authentik | /srv/containers/authentik/.env | (環境変数として読込) | 各種シークレット |
| Authentik | /srv/containers/authentik/postgres/ | /var/lib/postgresql/data | Authentik用DB |
| Authentik | /srv/containers/authentik/redis/ | /data | セッションキャッシュ |
初期セキュリティ硬化(AlmaLinux 9 / Rocky Linux 9 共通)
SELinux 状態確認
sudo getenforce
# 期待する出力: Enforcing
Permissive や Disabled の場合は本ガイドの手順が正しく動作しません。
firewalld の有効化とマスカレード設定
Caddy コンテナ(DMZ: 172.16.1.18)は Let's Encrypt との通信のためにインターネットへ出る必要があります。ホストOSでIPマスカレード(NAT)を有効にします。
LAN側コンテナとDMZ側コンテナの違い
SERVER側のコンテナ群(Headscale / Authentik)は Macvlan で物理NICに直結するため、ホストOSのマスカレード設定は不要です。DMZ側のCaddyコンテナはブリッジ接続を使用するため、ホストOS経由のNATが必要です。
sudo systemctl enable --now firewalld
# Caddyコンテナが外部(Let's Encrypt)と通信するためのNAT設定
sudo firewall-cmd --zone=public --add-masquerade --permanent
sudo firewall-cmd --zone=public --add-service=http --permanent
sudo firewall-cmd --zone=public --add-service=https --permanent
sudo firewall-cmd --reload
# 設定確認
sudo firewall-cmd --list-all
journald ログ保持期間の設定
sudo mkdir -p /etc/systemd/journald.conf.d/
sudo tee /etc/systemd/journald.conf.d/retention.conf << 'EOF'
[Journal]
SystemMaxUse=500M
MaxRetentionSec=30day
EOF
sudo systemctl restart systemd-journald
Headscale の構築とタグ ACL
VPNコントロールプレーン(PostgreSQL版)
Phase 3 の設計方針
Headscale は HTTP:8080 のみで起動します。HTTPS終端は Phase 4 の Caddy が担当します。tls_letsencrypt_* の設定は一切記述しません。これは意図的な設計変更です。
- データベースを SQLite から PostgreSQL に変更(耐障害性・パフォーマンス向上)
- Quadlet(systemdネイティブ)でコンテナを管理
- config.yaml の DBパスワードを .env から注入(平文残存を防ぐ)
- metrics_listen_addr を 0.0.0.0:9090 に変更(Phase 6 の Prometheus が接続可能)
フォルダとシークレットの準備(AlmaLinux 9 側)
コンテナが削除・再作成されても登録データや暗号鍵が消えないよう、ホストOS側に永続化領域を作成します。
コンテナDB権限問題の解消
sudo touch / mkdir で作成したファイルは root所有(644)になります。Headscaleコンテナが一般ユーザー(UID 1000)で動作する場合、dbファイルへの書き込みが失敗します。chownで所有権を変更し、restoreconでSELinuxコンテキストも正しく設定します。
# ── フォルダ作成 ─────────────────────────────
sudo mkdir -p /srv/containers/headscale/config
sudo mkdir -p /srv/containers/headscale/data
sudo mkdir -p /srv/containers/headscale/postgres
# 空ファイルの作成(Podmanのディレクトリ誤認識を防ぐ)
sudo touch /srv/containers/headscale/config/acl.hujson
# ── 権限設定 ─────────────────────────────────
# コンテナ実行ユーザー(UID 1000)に所有権を変更
sudo chown -R 1000:1000 /srv/containers/headscale/data
sudo chown 1000:1000 /srv/containers/headscale/config/acl.hujson
# SELinuxコンテキストを正しく設定(:Zマウント前に実行)
sudo restorecon -Rv /srv/containers/headscale
# ── シークレット生成 ──────────────────────────
# DBパスワードを.envファイルに永続保存(パーミッション600で保護)
sudo touch /srv/containers/headscale/.env
sudo chmod 600 /srv/containers/headscale/.env
echo "HEADSCALE_DB_PASS=$(openssl rand -hex 32)" | sudo tee /srv/containers/headscale/.env
# 保存内容を確認
sudo cat /srv/containers/headscale/.env
# 期待する出力例: HEADSCALE_DB_PASS=a3f8c2...
Headscale 設定ファイル(config.yaml)の作成
yourdomain.mydns.jp を書き換えてください
hs.yourdomain.mydns.jp をご自身のDDNSドメインに置き換えてください。例: hs.myserver.mydns.jp
v5での設定変更ポイント(旧バージョンからの差分):db_type: postgres(SQLiteから変更)/ metrics_listen_addr: 0.0.0.0:9090(127.0.0.1から変更、Prometheus接続を可能に)/ tls_letsencrypt_* の行は存在しない(Caddyが担当するため不要)
✓ 修正済み:private_key_path / noise.private_key_path を追加
トラブルシューティング記録の問題③④で判明した通り、Headscale 0.22.x系ではこの2つの秘密鍵パス設定が無いと起動を69回以上繰り返して失敗します。下記のconfig.yamlには最初から組み込んであります。
# teeコマンドでそのままコピー&ペーストできます
sudo tee /srv/containers/headscale/config/config.yaml << 'EOF'
# ── 外部公開URL(CaddyがHTTPS 443で公開するURLと一致させる)──
server_url: https://hs.yourdomain.mydns.jp
# ── コンテナ内部の受付ポート(HTTPのみ・TLSなし)─────────
listen_addr: 0.0.0.0:8080
# ── 管理API(外部非公開・Pod内localhostのみ)───────────
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: true
# ── Prometheusメトリクス(Phase 6でPrometheusが収集)───────
metrics_listen_addr: 0.0.0.0:9090
# ── データベース設定(PostgreSQL)──────────────────────
db_type: postgres
db_host: 127.0.0.1
db_port: 5432
db_name: headscale
db_user: headscale
db_pass: DB_PASS_PLACEHOLDER
# ── ACLファイルパス ────────────────────────────────
acl_policy_path: /etc/headscale/acl.hujson
# ── DERP(中継サーバー)────────────────────────────
# 自前DERPを無効化。外部開放ポートをTCP 80/443のみに抑える。
derp:
server:
enabled: false
# ── タイムアウト設定 ──────────────────────────────
node_key_expiry: 180d
ephemeral_node_inactivity_timeout: 30m
# ── 【必須】WireGuard通信用秘密鍵 ─────────────────
# トラブルシューティング問題④で判明:この設定がないと起動失敗する
# 鍵ファイルはHeadscaleが初回起動時に自動生成する
private_key_path: /var/lib/headscale/private.key
# ── 【必須】Tailscale v2プロトコル用ノイズ暗号鍵 ──────
# トラブルシューティング問題③で判明:0.22.xから必須になった新しい設定
# 鍵ファイルはHeadscaleが初回起動時に自動生成する
noise:
private_key_path: /var/lib/headscale/noise_private.key
EOF
# ── DBパスワードを.envから自動置換(config.yamlに平文が残らないよう処理)
DB_PASS_TMP=$(grep HEADSCALE_DB_PASS /srv/containers/headscale/.env | cut -d= -f2)
sudo sed -i "s/DB_PASS_PLACEHOLDER/${DB_PASS_TMP}/" /srv/containers/headscale/config/config.yaml
# ── セキュリティ:config.yamlのパーミッションを制限 ────
sudo chmod 600 /srv/containers/headscale/config/config.yaml
# ── 確認(DBパスワードが置換されているか確認)─────────
sudo grep db_pass /srv/containers/headscale/config/config.yaml
# DB_PASS_PLACEHOLDERという文字が残っていなければ成功
ACL ファイル(acl.hujson)の作成
【最重要バグ対策】strip_email_domain の仕様を理解する
config.yamlに strip_email_domain: true を設定すると(Phase 5で追加予定)、Authentik上のメールアドレスが user@home.local であっても、Headscale内部では@以降が削除された "user" 単体として認識されます。そのためACLのgroup定義は必ずアカウント名単体("user")で記述します。
誤り例:"group:admin": ["user@home.local"] 正解例:"group:admin": ["user"]
sudo tee /srv/containers/headscale/config/acl.hujson << 'EOF'
{
// ── グループ定義 ──────────────────────────────
"groups": {
"group:admin": ["user"]
},
// ── タグの所有者を明記(tags ではなく tagOwners が正しい構文)──
"tagOwners": {
"tag:dmz": ["group:admin"], // DMZ(Rocky Linux 9)用のタグ
"tag:lan": ["group:admin"] // SERVER(Ubuntu / AlmaLinux)用のタグ
},
"acls": [
// 1. 管理者グループはすべてのリソースへのアクセスを許可
{ "action": "accept", "src": ["group:admin"], "dst": ["*:*"] },
// 2. LANタグを持つデバイス同士のみ通信を相互許可
{ "action": "accept", "src": ["tag:lan"], "dst": ["tag:lan:*"] }
// 3. DMZ(tag:dmz)からLANへのルールがここに存在しないため、
// デフォルトで「DMZからLANへのすべての通信」が完全に拒否されます。
]
}
EOF
Quadlet(systemd)ファイルの作成と起動
Quadlet とは?(--restart always との違い)
Quadlet は Podman 4.4+ で導入された「systemdのユニットファイルでコンテナを管理する仕組み」です。podman run --restart always はPodmanデーモンが再起動を管理しますが、Quadlet(systemd)はOSのinitシステムが再起動を管理します。OS起動時に自動でコンテナが起動し、依存関係(Requires=/After=)で起動順序を保証でき、journalctlでログが一元管理されます。
✓ 修正済み:物理NIC名は決め打ちせず必ず確認する
トラブルシューティング記録の問題②で判明した通り、NIC名は環境によって eth0 ではなく enp1s0 等になっていることがあります。まず確認してから設定してください。
# Step 1:まずNIC名を確認する(必須)
ip link show | grep -E "^[0-9]+:" | grep -v lo
# 出力例:
# 2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> ... ← 有線NIC(Phase 1で確認済みの名前と同じはず)
# 3: wlp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> ... ← 無線NIC(使わない)
# Step 2:NIC名を確認して設定ファイルを作成
sudo tee /etc/containers/systemd/local_lan.network << 'EOF'
[Network]
# Macvlanで物理NICに直結するSERVERセグメント
# ★ 重要:parent= の後ろは実際のNIC名に書き換えてください
# 確認方法: ip link show | grep -E "^[0-9]+:" | grep -v lo
Driver=macvlan
Options=parent=enp1s0
Subnet=192.168.2.0/24
Gateway=192.168.2.1
EOF
sudo tee /etc/containers/systemd/headscale.pod << 'EOF'
[Pod]
PodName=headscale-pod
Network=local_lan.network
IP=192.168.2.13
EOF
二重定義に注意
EnvironmentFile で .env を読み込んでいる場合、同じ変数名を Environment= で再定義すると二重定義になります。このガイドでは EnvironmentFile のみでDBパスワードを渡します。
sudo tee /etc/containers/systemd/headscale-db.container << 'EOF'
[Container]
Pod=headscale-pod.pod
ContainerName=headscale-db
Image=docker.io/library/postgres:<version>-alpine
EnvironmentFile=/srv/containers/headscale/.env
Environment=POSTGRES_DB=headscale
Environment=POSTGRES_USER=headscale
Environment=POSTGRES_PASSWORD=${HEADSCALE_DB_PASS}
Volume=/srv/containers/headscale/postgres:/var/lib/postgresql/data:Z
[Service]
Restart=always
EOF
sudo tee /etc/containers/systemd/headscale.container << 'EOF'
[Unit]
Description=Headscale VPN Control Plane
# PostgreSQLが起動してからHeadscaleを起動する(依存関係)
Requires=headscale-db.service
After=headscale-db.service
[Container]
Pod=headscale-pod.pod
Image=docker.io/headscale/headscale:<version>
ContainerName=headscale
Volume=/srv/containers/headscale/config/config.yaml:/etc/headscale/config.yaml:ro,Z
Volume=/srv/containers/headscale/config/acl.hujson:/etc/headscale/acl.hujson:ro,Z
Volume=/srv/containers/headscale/data:/var/lib/headscale:Z
Exec=headscale serve
HealthCmd=headscale nodes list || exit 1
HealthInterval=30s
HealthRetries=3
[Service]
Restart=always
[Install]
WantedBy=multi-user.target
EOF
✓ 修正済み:enable ではなく start を使う
トラブルシューティング記録の問題①で判明:Quadletが生成するユニットは /run/systemd/generator/ という一時的な場所に置かれるため、systemctl enable は「これは一時的なファイルです」というエラーで失敗します。OS再起動時の自動起動はQuadletが自動的に処理するため、enable は不要です。start だけで起動してください。
# Quadletファイルを変更したら必ずdaemon-reloadを実行する
sudo systemctl daemon-reload
# 起動する(enable不要。Quadletが/etc/containers/systemd/の存在を検知して
# OS起動時に自動生成・自動起動するため)
sudo systemctl start headscale
# ── 起動確認 ─────────────────────────────────
sudo systemctl status headscale headscale-db
sudo journalctl -u headscale -f --no-pager | head -30
デバイス登録と即時タグ適用
sudo podman exec headscale headscale users create admin
sudo podman exec headscale headscale users list
Pre-auth Key とは?
有効期限付きの使い捨てキーです。デバイス登録時に管理者の手動承認が不要になります。登録完了後はキーを削除することをお勧めします。
# ── サーバー側(AlmaLinux 9)── 一時キーを発行(有効期限10分)
sudo podman exec headscale headscale preauthkeys create --user admin --expiration 10m
# 出力例: K9VzYgk7...
# ── クライアント側(Rocky Linux 9 / スマホ等)── 一発で接続
curl -fsSL https://tailscale.com/install.sh | sh
# Rocky Linux 9は明示的な有効化が必要(SELinux対応)
sudo systemctl enable --now tailscaled
# Headscaleサーバーを指定して接続(発行したキーを貼り付け)
sudo tailscale up --login-server https://hs.yourdomain.mydns.jp \
--auth-key K9VzYgk7...
デバイス登録直後は「タグなし・無防備状態」です
ACLの暗黙の拒否は「タグ」が付いているデバイスに適用されます。タグなしのデバイスはデフォルトのルールが適用され、意図しない通信が発生する可能性があります。登録完了後、即座に以下のコマンドを実行してください。
# 登録されたデバイスのIDを確認
sudo podman exec headscale headscale nodes list
# DMZデバイス(Rocky Linux)にtag:dmzを即時付与
sudo podman exec headscale headscale nodes tag -n 1 -t tag:dmz
# SERVERデバイス(Ubuntu / AlmaLinux)にtag:lanを付与
# sudo podman exec headscale headscale nodes tag -n 2 -t tag:lan
# 付与されたことを確認(Tags列にtag:dmzが表示されれば成功)
sudo podman exec headscale headscale nodes list
Phase 3 完了チェック
sudo systemctl is-active headscale headscale-db → どちらも active
sudo podman exec headscale headscale nodes list → エラーなく実行できる
curl -s http://192.168.2.13:8080/metrics | head -3 → メトリクスが返ってくる
※ この時点では https://hs.yourdomain.mydns.jp にはアクセスできません。Phase 4 でCaddyを起動して初めてHTTPS公開が完成します。
Caddy リバースプロキシの構築
HTTPS終端・証明書自動管理・DMZ境界セグメント
Phase 4 がすべての問題を解決する
- ①ポート競合の解消:Rocky Linux(Web/Mail)が:80/:443を占有していても、CaddyがDMZ(172.16.1.18)で受け取り、ホスト名(SNI)で振り分けます。
- ②Let's Encrypt証明書の完全自動化:Caddyfileに1ブロック書くだけで取得・更新が自動化されます。certbot / cronは不要です。
- ③将来の拡張が1ブロック追記のみ:Grafana(Phase 6)もOpenZiti(Phase 7)もCaddyfileへの追記だけで公開できます。
- ④セキュリティ設計の正規化:インターネットトラフィックがDMZで完全に終端され、SERVERには届きません。
ディレクトリとCaddyfileの作成(Rocky Linux 9側)
# ── ディレクトリ作成 ──────────────────────────
sudo mkdir -p /srv/containers/caddy/config
sudo mkdir -p /srv/containers/caddy/data
sudo restorecon -Rv /srv/containers/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'
# ── 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
Quadlet ファイルの作成と起動
ブリッジ名の確認方法
DMZネットワークのブリッジ名は環境によって異なります。ip link show type bridge で確認してください(br0 / br-dmz / virbr0 など)。
sudo tee /etc/containers/systemd/local_dmz.network << 'EOF'
[Network]
# 注意: bridge=br0 のbr0は実際のブリッジ名に合わせてください
Driver=bridge
Options=bridge=br0
Subnet=172.16.1.0/24
Gateway=172.16.1.1
EOF
# イメージのダイジェスト(ハッシュ)を取得して固定する
sudo podman pull docker.io/library/caddy:<version>
CAD_DIGEST=$(sudo podman image inspect docker.io/library/caddy:<version> \
--format '{{index .RepoDigests 0}}')
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
sudo systemctl daemon-reload
sudo systemctl start caddy
# ── 起動確認 ──────────────────────────────────
sudo systemctl status caddy
# ログで証明書取得状況を確認("obtained certificate"が表示されれば成功)
sudo journalctl -u caddy -f --no-pager | head -40
FortiGate の設定(VIP / ポリシー)
| 目的 | 外部ポート(WAN) | 転送先IP | 転送先ポート |
|---|---|---|---|
| Caddy HTTPS(Headscale/Authentik通信) | TCP 443 | 172.16.1.18 | 443 |
| Caddy HTTP(Let's Encrypt証明書更新) | TCP 80 | 172.16.1.18 | 80 |
| 設定項目 | 値 |
|---|---|
| Incoming Interface | WAN(外部) |
| Outgoing Interface | DMZ(172.16.1.x) |
| Destination | 上記で作成したVIP(2つ) |
| Service | HTTP, HTTPS |
| Action | ACCEPT(IPSプロファイル有効、全トラフィックをログ記録) |
この設定が「ゼロトラスト境界」の核心です
Caddy(172.16.1.18)からSERVERへの通信を最小限に制限します。他のDMZ機器(Rocky Linux等)からSERVERへの通信は一切許可しません。
| 設定項目 | 値 |
|---|---|
| Source | 172.16.1.18(Caddyのみ) |
| Destination | 192.168.2.13(Headscale)と 192.168.2.16(Authentik) |
| Service | TCP 8080(Headscale)、TCP 9000(Authentik)のみ |
| Action | ACCEPT(IPS有効、全トラフィックをログ記録) |
動作テスト
# ── 内部から疎通テスト ──────────────────────────
sudo systemctl is-active caddy
# ── 外部から疎通テスト(モバイル回線推奨) ────────────
curl -v https://hs.yourdomain.mydns.jp
# SSL証明書エラーが出なければ成功
# ── ログで証明書確認 ────────────────────────────
sudo journalctl -u caddy --no-pager | grep -i "certificate"
# "obtained certificate" が表示されればOK
Phase 4 完了チェック
sudo systemctl is-active caddy → active
journalctl -u caddy | grep "obtained certificate" → 取得成功ログがある
外部端末から https://hs.yourdomain.mydns.jp にアクセスしSSL警告が出ない
既存Webサイト www.yourdomain.mydns.jp が引き続き正常に表示される
Authentik による MFA 追加
OIDC認証基盤(PostgreSQL / Redis)
【プロ技①】Phase 2 との DNS連携
Authentik Podに --dns 192.168.2.11 を指定することで、AuthentikがPhase 2で構築したUnbound(内向きDNS)を使ってhs.yourdomain.mydns.jpなどのSERVER内ホスト名を正しく解決できます。「Phase 2のDNSがPhase 3/5で実際に機能する」という依存関係が完成します。
シークレット生成とディレクトリ準備(AlmaLinux 9側)
# ── ディレクトリ作成 ──────────────────────────
sudo mkdir -p /srv/containers/authentik/postgres
sudo mkdir -p /srv/containers/authentik/redis
sudo mkdir -p /srv/containers/authentik/media
sudo mkdir -p /srv/containers/authentik/templates
sudo restorecon -Rv /srv/containers/authentik
# ── シークレット生成(3つ全て.envに保存)─────────
sudo touch /srv/containers/authentik/.env
sudo chmod 600 /srv/containers/authentik/.env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -hex 32)" | sudo tee /srv/containers/authentik/.env
echo "AUTHENTIK_POSTGRESQL__PASSWORD=$(openssl rand -hex 32)" | sudo tee -a /srv/containers/authentik/.env
echo "AUTHENTIK_REDIS__PASSWORD=$(openssl rand -hex 32)" | sudo tee -a /srv/containers/authentik/.env
# 確認(3行表示されれば成功)
sudo cat /srv/containers/authentik/.env
Quadlet ファイルの作成(Pod + 4コンテナ)
Authentikの起動順序(依存関係)
Workerはメールやバックグラウンドジョブを処理します。Serverより後に起動することで安定した動作が保証されます。
sudo tee /etc/containers/systemd/authentik.pod << 'EOF'
[Pod]
PodName=authentik-pod
Network=local_lan.network
IP=192.168.2.16
# 【プロ技①】Phase 2のUnboundをDNSとして明示指定
DNS=192.168.2.11
EOF
sudo tee /etc/containers/systemd/authentik-db.container << 'EOF'
[Container]
Pod=authentik-pod.pod
ContainerName=authentik-db
Image=docker.io/library/postgres:<version>-alpine
EnvironmentFile=/srv/containers/authentik/.env
Environment=POSTGRES_DB=authentik
Environment=POSTGRES_USER=authentik
Environment=POSTGRES_PASSWORD=${AUTHENTIK_POSTGRESQL__PASSWORD}
Volume=/srv/containers/authentik/postgres:/var/lib/postgresql/data:Z
[Service]
Restart=always
EOF
sudo tee /etc/containers/systemd/authentik-redis.container << 'EOF'
[Container]
Pod=authentik-pod.pod
ContainerName=authentik-redis
Image=docker.io/library/redis:7.2.5-alpine
EnvironmentFile=/srv/containers/authentik/.env
Volume=/srv/containers/authentik/redis:/data:Z
Exec=sh -c 'exec redis-server --requirepass "$AUTHENTIK_REDIS__PASSWORD"'
[Service]
Restart=always
EOF
sudo tee /etc/containers/systemd/authentik-server.container << 'EOF'
[Unit]
Description=Authentik OIDC Server
Requires=authentik-db.service authentik-redis.service
After=authentik-db.service authentik-redis.service
[Container]
Pod=authentik-pod.pod
ContainerName=authentik-server
Image=ghcr.io/goauthentik/server:2026.4
EnvironmentFile=/srv/containers/authentik/.env
Volume=/srv/containers/authentik/media:/media:Z
Volume=/srv/containers/authentik/templates:/templates:Z
Environment=AUTHENTIK_POSTGRESQL__HOST=127.0.0.1
Environment=AUTHENTIK_POSTGRESQL__USER=authentik
Environment=AUTHENTIK_POSTGRESQL__NAME=authentik
Environment=AUTHENTIK_REDIS__HOST=127.0.0.1
Exec=server
[Service]
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo tee /etc/containers/systemd/authentik-worker.container << 'EOF'
[Unit]
Description=Authentik OIDC Worker
Requires=authentik-server.service authentik-db.service authentik-redis.service
After=authentik-server.service authentik-db.service authentik-redis.service
[Container]
Pod=authentik-pod.pod
ContainerName=authentik-worker
Image=ghcr.io/goauthentik/server:2026.4
EnvironmentFile=/srv/containers/authentik/.env
Volume=/srv/containers/authentik/media:/media:Z
Volume=/srv/containers/authentik/templates:/templates:Z
Environment=AUTHENTIK_POSTGRESQL__HOST=127.0.0.1
Environment=AUTHENTIK_POSTGRESQL__USER=authentik
Environment=AUTHENTIK_POSTGRESQL__NAME=authentik
Environment=AUTHENTIK_REDIS__HOST=127.0.0.1
Exec=worker
[Service]
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl start authentik-server authentik-worker
# ── 起動確認(4つすべてがactive (running)であることを確認)──
sudo systemctl status authentik-db authentik-redis authentik-server authentik-worker
# 管理画面が応答するまで30秒〜1分待ってからアクセス(初回マイグレーションのため)
Authentik 初期セットアップと OIDCプロバイダ設定
ブラウザでのアクセスはCaddy経由の正規URLを使う
正しいURL: https://auth.yourdomain.mydns.jp。内部IP(http://192.168.2.16:9000)でも管理画面は開けますが、OIDCの設定は外部からアクセスするURL(Caddy経由)で行う必要があります。
ブラウザで https://auth.yourdomain.mydns.jp/if/flow/initial-setup/ にアクセスし、管理者メールアドレスとパスワードを設定します。
管理画面左メニュー「Applications」→「Providers」→「Create」を選択します。
| 設定項目 | 設定値 |
|---|---|
| タイプ | OAuth2/OpenID Provider |
| Name | Headscale |
| Client ID / Secret | (自動生成 → 後ほどconfig.yamlにコピー) |
| Redirect URIs | https://hs.yourdomain.mydns.jp/oidc/callback |
| Signing Key | authentik Self-signed Certificate(デフォルト) |
「Applications」→「Create」を選択します。
| 設定項目 | 設定値 |
|---|---|
| Name | Headscale-App |
| Slug | headscale-app |
| Provider | 作成した「Headscale」を選択 |
【プロ技②】管理者ユーザーを先に作成(ロックアウト防止)
管理者ロックアウト防止
OIDCを有効にするとHeadscaleはAuthentikのアカウント名で認証します。ACLの書き換えより前にユーザーを作成していないと、書き換えた瞬間に「存在しないユーザーが管理者」となり、すべてのデバイスへのアクセスが永久に遮断されます。必ず「ユーザー作成(このステップ)→ ACL書き換え(次ステップ)」の順序を守ってください。
管理画面左メニュー「Directory」→「Users」→「Create」を選択します。
| 設定項目 | 設定値 |
|---|---|
| Username | user(HeadscaleのACLに記入する名前) |
| user@home.local | |
| Is Active | ✅ チェックを入れる |
| Password | 強力なパスワードを設定 |
作成したユーザーを選択 →「MFA Devices」→「TOTP Device」→「Enroll」を選択します。QRコードをGoogle Authenticator / Authy等のアプリで読み取り、ワンタイムパスワードを登録します。
ACL更新 → Headscale設定更新(この順序を厳守)
順序を間違えると管理者がロックアウトされます
① ACLを更新する(5-1)→ ② HeadscaleにOIDC設定を追加する(5-2)→ ③ Headscaleを再起動する(5-3)。この順序を必ず守ってください。
sudo nano /srv/containers/headscale/config/acl.hujson
# groupsセクションを以下のように書き換える(Authentikのアカウント名「user」と一致させる)
"groups": {
"group:admin": ["user"],
"group:user": [],
"group:guest": []
},
insecure_skip_verify は不要になりました
v3/v4ではHeadscale→Authentikを内部IP(192.168.2.16:9443)で直接接続していたため、自己署名証明書の検証エラーを回避するinsecure_skip_verify: trueが必要でした。v5ではCaddyがLet's Encrypt証明書でHTTPSを終端するため、issuerには正規のHTTPS URL(auth.yourdomain.mydns.jp)を指定します。これによりinsecure_skip_verifyは完全に不要になりました。
sudo nano /srv/containers/headscale/config/config.yaml
# ファイルの末尾に以下を追記する
# ── OIDC設定(Authentikとの連携)─────────────────
oidc:
issuer: "https://auth.yourdomain.mydns.jp/application/o/headscale-app/"
client_id: "(AuthentikのProviders画面からClient IDをコピー)"
client_secret: "(AuthentikのProviders画面からClient Secretをコピー)"
scope: ["openid", "profile", "email"]
# trueにするとuser@home.localの@home.localが削除され"user"として認識される
# ACLのgroup:admin: ["user"]と一致させるために必要
strip_email_domain: true
sudo systemctl restart headscale
sudo systemctl is-active headscale
# OIDC設定の読み込みをログで確認("oidc"という文字が含まれていれば成功)
sudo journalctl -u headscale --no-pager | tail -20
動作確認:MFAログインテスト
# スマートフォンやモバイル回線のPCで実行
sudo tailscale up --login-server https://hs.yourdomain.mydns.jp
# 期待する動作:
# 1. ブラウザが自動的に開き、Authentikのログイン画面が表示される
# 2. ユーザー名(user)とパスワードを入力
# 3. TOTPコード(ワンタイムパスワード)の入力を求められる
# 4. 認証成功後、Tailnetに接続される
Phase 5 完了チェック
systemctl is-active authentik-server authentik-worker → どちらもactive
tailscale up実行時にAuthentikのログイン画面が開く
TOTPコード入力後に接続が完了する
tailscale ping 192.168.2.3 が成功する
Ⅲ. 現場エンジニアを救う「トラブル回避の7ヶ条」
設定ファイルを弄る前に、必ず以下の切り分けを順番に実施してください。
① Let's Encrypt 証明書が取得できない
確認手順:①curl -v http://hs.yourdomain.mydns.jpを実行 → ②Caddyコンテナまで届いているか確認 → ③FortiGateのVIPでTCP:80が172.16.1.18に転送されているか確認 → ④MyDNS.jpの管理画面でIPが最新のWAN IPに更新されているか確認。原因の9割は「FortiGateのTCP:80転送漏れ」か「DDNSのIP未更新」です。
sudo journalctl -u caddy --no-pager | grep -i "error\|certificate\|acme"
nslookup hs.yourdomain.mydns.jp 8.8.8.8
② SELinuxが原因でコンテナが起動しない
chmodの前に必ずrestoreconを試す
Permission deniedが出たとき、最初に試すのはchmodではなくrestoreconです。chmodで権限を広げるとSELinuxの保護が無効化されます。
sudo restorecon -Rv /srv/containers
sudo ausearch -m avc -ts recent | tail -20
sudo journalctl -u headscale --no-pager | grep -i "denied\|permission"
③ Headscale本体が起動しない(PostgreSQL関連)
Headscaleが起動しない原因の多くはPostgreSQLの起動失敗です。必ずDBのログを先に確認してください。
sudo journalctl -u headscale-db --no-pager | tail -30
sudo systemctl is-active headscale-db
sudo grep db_pass /srv/containers/headscale/config/config.yaml
④ OIDC接続時にAuthentikの認証画面が出ない
sudo grep -A 6 "^oidc:" /srv/containers/headscale/config/config.yaml
# 正しいissuerの形式(末尾スラッシュに注意):
# https://auth.yourdomain.mydns.jp/application/o/headscale-app/
sudo systemctl is-active authentik-server
sudo journalctl -u authentik-server --no-pager | tail -30
⑤ ログインに成功してもPermission deniedになる
sudo podman exec headscale headscale users list
sudo podman exec headscale headscale nodes list
sudo cat /srv/containers/headscale/config/acl.hujson | grep admin
# "group:admin": ["user"] のように@なしのユーザー名になっているか確認
⑥ Caddy設定変更時の安全な手順
systemctl restart caddyの前に必ず文法チェックを行う
設定ファイルにミスがあるとCaddyが起動しなくなります。再起動の前に必ずcaddy validateで文法を確認してください。
sudo podman exec caddy caddy validate --config /etc/caddy/Caddyfile
# エラーなしの場合:設定をリロード(ダウンタイムなし)
sudo podman exec caddy caddy reload --config /etc/caddy/Caddyfile
⑦ Quadletファイルを変更したのに反映されない
# Quadletファイルを変更したら必ずdaemon-reloadを実行する
sudo systemctl daemon-reload
sudo systemctl restart headscale
sudo systemctl status headscale
Ⅳ. Day-2:バックアップと災害復旧(DR)
デイリー自動バックアップスクリプト
#!/bin/bash
# /usr/local/bin/backup-zerotrust.sh
# 使い方: sudo crontab -e で "0 3 * * * /usr/local/bin/backup-zerotrust.sh" を追加
set -euo pipefail
BACKUP_DATE=$(date +%F)
BACKUP_DIR="/tmp/backup_${BACKUP_DATE}"
NAS_HOST="192.168.1.5"
NAS_USER="backup"
NAS_PATH="/volume1/backups"
# ── 稼働中DBのオンラインダンプ ─────────────────────
echo "[1/3] PostgreSQLダンプを取得中..."
sudo podman exec -t authentik-db pg_dump -U authentik authentik \
> /srv/containers/authentik/postgres/db.sql
sudo podman exec -t headscale-db pg_dump -U headscale headscale \
> /srv/containers/headscale/postgres/db.sql
# ── ファイル一括アーカイブ ─────────────────────────
echo "[2/3] ファイルをアーカイブ中..."
sudo tar -czvf ${BACKUP_DIR}.tar.gz \
/srv/containers/ \
/etc/containers/systemd/ \
--exclude="*/data/caddy/cache" \
--exclude="*/cache"
# ── NASへ転送 ──────────────────────────────────
echo "[3/3] NASへ転送中..."
scp ${BACKUP_DIR}.tar.gz ${NAS_USER}@${NAS_HOST}:${NAS_PATH}/
# 30日以上古いバックアップを削除
find /tmp -name "backup_*.tar.gz" -mtime +30 -delete
echo "バックアップ完了: ${BACKUP_DIR}.tar.gz"
# スクリプトを配置して実行権限を付与
sudo chmod +x /usr/local/bin/backup-zerotrust.sh
# cronに登録(毎朝3:00に自動実行)
echo "0 3 * * * root /usr/local/bin/backup-zerotrust.sh" | sudo tee /etc/cron.d/zerotrust-backup
ディザスタリカバリ(完全復旧手順)
# ── 1. バックアップを展開 ──────────────────────
sudo tar -xzvf backup_YYYY-MM-DD.tar.gz -C /
# ── 2. SELinuxコンテキストを復元 ────────────────
sudo restorecon -Rv /srv/containers /etc/containers/systemd
# ── 3. systemdに認識させて自動起動を有効化 ─────
sudo systemctl daemon-reload
sudo systemctl start headscale caddy authentik-server authentik-worker
# ── 4. 動作確認 ────────────────────────────────
sudo systemctl status headscale caddy authentik-server authentik-worker
※ NAS_HOSTは自宅NAS(192.168.1.5)の実際のアドレスに置き換えています。
Ⅴ. アペンディクス(リファレンス)
Appendix A 使用ポート一覧
| ポート | プロトコル | 用途 | 転送元 → 転送先 |
|---|---|---|---|
| 80 | TCP | Let's Encrypt証明書更新 | WAN → Caddy(172.16.1.18) |
| 443 | TCP | HTTPS(Headscale/Authentik) | WAN → Caddy(172.16.1.18) |
| 8080 | TCP | Headscale内部通信 | Caddy → Headscale(192.168.2.13) |
| 9000 | TCP | Authentik内部通信 | Caddy → Authentik(192.168.2.16) |
| 9090 | TCP | Prometheusメトリクス | SERVER内部のみ(Phase 6で使用) |
| 5432 | TCP | PostgreSQL | Pod内部通信のみ |
| 6379 | TCP | Redis | Pod内部通信のみ |
Appendix B ディレクトリツリー
/srv/containers/
├── headscale/
│ ├── .env # DBパスワード(chmod 600)
│ ├── config/
│ │ ├── config.yaml # Headscaleメイン設定
│ │ └── acl.hujson # アクセス制御ルール
│ ├── data/ # 鍵・暗号ストア(UID 1000所有)
│ └── postgres/ # Headscale用DBデータ
│
├── authentik/
│ ├── .env # シークレットキー・各種パスワード(chmod 600)
│ ├── postgres/ # Authentik用DBデータ
│ ├── redis/ # セッションキャッシュ
│ ├── media/ # アップロードファイル
│ └── templates/ # カスタムテンプレート
│
└── caddy/
├── Caddyfile # プロキシ設定
├── data/ # Let's Encrypt証明書キャッシュ
└── config/ # Caddy設定キャッシュ
/etc/containers/systemd/
├── local_lan.network # SERVERセグメント(Macvlan)
├── local_dmz.network # DMZセグメント(Bridge)
├── headscale.pod # Headscale Pod定義
├── headscale-db.container # PostgreSQL(Headscale用)
├── headscale.container # Headscale本体
├── authentik.pod # Authentik Pod定義
├── authentik-db.container # PostgreSQL(Authentik用)
├── authentik-redis.container # Redis
├── authentik-server.container # Authentik Server
├── authentik-worker.container # Authentik Worker
└── caddy.container # Caddy(DMZ)
Appendix C 主要管理コマンド集
# ── サービス状態確認 ──────────────────────────
sudo systemctl status headscale caddy authentik-server authentik-worker
# ── リアルタイムログ確認 ──────────────────────
sudo journalctl -u headscale -f
sudo journalctl -u caddy -f
sudo journalctl -u authentik-server -f
# ── Headscale管理コマンド ─────────────────────
sudo podman exec headscale headscale users list
sudo podman exec headscale headscale nodes list
sudo podman exec headscale headscale preauthkeys list --user admin
# ── ヘルスチェック ────────────────────────────
sudo podman healthcheck run headscale
sudo podman healthcheck run caddy
# ── Caddy設定リロード(ダウンタイムなし) ──────
sudo podman exec caddy caddy reload --config /etc/caddy/Caddyfile
# ── コンテナ一覧確認 ──────────────────────────
sudo podman ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
最終動作確認チェックリスト
Phase 3 Headscale
- headscaleとheadscale-dbが起動している:
sudo systemctl is-active headscale headscale-db→ どちらもactive - HTTP:8080でメトリクスが返る(SERVER内からのみ):
curl -s http://192.168.2.13:9090/metrics | head -5 - nodes listが正常に実行できる:
sudo podman exec headscale headscale nodes list
Phase 4 Caddy
- caddyが起動している:
sudo systemctl is-active caddy→ active - Let's Encrypt証明書が取得されている:
sudo journalctl -u caddy | grep "obtained certificate" - 外部からHTTPSでアクセスできる:
curl -v https://hs.yourdomain.mydns.jp→ SSLエラーなし - 既存Webサイトが引き続き正常に表示される:
https://www.yourdomain.mydns.jp
Phase 5 Authentik
- 4つのAuthentikコンテナがすべて起動している:
sudo systemctl is-active authentik-db authentik-redis authentik-server authentik-worker - tailscale up時にAuthentikのログイン画面が表示される
- MFA(TOTP)が要求される
セキュリティ検証テスト
- 【トンネル疎通テスト】
tailscale ping 192.168.2.3→ pongが返ってくる - 【ブロックテスト】tag:dmzのRocky LinuxからSERVER内サーバーにping/ssh → 通信できないことを確認
- 【プロ技①】Authentikが内部ホスト名を解決できる:
sudo podman exec authentik-server nslookup hs.yourdomain.mydns.jp - 【プロ技②】管理者アクセスが正常に機能している:Authentikでログイン後、すべてのTailnetデバイスに接続できる
- 【プロ技③】db.sqliteの権限問題が発生していない:
sudo ls -la /srv/containers/headscale/data/
全工程完結
インターネットトラフィックがDMZ(Caddy)で完全に終端され、SERVERにはFortiGateで制御された必要最小限の通信のみが届く。認証された端末だけがWireGuard暗号トンネルで自宅サーバーに接続できる。証明書管理・リバースプロキシ・MFA・アクセス制御がすべて自動化された「完全無料のゼロトラスト基盤」が完成しました。
次フェーズ:Grafana / Loki / Prometheus による監視基盤(Phase 6)