AlmaLinux 9 一覧へ戻る トップAlmaLinux 9 / Phase 3〜5(v5決定版)
AlmaLinux 9 ・ Phase 3〜5 ・ 決定版 v5

ゼロトラスト基盤構築ガイド(ポート443競合対策バージョン)

🛡️ Headscale (PostgreSQL版) + 🌐 Caddy + 🔑 Authentik OIDC/MFA 統合

対象OS: Rocky Linux 9(DMZ)/ AlmaLinux 9(SERVER) 費用: 完全無料 前提: Phase 1・2 完了済み

本ページは「Phase 3 構築トラブルシューティング完全記録」で判明した3つの不具合(Quadletユニットの起動方法/物理NIC名の決め打ち/config.yamlの秘密鍵設定漏れ)を修正済みです。このページの手順通りに進めれば、同じ問題は発生しません。修正箇所には ✓ 修正済み の印を付けています。

目次

  1. Ⅰ. 設計思想とアーキテクチャ
  2. Ⅱ. 事前準備・初期硬化
  3. Phase 3:Headscale構築
  4. Phase 4:Caddy構築
  5. Phase 5:Authentik構築
  6. Ⅲ. トラブル回避の7ヶ条
  7. Ⅳ. バックアップとDR
  8. Ⅴ. アペンディクス
  9. 最終動作確認チェックリスト

Ⅰ. 設計思想とアーキテクチャ

なぜ 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 で制御された内部通信のみを通します。

図① 全体トポロジー(ネットワーク構成図)

インターネット → TCP 80/443 → FortiGate(VIP転送: WAN:80/443 → 172.16.1.18)
DMZ セグメント
172.16.1.0/24
Caddy(172.16.1.18)TLS終端・振り分け
Rocky Linux(172.16.1.2)既存Web/Mail
SERVER セグメント
192.168.2.0/24(信頼ゾーン)
Unbound DNS / Chrony NTP(192.168.2.11)
Headscale VPNコントローラー(192.168.2.13)
Authentik MFA/OIDC(192.168.2.16)
Grafana / Prometheus(192.168.2.12・Phase 6)
FortiGate Policy(DMZ → SERVER 最小例外) 送信元: 172.16.1.18のみ / 宛先: 192.168.2.13:8080(Headscale)・192.168.2.16:9000(Authentik)

図② TLS終端と通信フロー

外部クライアント →HTTPS(暗号化)→ Caddy DMZ: 172.16.1.18(TLS終端・証明書管理)
→HTTP(平文・内部通信のみ、FortiGateで制御された経路)→
├─ :8080 → Headscale SERVER: 192.168.2.13
└─ :9000 → Authentik SERVER: 192.168.2.16

重要

Caddy が HTTPS を一元管理するため、Headscale / Authentik は TLS 設定が不要(HTTPのみ)。証明書の自動更新も Caddy が担当し、insecure_skip_verify: true も不要(Caddy 経由の正規 HTTPS URL を使用)。

図③ OIDC認証シーケンス

ユーザーが tailscale up を実行Headscale がブラウザを https://hs.yourdomain.mydns.jp に誘導
Headscale が Authentik に認証を委託(OIDCリダイレクト)ブラウザが https://auth.yourdomain.mydns.jp に移動
Authentik がユーザーに MFA を要求ID/パスワード + TOTPコードを入力
認証成功 → Authentik が OIDCトークンを発行Headscale がトークンを検証してデバイスをTailnetに追加
WireGuard暗号トンネル(Tailnet)が開通

図④ 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します。

検証済みバージョン一覧

サービスコンテナイメージ役割
Caddydocker.io/library/caddy:<version>TLS終端・リバースプロキシ
Headscaledocker.io/headscale/headscale:<version>VPNコントロールプレーン
Authentikghcr.io/goauthentik/server:2026.4MFA/OIDC認証基盤
PostgreSQLdocker.io/library/postgres:<version>-alpineHeadscale/Authentik用DB
Redisdocker.io/library/redis:7.2.5-alpineAuthentikセッションキャッシュ

Ⅱ. 事前準備・ファイルパス対応表・初期硬化

ファイルパス対応表(全フェーズ)

サービスホストOS側のパスコンテナ内パス役割
Headscale/srv/containers/headscale/config/config.yaml/etc/headscale/config.yamlメイン設定
Headscale/srv/containers/headscale/config/acl.hujson/etc/headscale/acl.hujsonACL(アクセス制御)
Headscale/srv/containers/headscale/data//var/lib/headscale/鍵・データ
Headscale/srv/containers/headscale/.env(環境変数として読込)DBパスワード
Headscale/srv/containers/headscale/postgres//var/lib/postgresql/dataHeadscale用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/dataAuthentik用DB
Authentik/srv/containers/authentik/redis//dataセッションキャッシュ

初期セキュリティ硬化(AlmaLinux 9 / Rocky Linux 9 共通)

1

SELinux 状態確認

sudo getenforce
# 期待する出力: Enforcing

Permissive や Disabled の場合は本ガイドの手順が正しく動作しません。

2

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
3

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
Phase 3 ・ 192.168.2.13

Headscale の構築とタグ ACL

VPNコントロールプレーン(PostgreSQL版)

Phase 3 の設計方針

Headscale は HTTP:8080 のみで起動します。HTTPS終端は Phase 4 の Caddy が担当します。tls_letsencrypt_* の設定は一切記述しません。これは意図的な設計変更です。

1

フォルダとシークレットの準備(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...
2

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という文字が残っていなければ成功
3

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
4

Quadlet(systemd)ファイルの作成と起動

Quadlet とは?(--restart always との違い)

Quadlet は Podman 4.4+ で導入された「systemdのユニットファイルでコンテナを管理する仕組み」です。podman run --restart always はPodmanデーモンが再起動を管理しますが、Quadlet(systemd)はOSのinitシステムが再起動を管理します。OS起動時に自動でコンテナが起動し、依存関係(Requires=/After=)で起動順序を保証でき、journalctlでログが一元管理されます。

4-1. ネットワーク定義(AlmaLinux 9)

✓ 修正済み:物理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
4-2. Pod 定義
sudo tee /etc/containers/systemd/headscale.pod << 'EOF'
[Pod]
PodName=headscale-pod
Network=local_lan.network
IP=192.168.2.13
EOF
4-3. PostgreSQL コンテナ

二重定義に注意

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
4-4. Headscale 本体
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
4-5. systemdに認識させて起動

✓ 修正済み: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
5

デバイス登録と即時タグ適用

5-1. 初期ユーザー(名前空間)の作成
sudo podman exec headscale headscale users create admin
sudo podman exec headscale headscale users list
5-2. Pre-auth Key 方式でデバイスを登録(推奨)

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...
5-3. タグの即時割当(無防備期間の排除)

デバイス登録直後は「タグなし・無防備状態」です

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公開が完成します。

Phase 4 ・ 172.16.1.18

Caddy リバースプロキシの構築

HTTPS終端・証明書自動管理・DMZ境界セグメント

Phase 4 がすべての問題を解決する

1

ディレクトリと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
2

Quadlet ファイルの作成と起動

2-1. ネットワーク定義

ブリッジ名の確認方法

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
2-2. Caddy コンテナ定義
# イメージのダイジェスト(ハッシュ)を取得して固定する
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
2-3. 起動
sudo systemctl daemon-reload
sudo systemctl start caddy

# ── 起動確認 ──────────────────────────────────
sudo systemctl status caddy
# ログで証明書取得状況を確認("obtained certificate"が表示されれば成功)
sudo journalctl -u caddy -f --no-pager | head -40
3

FortiGate の設定(VIP / ポリシー)

3-1. VIP(バーチャルIP)の作成
目的外部ポート(WAN)転送先IP転送先ポート
Caddy HTTPS(Headscale/Authentik通信)TCP 443172.16.1.18443
Caddy HTTP(Let's Encrypt証明書更新)TCP 80172.16.1.1880
3-2. WAN → DMZ ファイアウォールポリシー
設定項目
Incoming InterfaceWAN(外部)
Outgoing InterfaceDMZ(172.16.1.x)
Destination上記で作成したVIP(2つ)
ServiceHTTP, HTTPS
ActionACCEPT(IPSプロファイル有効、全トラフィックをログ記録)
3-3. DMZ → SERVER ファイアウォールポリシー(最重要)

この設定が「ゼロトラスト境界」の核心です

Caddy(172.16.1.18)からSERVERへの通信を最小限に制限します。他のDMZ機器(Rocky Linux等)からSERVERへの通信は一切許可しません。

設定項目
Source172.16.1.18(Caddyのみ)
Destination192.168.2.13(Headscale)と 192.168.2.16(Authentik)
ServiceTCP 8080(Headscale)、TCP 9000(Authentik)のみ
ActionACCEPT(IPS有効、全トラフィックをログ記録)
4

動作テスト

# ── 内部から疎通テスト ──────────────────────────
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 が引き続き正常に表示される
Phase 5 ・ 192.168.2.16

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で実際に機能する」という依存関係が完成します。

1

シークレット生成とディレクトリ準備(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
2

Quadlet ファイルの作成(Pod + 4コンテナ)

Authentikの起動順序(依存関係)

authentik-db ─┐
       ├─▶ authentik-server ─▶ authentik-worker
authentik-redis ─┘

Workerはメールやバックグラウンドジョブを処理します。Serverより後に起動することで安定した動作が保証されます。

2-1. Pod 定義
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
2-2. PostgreSQL
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
2-3. Redis
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
2-4. Authentik Server
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
2-5. Authentik Worker
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
2-6. 起動
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分待ってからアクセス(初回マイグレーションのため)
3

Authentik 初期セットアップと OIDCプロバイダ設定

ブラウザでのアクセスはCaddy経由の正規URLを使う

正しいURL: https://auth.yourdomain.mydns.jp。内部IP(http://192.168.2.16:9000)でも管理画面は開けますが、OIDCの設定は外部からアクセスするURL(Caddy経由)で行う必要があります。

3-1. 初期管理者の登録

ブラウザで https://auth.yourdomain.mydns.jp/if/flow/initial-setup/ にアクセスし、管理者メールアドレスとパスワードを設定します。

3-2. OIDCプロバイダの作成

管理画面左メニュー「Applications」→「Providers」→「Create」を選択します。

設定項目設定値
タイプOAuth2/OpenID Provider
NameHeadscale
Client ID / Secret(自動生成 → 後ほどconfig.yamlにコピー)
Redirect URIshttps://hs.yourdomain.mydns.jp/oidc/callback
Signing Keyauthentik Self-signed Certificate(デフォルト)
3-3. アプリケーションの作成

「Applications」→「Create」を選択します。

設定項目設定値
NameHeadscale-App
Slugheadscale-app
Provider作成した「Headscale」を選択
4

【プロ技②】管理者ユーザーを先に作成(ロックアウト防止)

管理者ロックアウト防止

OIDCを有効にするとHeadscaleはAuthentikのアカウント名で認証します。ACLの書き換えより前にユーザーを作成していないと、書き換えた瞬間に「存在しないユーザーが管理者」となり、すべてのデバイスへのアクセスが永久に遮断されます。必ず「ユーザー作成(このステップ)→ ACL書き換え(次ステップ)」の順序を守ってください。

4-1. Authentikでのユーザー作成

管理画面左メニュー「Directory」→「Users」→「Create」を選択します。

設定項目設定値
Usernameuser(HeadscaleのACLに記入する名前)
Emailuser@home.local
Is Active✅ チェックを入れる
Password強力なパスワードを設定
4-2. MFA(TOTP)の設定

作成したユーザーを選択 →「MFA Devices」→「TOTP Device」→「Enroll」を選択します。QRコードをGoogle Authenticator / Authy等のアプリで読み取り、ワンタイムパスワードを登録します。

5

ACL更新 → Headscale設定更新(この順序を厳守)

順序を間違えると管理者がロックアウトされます

① ACLを更新する(5-1)→ ② HeadscaleにOIDC設定を追加する(5-2)→ ③ Headscaleを再起動する(5-3)。この順序を必ず守ってください。

5-1. ACLの管理者定義を更新する
sudo nano /srv/containers/headscale/config/acl.hujson
# groupsセクションを以下のように書き換える(Authentikのアカウント名「user」と一致させる)
"groups": {
  "group:admin": ["user"],
  "group:user":  [],
  "group:guest": []
},
5-2. Headscale設定にOIDCを追加する

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
5-3. Headscaleを再起動して設定を反映
sudo systemctl restart headscale
sudo systemctl is-active headscale
# OIDC設定の読み込みをログで確認("oidc"という文字が含まれていれば成功)
sudo journalctl -u headscale --no-pager | tail -20
6

動作確認: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 使用ポート一覧

ポートプロトコル用途転送元 → 転送先
80TCPLet's Encrypt証明書更新WAN → Caddy(172.16.1.18)
443TCPHTTPS(Headscale/Authentik)WAN → Caddy(172.16.1.18)
8080TCPHeadscale内部通信Caddy → Headscale(192.168.2.13)
9000TCPAuthentik内部通信Caddy → Authentik(192.168.2.16)
9090TCPPrometheusメトリクスSERVER内部のみ(Phase 6で使用)
5432TCPPostgreSQLPod内部通信のみ
6379TCPRedisPod内部通信のみ

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

Phase 4 Caddy

Phase 5 Authentik

セキュリティ検証テスト

全工程完結

インターネットトラフィックがDMZ(Caddy)で完全に終端され、SERVERにはFortiGateで制御された必要最小限の通信のみが届く。認証された端末だけがWireGuard暗号トンネルで自宅サーバーに接続できる。証明書管理・リバースプロキシ・MFA・アクセス制御がすべて自動化された「完全無料のゼロトラスト基盤」が完成しました。

次フェーズ:Grafana / Loki / Prometheus による監視基盤(Phase 6)

AlmaLinux 9 一覧へ戻る