私のアイデアはいつも同じように始まります。ノート PC で動いて、ちゃんと動いて、誰かに見せたくなる。しばらくの間、その次の一歩は決まってクラウドの請求書でした。マシン、データベース、バケット、それを指すドメイン、そして三人しか見ないものに毎月かかる料金。サイドプロジェクトや、本気のプロジェクトでも最初の一か月なら、これは順番が逆です。本番があるかどうかもわからないうちに、本番の代金を払っているのですから。
この記事はその逆の順番の話です。サービスは作ったマシンの上で動き続け、Cloudflare Tunnel がそこに公開の HTTPS ホスト名を与えます。ノート PC の上でインターネットに向けて待ち受けているものは何もありません。ポートフォワーディングも、ルーターの設定も、むき出しの IP アドレスもなし。マシンのほうから Cloudflare に接続し、Cloudflare はその接続を通してリクエストを送り返してきます。トンネルは無料で、TLS 証明書、DDoS 対策、キャッシュは、おそらく既に持っている Cloudflare のゾーンに付いてきます。
例は実際のものです。私のプロダクトのひとつ は Fly.io で動いていて、そのモバイルアプリはバックエンドから JavaScript の OTA(無線)更新を受け取ります(セルフホストの Expo Updates)。本番はそのままにして、開発ビルド用にその更新サーバーの二つ目のコピーを自分の Mac で動かしたかったのです。以下の出力は、うまくいかなかった部分も含めて、その日の午後のものです。
Cloudflare Tunnel とは実際のところ何なのか#
普通のサーバーは接続を待ちます。インターネットからノート PC に届くようにするには、ルーターでポートを開け、それをノート PC に転送し、ISP がパブリックな IPv4 アドレスをくれることを祈るしかありません(多くの地域では、私のところも含めて、くれません)。そうなると、そのポートで待ち受けているものはすべて、インターネット中のスキャナーから一日中届くことになります。
トンネルはこれを逆にします。cloudflared はアプリの隣で動き、Cloudflare のエッジへ外向きの接続を開きます。近くの二つのデータセンターへ四本です。ゾーンの DNS レコードがホスト名をトンネルに向けます。そのホスト名へのリクエストが届くと、Cloudflare が TLS を終端し、リクエストをその接続のどれか一本に流します。あとは cloudflared が、設定で指定したローカルのサービスに渡します。
セキュリティの面で大事なのは、ルーターに受信ルールがひとつもなく、IP アドレスが DNS に一度も出てこないことです。自宅の回線をスキャンしても何も見つかりません。そこで待ち受けているものが何もないからです。
設定方法、最初から最後まで#
必要なのは Cloudflare に載せたドメイン(無料プランで十分)と、インストール済みの cloudflared(Mac なら brew install cloudflared)です。
1. 一度だけログインする。 ブラウザが開くのでゾーンを選ぶと、このマシンがそのゾーンにトンネルと DNS レコードを作れるようにする証明書が保存されます。
cloudflared tunnel login2. トンネルを作る。 このプロジェクト用のものはすべて専用のフォルダに置いています。認証情報ファイルはトンネルの身元そのもので、それを持っている人なら誰でもあなたのホスト名のトラフィックを配信できてしまうからです。
mkdir -p ~/.cloudflared/myapp && cd ~/.cloudflared/myapp
cloudflared tunnel create --credentials-file ./creds.json myapp-local
chmod 600 creds.jsonTunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
Created tunnel myapp-local with id 1ade030c-…3. ホスト名をトンネルに向ける。 ホスト名ごとにコマンドを一回実行すると、<tunnel-id>.cfargotunnel.com へのプロキシ済み CNAME が作られます。
cloudflared tunnel route dns myapp-local ota.example.com
cloudflared tunnel route dns myapp-local checkin-local.example.com4. ingress ルールを書く。 セキュリティにとって肝心な部分なので、下で節をひとつ設けます。
5. 動かす。 私のアプリは既に Docker Compose で動いているので、cloudflared は同じネットワーク上のコンテナがひとつ増えるだけです。アプリにはサービス名で届くので、アプリ側はホストにポートを公開する必要すらありません。
# compose.tunnel.yml, used alongside the project's docker-compose.yml
services:
cloudflared:
image: cloudflare/cloudflared:2026.10.7
command: tunnel --config /etc/cloudflared/config.yml run
volumes:
- ${HOME}/.cloudflared/myapp:/etc/cloudflared:ro
restart: unless-stopped
depends_on: [api, edge]docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflaredINF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quicソウルへの接続が四本。これでノート PC は、外から見てインターネット上にいます。restart: unless-stopped のおかげで、再起動しても Docker が立ち上がればすぐに戻ってきます。
サーバーではなくパスを公開する#
ここは私が危うくやりかけた間違いで、おそらく一番よくある間違いです。手軽な設定は一行で済みます。hostname → http://localhost:8000。これで公開されるのは開発サーバー丸ごとです。
開発サーバーはそんな使い方を想定して作られていません。私のものは DEBUG=True で動いていて、エラーが起きれば完全なトレースバックと設定が表示されます。認証も切ってあり(AUTH_MODE=disabled)、どんな Bearer トークンでもテストユーザーとしてログインできます。localhost ならまさにそれで正しく、公開ホスト名の上では大惨事です。あなたのところにも似たようなものがあるはずです。デフォルトパスワードのままの管理画面、データ投入用のエンドポイント、デバッグツールバー、あるいは呼ぶのが自分だけだからと呼び出し元を信用している API。
だからトンネルの設定は許可リストにします。cloudflared は各リクエストを ingress ルールと上から順に、ホスト名とパスの正規表現で照合します。最初に一致したものが勝ち、最後のルールが残りすべてを受け止めます。
tunnel: myapp-local
credentials-file: /etc/cloudflared/creds.json
ingress:
# OTA: the two read-only endpoints the phone calls, and the files they link.
- hostname: ota.example.com
path: ^/api/expo-updates/(manifest|status)/$
service: http://api:8000
- hostname: ota.example.com
path: ^/media/
service: http://api:8000
# Device check-ins, authenticated by each device's own key.
- hostname: checkin-local.example.com
path: ^/c/$
service: http://edge:8081
# Everything else, including /admin/ and the publish endpoint: 404 at the edge.
- service: http_status:404公開用のエンドポイントは同じサーバーの同じ接頭辞の下(/api/expo-updates/import-update/)にありますが、リストには入っていません。管理画面も、API の残りの部分も同じです。そうしたリクエストは Cloudflare から先へは出ていきません。
自分の正規表現を信じる必要はありません。URL がどのルールに一致するかは cloudflared が教えてくれます。
cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/https://ota.example.com/api/expo-updates/manifest/ service: http://api:8000
https://ota.example.com/api/expo-updates/import-update/ service: http_status:404
https://ota.example.com/admin/ service: http_status:404
https://ota.example.com/media/expo-updates/x.js service: http://api:8000
https://checkin-local.example.com/c/ service: http://edge:8081
https://checkin-local.example.com/health service: http_status:404このチェックはスクリプトにして、設定を変えるたびに走らせるのがおすすめです。数秒で終わりますし、意図より広く一致する正規表現は、他人に指摘されて初めて気づく類の間違いです。
経験則をあと二つ。
- 公開するものは自分で認証すること。 OTA のエンドポイントが配るのは公開の読み取り専用データです。デバイスのチェックインはデバイスごとの鍵で署名されています。どちらも安全をネットワークに頼っていません。ログインのないページを誰かに見せたいなら、ログインを作るのではなく、そのホスト名の前に Cloudflare Access を置きましょう(50 ユーザーまで無料)。
- トンネルを開発ワークフローの裏で常時動かさないこと。 意図したホスト名のために意図して動かし、終わったら止めます。
docker compose stop cloudflaredのコマンド一発で、すべてがインターネットから消えます。
ホスト名を飲み込んだリダイレクト#
トンネルがつながり、ルールも確かめたところで、外から試してみました。
https://ota.example.com/api/expo-updates/status/?… 301
https://ota.example.com/api/expo-updates/manifest/ 301
https://ota.example.com/admin/ 301
https://checkin-local.example.com/c/ 301どのパスも同じ 301 を返しました。トンネルなら 404 を返すはずのものまでです。つまりリクエストは cloudflared に届いていません。Location ヘッダーは https://www.example.com/ で、server: cloudflare ヘッダーで決まりでした。Cloudflare が自分で応答していたのです。
原因は自分でした。何か月も前に、でっち上げのサブドメインを歩き回るスキャナー向けのシンクホールとして、ゾーンレベルのリダイレクトルール「その他のサブドメイン → www」を追加していました。リダイレクトルールは Cloudflare のリクエスト処理の中で、リクエストがオリジンに送られる前に動きます。そしてトンネルはただのオリジンです。同じルールが私のプレビュー環境も捕まえて、Workers に届く前に止めていました。対処も前回と同じで、ルールの式に例外をもうひとつ足すだけです。
… and not http.host in {"ota.example.com" "checkin-local.example.com"}トンネルのホスト名が、アプリではなく Cloudflare のリダイレクトやエラーページを返してくるなら、トンネルをデバッグする前に、そのゾーンの Rules → Redirect Rules、Page Rules、WAF のカスタムルールを確認してください。cloudflared は転送したリクエストをすべてログに残すので、ログが空ならリクエストはそこまで来ていません。
できないこと#
- 公開インターネットからの UDP。 トンネルの公開ホスト名が運ぶのは HTTP(S)(と WebSocket、gRPC)です。私のプロダクトのデバイスは普段 UDP 上の CoAP でチェックインしますが、それはトンネルを通れません。ローカルのコピーにつながるデバイスは HTTPS のチェックインにフォールバックし、そのたびに数秒余計にかかります。アイデアがゲームサーバーや VoIP なら、トンネルは向いていません。
- 稼働率。 ノート PC です。蓋を閉じればサイトは落ち、Cloudflare が 530 のエラーページを出します。デモや十人規模のベータ、ステージング用のコピーならそれで構いません。誰かが頼りにしているものには向きません。
- ビルドに焼き込まれるもの。 モバイルアプリの更新 URL はネイティブの設定なので、トンネルの URL で作ったビルドだけがそれを使います。ストア版のビルドは本番と話し続けるので、だからこそこの構成は安心して試せました。
コスト: Cloudflare Tunnel は無料?#
無料プランの Cloudflare ゾーン、トンネル、その証明書、DNS レコード、合わせて 0 ドル。マシンはもともと動いていました。本当のコストは、今やあなたが稼働率の担当チームになる、ということだけです。
その代わりに省けるのは、「自分のマシンでは動く」と「お金を払ってホストする価値がある」の間の段階です。テスターやデザイナー、最初のお客さんに、その日の午後のうちにリンクを送れます。誰も興味を示さなければ、失ったものは何もありません。興味を示してくれたら、本物のホストへの移行は変更ひとつで済みます。CNAME を別の場所に向けるだけです。
