
ローカルのProxmox上のJitsiを、VPS+WireGuardで公開するまでの実録ログ〜Cloudflare Tunnelで詰まり、prosody 13で沼った一日〜
Jitsi Meet(セルフホストのビデオ会議)を立てた。ただし普通の構成じゃない。サーバーは自宅のProxmoxVE9上のLXCで、グローバルIPは持っていない。これを手持ちのVPS経由で外部公開したい、というのが今回のテーマだ。
当初は「Cloudflare Tunnelで引っ張ればいいや」と軽く考えていた。それが大間違いの始まりで、結局その日は朝から晩まで溶けた。GPG鍵の権限、nginxのhttp2記法、prosody 13のLua非互換、設定の上書き……新しめの環境(Debian 12 / Ubuntu 24)特有のエッジケースを片っ端から踏んだ。
この記事は、そのハマった順の実況ログだ。完成形のキレイな手順書ではない。同じ構成を組もうとしている人が、同じ罠を踏む前に「あ、これ記事で見たやつだ」となれば本望。
対象読者は、自分でVPSやLXCを触る中〜上級者。コマンドはそのまま載せるが、なぜそうするかの判断部分を厚めに書いた。
最終的な構成(先に結論)
ハマりログに入る前に、たどり着いた完成形を置いておく。
ブラウザ
│
├─ Web (TCP 443) ──→ VPS:nginxリバプロ ──wg──→ LXC:nginx (Jitsi web)
│ (Let's Encrypt終端)
│
└─ メディア (UDP 10000) ──→ VPS:nftables DNAT ──wg──→ LXC:JVB:10000
WireGuard: VPS 10.66.0.1 ⇄ LXC 10.66.0.2ローカル: Proxmox(ホストはUbuntu 24)上のDebian 12 LXCでJitsi稼働
VPS: Ubuntu 24.04、グローバルIPを持つ公開窓口
Web (TCP 443): VPSのnginxリバプロ → WireGuard越しにLXCへ
メディア (UDP 10000): VPSのnftables DNAT → WireGuard越しにLXCのJVBへ
DNS: jitsi01.example.com をVPSのグローバルIPに向け、Cloudflareは grey cloud(DNS only)
prosody: 公式リポジトリの13系(Lua 5.4で動かす)
罠その0:そもそもCloudflare Tunnelでは無理だった
最初の計画は「ローカルのLXCにJitsiを入れて、Cloudflare TunnelでURLを引っ張る」だった。jitsi01.example.com を予定。
ところがここで根本的な問題にぶつかる。Jitsiの通信は2系統あって、Cloudflare Tunnelが運べるのは片方だけなのだ。
Web UI・シグナリング・WebSocket → HTTPS/WebSocketなのでTunnelで通る
実際の映像・音声(JVB) → デフォルトで UDP 10000 を使う。CloudflareはUDPのプロキシをサポートしていない
つまりTunnelだけで組むと、画面と会議室は開けるが、参加者同士で映像・音声が一切繋がらない。JVBのログに No socket found for ...:10000/udp が出る、あの定番のパターンに直行する。
ここで方針転換。手持ちのVPS(グローバルIPあり)を表玄関にして、WireGuardでローカルLXCと繋ぐことにした。Cloudflare Tunnelと違ってWireGuardはUDPもそのまま流せるので、Jitsiから見れば「グローバルIPを持った普通のサーバー」として振る舞える。
教訓: WebRTC系を自宅サーバーで公開するなら、UDPをどう通すかを最初に設計する。HTTP(S)しか通らないトンネルだとメディアで詰む。
STEP 1:WireGuardでVPSとLXCを繋ぐ
VPSとLXCの両方にWireGuardを入れる。LXC側からVPSへ接続を張る形にして、NAT越えする。
VPS側 /etc/wireguard/wg0.conf
[Interface]
Address = 10.66.0.1/24
ListenPort = 51820
PrivateKey = <VPSの秘密鍵>
[Peer]
PublicKey = <LXCの公開鍵>
AllowedIPs = 10.66.0.2/32LXC側 /etc/wireguard/wg0.conf
[Interface]
Address = 10.66.0.2/24
PrivateKey = <LXCの秘密鍵>
[Peer]
PublicKey = <VPSの公開鍵>
Endpoint = <VPSのグローバルIP>:51820
AllowedIPs = 10.66.0.1/32
PersistentKeepalive = 25PersistentKeepalive = 25 がNATの内側からトンネルを維持する肝。これがないと無通信時に切れる。
両側で systemctl enable --now wg-quick@wg0 して、双方向で ping が通り、wg show に latest handshake が出れば成功。ここは一発で通った。
Ubuntu 24.04(カーネル6.8)もProxmoxホストもWireGuard標準同梱なので、土台で困ることはなかった。非特権LXCでもtunデバイス問題に引っかからずに上がった。
STEP 2:VPSでUDP 10000を転送(nftables DNAT)
VPSが受けたUDP 10000を、WireGuard越しにLXCへDNATする。VPSはUbuntu 24なので、firewalldではなくnftablesで書く。
まずVPSの外向きNIC名を確認。ip -br addr で見たら ens10 だった(eth0 ではない、ここ環境依存)。
IP転送を有効化
echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-jitsi.conf
sudo sysctl --system/etc/nftables.conf にDNATテーブルを追記
table ip jitsi_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iif "ens10" udp dport 10000 dnat to 10.66.0.2:10000
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip daddr 10.66.0.2 udp dport 10000 masquerade
}
}forward-port の戻り経路のためにmasqueradeが要る。適用は sudo nft -f /etc/nftables.conf。
ポイント: NIC名は決め打ちしないこと。eth0 だと思い込んでいると、最近のUbuntuは ens10 だったりして転送が一切効かない。
STEP 3:VPSにWebのリバースプロキシ
Webは443で受けて、WireGuard越しにLXCのnginx(自己署名で443待ち受け)へ流す。TLSはVPS側のLet's Encryptで終端する。
ここで 罠その1 を踏む。
罠その1:nginxの http2 on; がUbuntu 24では使えない
vhostを置いて nginx -t したら、これが出た。
[emerg] unknown directive "http2" in ...:3http2 on; という単独ディレクティブの書き方は nginx 1.25.1以降の新記法。Ubuntu 24.04標準のnginxは1.24.0で、1バージョン届かない。
対処は旧記法に戻すだけ。
# NG(1.24では使えない)
listen 443 ssl;
http2 on;
# OK(旧記法、1.24では正規の書き方)
listen 443 ssl http2;「nginxを公式リポジトリ版に上げる」手もあるが、他の本番サイトも動いているVPSで、Jitsiのためだけにnginxを入れ替えるのはリスクに見合わない。旧記法で十分HTTP/2は効く。
最終的なvhostはこんな形。WebSocket(xmpp-websocket / colibri-ws)のupgrade処理を忘れると、会議室には入れるが映像が出ない or 再接続を繰り返すので注意。
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name jitsi01.example.com;
ssl_certificate /etc/letsencrypt/live/jitsi01.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/jitsi01.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass https://10.66.0.2:443;
proxy_ssl_verify off; # LXCは自己署名なので検証しない
proxy_ssl_server_name on;
proxy_set_header Host jitsi01.example.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
}
}map ブロックは server の外(http直下)に置く。conf.d/ に専用ファイルで分けておくと、後でvhostだけ消したいときに巻き込まずに済む。
STEP 4:LXCをDebian 12で作り直す(13は見送り)
当初、LXCを**Debian 13(trixie)**で立てていた。が、ここで爆弾が。
Jitsiが「out-of-the-boxでサポート」と明記しているのは Debian 11/12、Ubuntu 22.04以降。trixieは新しすぎて、Jitsiのstableリポジトリが依存関係(特にOpenJDKやprosody周り)を完全には想定していない可能性がある。
apt-get install --simulate jitsi-meet で依存解決をシミュレートして勝算を見る手もあったが、「Jitsiは依存地獄にハマると数時間溶ける」と分かっていたので、実績のあるDebian 12で作り直すことにした。
WireGuardの鍵を作り直して公開鍵をVPS側に貼り替えるだけなので、トンネルは数分で復活。VPS側は一切変更不要だった(VPSは 10.66.0.2 しか見ていないので、LXCのLAN IPが何だろうと無関係)。
教訓: 母艦の安定性は最新さより優先。特にJitsiみたいに依存コンポーネントが多いものは、枯れたディストリに乗せた方が結局速い。
STEP 5:Jitsi本体のインストール、ここから怒涛のハマり
Debian 12のLXCで、リポジトリ追加 → nginx → apt install jitsi-meet という王道の流れ。対話プロンプトでhostnameは jitsi01.example.com、SSLは自己署名を選択(本物の証明書はVPS側が持つので)。Let's Encryptスクリプトは実行しない(LXCはNAT内側でHTTP-01チャレンジを受けられない)。
……が、その前のリポジトリ追加で早速詰まる。
罠その2:GPG鍵の権限が600で _apt が読めない
Jitsiのリポジトリを追加して apt update したら、これ。
NO_PUBKEY B4D2D216F1FD7806
E: リポジトリ ... は署名されていません。鍵は正しく登録したはず。gpg --show-keys でも鍵は見える。.list の signed-by= のパスも正常。なのに弾かれる。この矛盾でしばらく悩んだ。
正体は権限だった。
$ ls -l /usr/share/keyrings/jitsi-keyring.gpg
-rw------- 1 root root 2241 ... # ← 600gpg --dearmor した時、WireGuardの鍵生成で設定した umask 077 が効いていて、鍵ファイルが600で作られていた。apt のリポジトリ署名検証は _apt という非rootユーザーで走るので、root専用(600)の鍵を読めない。これが「rootの gpg --show-keys では見えるのに apt update(_aptユーザー)では NO_PUBKEY」という矛盾の真相。
対処は権限を付けるだけ。
chmod 644 /usr/share/keyrings/jitsi-keyring.gpg
apt update # 通った教訓: umask 077 の作業セッションでGPG鍵を作ると _apt が読めずにハマる。keyring系の鍵は644であることを確認する。この後prosodyやnginxの鍵を入れる時も、最初から chmod 644 を織り込んだ。
本体のインストール自体は、Debian 12なので依存で詰まることなく完了。4サービス(jitsi-videobridge2 / jicofo / prosody / nginx)が active (running) になった。
ただし、prosodyのログにこれが出ていた。
Error initializing module 'speakerstats_component' ... no file '/usr/lib/prosody/prosody/util/queue.lua'
Error initializing module 'room_metadata_component' ... no file '/usr/lib/prosody/prosody/util/datetime.lua'この時点では「補助機能だし後回しでいいか」と判断して、先に進んだ。これが後の大沼の入り口だった。
STEP 6:ひとまず会議が通った(メディア成功)
NAT_HARVESTERを設定して、JVBに「公開IPはVPSだよ」と教える。
/etc/jitsi/videobridge/sip-communicator.properties
org.ice4j.ice.harvest.NAT_HARVESTER_LOCAL_ADDRESS=192.168.99.255 # LXCのLAN IP
org.ice4j.ice.harvest.NAT_HARVESTER_PUBLIC_ADDRESS=116.80.999.255 # VPSのグローバルIPこれでJVBが VPS_IP:10000 をICE候補として広告 → ブラウザはVPS:10000へUDP → VPSがwg越しにLXCへ転送、という経路が成立する。
DNSは jitsi01.example.com をVPSのグローバルIPに向け、Cloudflareは**grey cloud(DNS only)**に。ここをオレンジ(プロキシ)のままにすると、結局UDP 10000がCloudflareで止まって振り出しに戻るので注意。
そしてスマホ(5G=完全な外部経路)とPCで会議室に入ったら、映像・音声とも通った。一番の懸念だったメディア経路(UDP 10000 → WireGuard → JVB)がクリアできた瞬間。Cloudflare Tunnelでは絶対に越えられなかったUDPの壁を、VPS + WireGuardで突破できた。
ここで終われば平和だった。だが「prosodyのモジュールエラーを消したい」と欲を出したのが運の尽き。
STEP 7:prosodyの沼(ここが本編)
prosodyのモジュールエラーの原因は、Debian標準のprosody 0.12.3がJitsiのモジュールと噛み合っていないこと。Jitsi公式は「prosody公式リポジトリから新しい版を入れる」ことを前提にしているし、最近のJitsiはprosody 13系を前提にしている(Debian標準の0.12のままだと逆に噛み合わない)。
なのでprosody公式リポジトリを追加して入れ替える。バックアップは取っておいた(これが後で効く)。
cp -r /etc/prosody /etc/prosody.bak.$(date +%Y%m%d)沼1:候補が13.0.5だった(メジャー跳躍)
リポジトリ追加後に apt-cache policy prosody を見たら、候補が 13.0.5-1~bookworm1。予想していた「0.12系の最新」ではなく、0.12 → 13.0 へのメジャー跳躍だった(prosodyは最近 0.12 の次を「13.0」とするバージョニングに変わった)。
「新しすぎて今度は逆にJitsiが追いつかないのでは」と一瞬怯んだが、調べたらJitsi側が「apt から常に最低でもprosody 13を入れる」方針に変わっていて、13.0.4向けのパッチもJitsi側で取り込み済み。13で正解と確認できた。
沼2:prosodyが起動しない(Lua 5.1非互換)
apt install prosody で13.0.5に上げて再起動したら、起動失敗。
$ prosodyctl check config
Prosody is no longer compatible with Lua 5.1prosody 13はLua 5.1と非互換。prosody の起動shebangが #!/usr/bin/env lua で、env lua が拾う /usr/bin/lua が lua5.1 を指していたのが原因。
$ readlink -f /usr/bin/lua
/usr/bin/lua5.1 # ← これが元凶
$ ls -l /etc/alternatives/lua-interpreter
... -> /usr/bin/lua5.1lua5.4 は入っているのに、alternatives経由のリンクが5.1を向いていた。対処は実体リンクを5.4へ張り替えるだけ。
ln -sf /usr/bin/lua5.4 /etc/alternatives/lua-interpreter
lua -v # → Lua 5.4.4 になればOKこれでprosodyは起動し、prosodyctl check config も All checks passed, congratulations! になった。やれやれ、と思った。
沼3:会議が「あなたは切断されました」になる
prosody入れ替え後、jicofo / jvb を再起動して4サービス active。なのにブラウザで会議に入ると*「あなたは切断されました」*。
prosodyのログは無音(起動以降ログが出ない)。これは「prosodyの中で失敗」ではなく「リクエストがprosodyまで届いていない」サイン。jicofoのログを見たら、犯人が分かった。
host-unknown
This server does not serve auth.jitsi01.example.comjicofoは focus@auth.jitsi01.example.com でprosodyにログインしようとするのに、prosody側に auth.jitsi01.example.com のVirtualHostが存在しないと言われている。
沼4:設定の Include 行が消えていた(真犯人)
調べていくと、Jitsiのvhost定義(auth.jitsi01... を含む)は /etc/prosody/conf.avail/jitsi01.example.com.cfg.lua にちゃんと残っていた。conf.d/ のシンボリックリンクも無傷。
問題はメイン設定。
$ grep -n 'Include' /etc/prosody/prosody.cfg.lua
(何も出ない)apt install prosody がメイン設定 prosody.cfg.lua を13系の新デフォルトで上書きし、その新デフォルトに Include "conf.d/*.cfg.lua" が無かった。だからprosodyは conf.d/ を一切読まず、せっかくリンクされたJitsiのvhostを完全に無視していた。これが host-unknown の真相。
対処は1行足すだけ。
echo 'Include "conf.d/*.cfg.lua"' >> /etc/prosody/prosody.cfg.luaprosodyctl check config のロード一覧に conf.d/jitsi01.example.com.cfg.lua が現れ、再起動後のjicofoログから host-unknown が消えて、JVBの登録に成功。
Added new videobridge: Bridge[jid=jvbbrewery@internal.auth.jitsi01.example.com/...]
BaseBrewery.start: Joined the room.PCとスマホで再テストして、映像・音声とも復活。prosody 13移行という大手術を、4つの沼(メジャー跳躍の判断 → Lua 5.1 → 切断 → Include漏れ)を抜けてようやく乗り越えた。
教訓:
STEP 8:表玄関を締める(ufw)
VPSは表玄関。メディアが通って満足していたが、VPSは全開放状態だった(ufwのルールは登録済みだが本体が非アクティブ)。ここを締める。
ufw有効化の2大事故は「自分が締め出される」「Jitsiのメディアが止まる」。順番を守る。
SSHポートの確認(最重要)。今回はSSHが 44444 だった。これがallowに入っていることを確認してから有効化。22ではなく44444で繋いでいるので、ここを外すと即締め出し。
forwardポリシーをACCEPTに。ufwを有効化するとforwardがデフォルトDROPになり、UDP 10000転送が止まる。/etc/default/ufw の DEFAULT_FORWARD_POLICY="ACCEPT" を確認。
必要ポートが揃っているか: SSH(44444)、80/443、51820/udp(WireGuard)、10000/udp(メディア)、既存サービス(25/465等)。
有効化する瞬間は、今のSSHセッションを閉じず、別ウィンドウで新規SSHが通るか試すのが鉄則。弾かれても生きているセッションで ufw disable すれば戻せる。
そして見落としがちなのが、ufwとnftablesの共存。ufwもnftablesを使うので、独自の jitsi_nat テーブルが生き残っているか確認する。
sudo nft list table ip jitsi_nat # DNAT/masqueradeが残っていればOK幸い、ufw有効化後も jitsi_nat は生存していた。会議の映像・音声も通る。表玄関が締まった。
STEP 9:再起動耐性(理論上クリア)
最後に、VPS再起動でこの構成が自動復活するかの確認。チェックは3点。
systemctl is-enabled nftables # enabled(設定を自動ロード)
grep -A8 'jitsi_nat' /etc/nftables.conf # テーブルが永続化されているか
systemctl is-enabled wg-quick@wg0 # enabled(WireGuard自動起動)3点とも揃っていれば、理論上は再起動しても自動復活する。ただしufwとnftables.serviceの起動順序の綾で、再起動後に片方が消える可能性はゼロではない(/etc/nftables.conf 冒頭の flush ruleset がufwのルールを消す、等)。なので最終的には実際に再起動して検証するのが確実。
ただVPSは他の本番サービス(メール等)も乗っているので、再起動は影響の少ないタイミングで。設定上は揃っているので、検証は「念のための最終確認」という位置づけ。
もし再起動後に jitsi_nat が消えていたら、sudo nft -f /etc/nftables.conf で即再投入できる。
まとめ:踏んだ罠カタログ
一日かけて踏んだ罠を並べておく。どれも「新しめの環境で、新旧バージョン差のエッジケースを踏む」という共通パターンだった。
Cloudflare TunnelはUDP非対応 → WebRTCのメディアが通らない。VPS+WireGuardに方針転換
GPG鍵の権限 600→644 → umask 077 のせいで _apt が読めず NO_PUBKEY
nginxのhttp2記法 → Ubuntu 24の1.24では http2 on; が使えず、旧記法 listen 443 ssl http2; が必要
prosody 13 + Lua 5.1非互換 → env lua がalternatives経由で5.1を掴んでいた。/etc/alternatives/lua-interpreter を5.4へ
prosody設定のInclude漏れ → apt が prosody.cfg.lua を上書きし Include "conf.d/*.cfg.lua" が消えて host-unknown
特にprosody 13まわり(4と5)は、Jitsiの自動インストールが内部でやっていることを理解していないと、ログだけ見ても何が起きているか分かりにくい。host-unknown → 「vhostが読まれていない」 → 「Include行が消えている」とたどり着くまでが今回の山場だった。
完成形だけ見ればシンプルな構成だが、そこに至る道には地雷がそこそこ埋まっている。同じことをやる人の地雷探知機になれば幸いです。
(この記事はその日の作業ログをもとに、時系列で再構成したものです。IPアドレスやドメインは実環境のものを一部伏せ字・改変しています。)
終わりに
普段は X で、Claude Code や VPS 運用、個人開発の試行錯誤について発信しています。よかったらフォローしてください 👇
X: @kumapress1
質問や「こんなハマり方したよ」みたいな話もお待ちしてます。