ADR-0001: homelab アプリ(bulletin)の外部公開方式

  • ステータス: Accepted
  • 日付: 2026-06-27
  • 決定者: 自分
  • タグ: homelab, security, networking, k3s

コンテキストと課題

k3s homelab 上で内部運用していた掲示板アプリ(bulletin)を、自分だけが他PCやスマホ・外出先からも使えるようにしたい。内部は hosts 運用(bulletin.shooonng.com を MetalLB の Ingress IP 192.168.3.200 に向ける)で完成済み。

  • 達成したいこと: 内部運用中のアプリを、自分だけが外部からも安全に使えるようにする。
  • 制約:
    • ホームルーターのポート開放はしたくない(攻撃面を増やしたくない)。
    • 公開ドメインは Cloudflare で管理しているゾーン(shooonng.com)配下。
    • 内部 hosts 運用は維持したい(LAN 内は Ingress 直結のまま)。
  • 「自分だけ」の守り方: URL 秘匿は弱い(TLS 証明書発行で FQDN が Certificate Transparency ログに公開される)。認証層で守る必要がある。

検討した選択肢

  • 選択肢A: Cloudflare Tunnel + Cloudflare Access
  • 選択肢B: DNS A レコード公開 + ルーターのポートフォワード
  • 選択肢C: URL 秘匿のみ(認証なしで公開)

決定

採用: 選択肢A(Cloudflare Tunnel + Cloudflare Access)

決め手:

  • ルーターのポート開放が不要(cloudflared がアウトバウンド接続を張る)。
  • Cloudflare Access により「許可したメールアカウントだけ」に認証で絞れる。URL 秘匿に依存しない。
  • 内部 hosts 運用と両立する(外部は Tunnel 経由、内部は Ingress 直結の二系統)。

各選択肢の評価

選択肢A: Cloudflare Tunnel + Access

  • 良い点: ポート開放不要 / 認証で「自分だけ」を担保 / 内部運用と両立 / グローバルIP変動に非依存。
  • 悪い点: Cloudflare への依存。cloudflared をクラスタに常駐させる運用が増える。
  • 採用理由: 制約(ポート開放回避・認証必須)を最もきれいに満たす。

選択肢B: DNS A レコード + ポートフォワード

  • 良い点: 構成が古典的で理解しやすい。Cloudflare 依存が小さい。
  • 悪い点: ルーターに直接ポートを開ける必要があり攻撃面が増える。動的グローバルIPだと DDNS 運用も必要。homelab を直接インターネットに晒すリスク。
  • 却下理由: ポート開放を避けたいという制約に反する。

選択肢C: URL 秘匿のみ

  • 良い点: 設定が最も簡単。
  • 悪い点: FQDN は CT ログで公開されるため秘匿は成立しない。実質無防備。
  • 却下理由: 「自分だけ」をセキュリティとして担保できない。

結果(Consequences)

  • 良い面: スマホ(モバイル回線)から Access ログインを経てアプリに到達できることを確認。ポート開放ゼロで外部公開を実現。
  • トレードオフ: Cloudflare エッジで TLS 終端するため、外部=エッジ証明書、内部=Ingress の LE 証明書、の二系統構成になった(cloudflared → Ingress は内部 HTTPS)。
  • 今後の宿題:
    • SealedSecrets の封印鍵バックアップを安全な場所に保管(復号の生命線)。
    • 同方式を Nextcloud 公開にも横展開予定。
    • 長期的には Proxmox VM のディスク拡張で Longhorn の容量に余裕を持たせる。

実装メモ・確定値

  • 公開 FQDN: bulletin.shooonng.com(Cloudflare ゾーン shooonng.com 配下、proxied/CNAME)
  • Tunnel ID: 3f55d9da-1471-4084-82ab-6418e83c361b(名前: bulletin)
  • origin: https://ingress-nginx-controller.ingress-nginx.svc:443
  • cloudflared config の要点(originRequest):
    • httpHostHeader: bulletin.shooonng.com
    • originServerName: bulletin.shooonng.com(SNI を正しく送るために必須)
  • TLS: cert-manager + Let’s Encrypt、DNS-01(Cloudflare)、ClusterIssuer letsencrypt-cloudflare
  • Secret 管理: SealedSecrets controller v0.38.1(kube-system)、Tunnel 認証情報は SealedSecret 化して argocd-apps に commit
  • GitOps: jacoloves/argocd-apps の apps/cloudflare/(namespace/SealedSecret/ConfigMap/Deployment/Application)
  • 運用順序の鉄則: Access のポリシーを先に有効化してから DNS(CNAME)を Tunnel に向ける(逆だと一時的に無認証公開される)

ハマった点 / 学び

  • Longhorn でボリュームが faulted・Pod が Pending

    • 原因: No available disk candidates。実空きではなく over-provisioning 枠でスケジュール判定される。Nextcloud の 20Gi×3レプリカが各ノードを圧迫していた。
    • 対処: storage-over-provisioning-percentage を 100→200 に。恒久対策は VM ディスク拡張。
  • Pod が CrashLoopBackOff(exec format error)

    • 原因: Apple Silicon Mac でビルドすると arm64 になり、amd64 ノードで実行不可。
    • 対処: docker build --platform linux/amd64。DOCKER_DEFAULT_PLATFORM=linux/amd64 を常設すると安全。同一タグ入れ替え時は古いイメージを ctr images rm するか、タグを上げる。
  • Tunnel から origin に繋ぐと 502(x509: certificate is valid for ingress.local)

    • 原因: cloudflared が Service 名で HTTPS 接続し SNI が合わず、ingress-nginx がフォールバックのデフォルト証明書(ingress.local)を返したため検証失敗。
    • 対処: originServerName: bulletin.shooonng.com を指定。これで正しい SNI が送られ Ingress が LE 証明書を返し、検証も一致する。
  • ConfigMap を更新しても cloudflared に反映されない

    • 原因: subPath でマウントしたファイルは ConfigMap 更新が自動反映されない(subPath 特有の挙動)。
    • 対処: ConfigMap 変更後に kubectl rollout restart。subPath を使わなければ自動反映される。
  • Access のログイン画面が出ず素通りに見えた

    • 原因: ブラウザに既存の Access セッションが残っていた。
    • 対処: シークレットタブで確認。実際にはポリシーは正しく機能していた。

参考リンク