MCP tunnelsをDocker Composeでデプロイする手順 — トークンと証明書の更新
Docker ComposeでMCP tunnelsのトンネルスタックを構築し、トークンローテーションと証明書更新まで、--url必須やUID 65532といった詰まりどころ込みでまとめます。
Docker Composeで何を起動することになるのか
最短で動作確認を通したいだけならMCP tunnelsクイックスタートの6手順で足ります。本記事はその先、本番ホストでの構築とトークン・証明書の更新運用を対象にします。
MCP tunnelsのConsole側で作るのはトンネルという入れ物だけで、実際に通信を運ぶのはネットワーク内で動くトンネルスタックです。単一ホストで動かす場合の公式な参照実装がDocker Composeで、cloudflared・プロキシ(mcp-proxy)・任意のsetupコンポーネントの3つをコンテナとして起動します。同じ構成を複数ホストに複製すれば可用性も確保できます。
始める前に、Consoleでトンネルを作成しておくか(手動フロー)、setupコンポーネントに作らせるか(プログラム的アクセス)を決めます。作成手順はMCP tunnelsをConsoleで作成・管理する手順にまとめています。
デプロイ先の選び方は単純です。単一ホストや動作確認ならDocker Compose、Kubernetesクラスタで動かすならMCP tunnelsをHelmでデプロイする手順のHelmチャートを使います。認証方式も独立に選べるので、「Kubernetesだが手動フロー」「単一ホストだがプログラム的アクセス」のような組み合わせも成立します。
| 判断軸 | Docker Compose | Helm |
|---|---|---|
| 可用性 | Docker Compose同じ構成を複数ホストへ手動複製 | HelmDeploymentのレプリカで水平化 |
| 証明書更新の自動化 | Docker Composesetup renew-certを自分でcron登録 | Helmプログラム的アクセスなら日次CronJobが標準搭載 |
ホストの条件で認証方式を決める
方式の分かれ目は、そのホストがOIDCのIDプロバイダ(クラウドVMのメタデータサーバーやSPIFFEなど)からJWTを受け取れるかどうかです。受け取れるならプログラム的アクセス、受け取れなければ手動フローになります。ローカルでの動作確認にも手動フローが使えます。
2つの認証方式
プログラム的アクセス
setupコンポーネントがWorkload Identity Federationでトークンを取得し、CAとサーバー証明書を生成してCAをAnthropicに登録します。手元でopensslを打つ必要がなく、証明書更新もrenew-certの1コマンドです。
手動フロー
Consoleでトークンとドメインを取り、opensslでCAとサーバー証明書を作り、CAをConsoleに登録します。setupサービスが無いぶんComposeファイルは短くなりますが、更新は自分の手で回します。
事前に必要なもの
- トンネル。プログラム的アクセスではトンネルIDを指定しなければsetupコンポーネントが作成します。手動フローでは必ずConsoleで先に作成し、トンネルIDを控えます
- Tunnels APIへの認証手段。プログラム的アクセスならfederation ruleのID(
fdrl_...)と組織ID、手動ならConsoleから取得するトンネルトークンです - DockerとDocker Composeが入ったホスト。手動フローでは
openssl(1.1.1以降)も使います - アウトバウンド通信。
api.anthropic.comへの443/TCPと、トンネルエッジ(198.41.192.0/19、2606:4700:a0::/44)への7844/TCP・UDPが必要です - 到達可能なMCPサーバーが1つ以上。手元に無ければFastMCPで作る最小サンプルで代用できます
- Zero Data RetentionやHIPAA BAAの対象範囲。MCP tunnelsは研究プレビューで、公式の「API and data retention」ページの機能別の一覧では、Zero Data RetentionもHIPAA BAAも対象外です。規制対象のデータを流す前に、この一覧を見ておきます
手元にMCPサーバーが無いときのテスト用サンプル
到達可能なMCPサーバーをまだ持っていない場合、FastMCPで動く最小構成を代わりに使えます。helloツールが1つあるだけの検証専用サーバーです。
mkdir -p mcp-tunnel
cat > mcp-tunnel/hello_server.py <<'EOF'
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("hello-server", host="0.0.0.0", port=9000)
@mcp.tool()
def hello(name: str = "world") -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
EOFこのサンプルを使う場合、docker-compose.yamlにhello-mcpサービスを追記し、プロキシ設定のroutesをecho: http://hello-mcp:9000にします。以降の手順ではこのechoルートを検証先として使います。
プログラム的アクセスでデプロイする
IDプロバイダのあるホストでは、まず作業ディレクトリを整え、コンテナが非rootのUID 65532で動くためdata/の書き込み権限を与えます。
mkdir -p mcp-tunnel/{config,data}
cd mcp-tunnel
sudo chown 65532:65532 data続いてdocker-compose.yamlを用意します。公式ガイドの参照構成は、イメージをSHA-256ダイジェストで固定し、全コンテナを非root・読み取り専用ファイルシステム・Linux capability全剥奪で動かすハードニング済みの内容です。
以下は要点を抜粋した構成です。@sha256:...のダイジェストは省略表記なので、実際に使う際は公式Docker Composeガイドの参照構成から完全な値をコピーしてください。
docker-compose.yaml(プログラム的アクセス、要点を抜粋)
services:
setup:
image: us-docker.pkg.dev/anthropic-public-registry/images/mcp-proxy@sha256:efb27...
entrypoint: ["/setup"]
command:
- init
- --api-url=https://api.anthropic.com
- --output=dir:/data
- --token-version=1
environment:
- TUNNEL_ID
- ANTHROPIC_FEDERATION_RULE_ID
- ANTHROPIC_ORGANIZATION_ID
- ANTHROPIC_WORKSPACE_ID
- ANTHROPIC_IDENTITY_TOKEN
volumes: ["./data:/data"]
user: "65532:65532"
read_only: true
cap_drop: ["ALL"]
profiles: ["setup"]
cloudflared:
image: cloudflare/cloudflared@sha256:6b599c...
command: tunnel --no-autoupdate run --url http://localhost:8080
environment: ["TUNNEL_TOKEN"]
network_mode: "service:mcp-proxy"
user: "65532:65532"
read_only: true
cap_drop: ["ALL"]
mcp-proxy:
image: us-docker.pkg.dev/anthropic-public-registry/images/mcp-proxy@sha256:efb27...
volumes:
- ./config/mcp-proxy.yaml:/etc/mcp-gateway/config.yaml:ro
- ./data:/data:ro
user: "65532:65532"
read_only: true
cap_drop: ["ALL"]識別子を設定し、setupコンポーネントを走らせてトンネルを発行します。TUNNEL_IDを指定しなければ新規トンネルが作られ、指定すればConsoleで作った既存トンネルに接続します。
export ANTHROPIC_FEDERATION_RULE_ID=fdrl_...
export ANTHROPIC_ORGANIZATION_ID=00000000-0000-0000-0000-000000000000
export ANTHROPIC_IDENTITY_TOKEN=<この環境のOIDC JWT>
docker compose run --rm setup
export TUNNEL_DOMAIN=$(sudo cat data/tunnel-domain)setup initはdata/に対して冪等です。再実行してもそこに保存済みのトンネルIDとCAをそのまま使い、2つ目のトンネルは作りません。新しいCAが生成・登録されるのは、data/が空かTUNNEL_IDを変えたときだけで、その場合は証明書2枚の上限に注意が要ります。両枠が既に埋まっている場合は、Consoleで古いCAを先にRevokeしてから再実行します。Workload Identity Federationのトークンは既定で1時間の短命トークンなので、setup完了後に手動で失効させる作業はありません。
プロキシ設定を書き、トークンを取り出してから起動します。tunnel_domainは必須で、受信ホスト名からドメイン部分を取り除いてからroutesのサブドメインと照合するために使われます。
cat > config/mcp-proxy.yaml <<EOF
listen_addr: ":8080"
tunnel_domain: ${TUNNEL_DOMAIN}
tls:
cert_file: /data/tls.crt
key_file: /data/tls.key
routes:
docs: http://your-mcp-server:9000
EOF
export TUNNEL_TOKEN=$(sudo cat data/tunnel-token)
docker compose up -d手動フローでデプロイする
プログラム的アクセスを使わない場合や動作確認だけの場合は、トークンとドメインをConsoleで取得し、CA証明書を自分で発行します。
手動フローの5ステップ
- 1
Consoleでドメインとトークンを取る
トンネル詳細ページのDomain(
abcd1234.tunnel.anthropic.comの形)をコピーし、Tokenの目のアイコンで表示したトークンもコピーします。 - 2
CAとサーバー証明書を作る
下のコマンドで
data/に出力します。サーバー証明書のSAN(Subject Alternative Name)には*.<トンネルドメイン>を含めます。 - 3
CAをConsoleに登録する
CertificatesのAdd certificateで
data/ca.crtを選びます。ステータスがActiveになります。 - 4
プロキシ設定とComposeファイルを書く
config/mcp-proxy.yamlと、cloudflaredに--urlを付けたdocker-compose.yamlを用意します。 - 5
起動する
TUNNEL_TOKENをexportしてdocker compose up -dを実行します。
export TUNNEL_DOMAIN=YOUR_TUNNEL_DOMAIN_HERE
mkdir -p mcp-tunnel/data && cd mcp-tunnel
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout data/ca.key -out data/ca.crt \
-days 3650 -subj "/CN=mcp-tunnel-ca" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash"
cat > data/tls.ext <<EOF
subjectAltName = DNS:${TUNNEL_DOMAIN},DNS:*.${TUNNEL_DOMAIN}
authorityKeyIdentifier = keyid,issuer
extendedKeyUsage = serverAuth
EOF
openssl req -newkey rsa:2048 -nodes \
-keyout data/tls.key -out /tmp/server.csr \
-subj "/CN=${TUNNEL_DOMAIN}"
openssl x509 -req -in /tmp/server.csr \
-CA data/ca.crt -CAkey data/ca.key -CAcreateserial \
-out data/tls.crt -days 90 -extfile data/tls.ext
chmod 644 data/tls.keyCAにsubjectKeyIdentifier、サーバー証明書にauthorityKeyIdentifierを付けているのは、自前のPKIで発行する証明書の要件だからです。サーバー証明書はCAが直接署名し(中間CAは不可)、このAKIがCAのSKIと一致している必要があります。CAは単一の証明書でRSA 2048bit以上(またはECDSA P-256以上)、PEMで8kB以内です。
この先のプロキシ設定とdocker-compose.yamlはプログラム的アクセスとほぼ同じで、setupサービスが無いぶん短くなります。
docker-compose.yamlはTUNNEL_TOKENをホストの環境変数からデフォルト無しで読むので、新しいシェルを開くたび、そしてホストを再起動するたびにexportをやり直す必要があります。複数ホストに展開する場合は、mcp-tunnel/ディレクトリをコピーし、各ホストでTUNNEL_TOKENを設定してdocker compose up -dを実行します。同じトンネルトークンと証明書がすべてのレプリカで共通に使えます。
routesとupstreamの書き方で起きること
routesはリストではなく、サブドメインからupstreamへの対応表です。upstreamの値はscheme://host:portの形に限られ、ポートは省略できません。パスを付けると設定の読み込み時にinvalid upstream (must be scheme://host:port)で弾かれます。リクエストのパスとクエリはそのままupstreamに転送されるので、パスはルートの側に書かずMCPサーバーのURLに委ねます。
tunnel_domainを空にすると、routesのキーは完全なホスト名で書く必要があります。サブドメインだけでキーを書くならtunnel_domainが要ります。
もう1つ見落としやすいのが、upstreamへの接続先の制限です。upstream.allowed_ipsの既定値はRFC1918の私設アドレス帯で、それ以外のIPに解決されるMCPサーバーへは接続しません。パブリックIPのサーバーへ向けるときは、許可するCIDRをupstream.allowed_ipsに書きます。https://のupstreamにはupstream.tls.ca_fileかupstream.tls.include_system_casのどちらかも要ります。
デプロイを検証する
サンプルMCPサーバーを使っている場合、ルーティングされたURLはhttps://echo.<トンネルドメイン>/mcpになります。Managed AgentセッションかMessages APIから、このURLに向けてリクエストが通ることを確認します。MCP自体の基本的な仕組みはMCPとはにまとめています。手元からcurl https://<プロキシ>:8080を打つ検証はできません。リスナーは平文のWebSocketで、TLSはそのストリームの内側で始まるため、wrong version numberで失敗するのが正常です。
通らないときは、プロキシとcloudflaredが別コンテナなのでdocker compose logs mcp-proxyとdocker compose logs cloudflaredを別々に見ます。次の表が、出力された文言から原因を引く早見表です。
| ログや症状 | 原因 | 直し方 |
|---|---|---|
mcp-proxyがopen /data/tls.key: permission deniedで終了 | 原因鍵が0600で、非rootのコンテナが読めない | 直し方chmod 644 data/tls.key |
mcp-proxyがcannot unmarshal !!seq into map[string]stringで終了 | 原因routesがYAMLのリスト | 直し方routes: { name: http://host:port }の形にする |
mcp-proxyのログにno route for host | 原因tunnel_domainが割り当てドメインと違う、または設定を変えて再起動していない | 直し方Consoleのドメインと一致させ、docker compose restart mcp-proxy |
mcp-proxyのログにIP validation failed: <ip> is not a private address | 原因upstreamがRFC1918の外に解決される | 直し方upstream.allowed_ipsを設定する |
cloudflaredのログにNo ingress rules were defined | 原因cloudflaredに転送先が無い | 直し方--url http://localhost:8080とnetwork_mode: "service:mcp-proxy"を付ける |
症状が増えたときはMCP tunnelsに接続できないときのトラブルシューティングに、OAuthやIP許可リスト絡みの長い切り分けがあります。
手動フローの証明書の出力例
OpenSSL 3.2.1で手順どおりCA、サーバー証明書の順に作ると、次の出力になります。TUNNEL_DOMAINはabcd1234.tunnel.anthropic.comの例です。
$ openssl x509 -in data/tls.crt -noout -ext subjectAltName,authorityKeyIdentifier,extendedKeyUsage
X509v3 Subject Alternative Name:
DNS:abcd1234.tunnel.anthropic.com, DNS:*.abcd1234.tunnel.anthropic.com
X509v3 Authority Key Identifier:
BB:7D:E0:6F:D9:4D:42:FF:5F:F0:70:18:E5:37:19:7C:FB:06:11:8E
X509v3 Extended Key Usage:
TLS Web Server Authentication
$ openssl verify -CAfile data/ca.crt -verify_hostname echo.abcd1234.tunnel.anthropic.com data/tls.crt
data/tls.crt: OKAKIの値はCAのsubjectKeyIdentifierと同じ並び(BB:7D:...:8E)で、ワイルドカードSANのおかげでecho.のような任意のルート名のホスト名検証も通ります。ただし、OpenSSL 3.2.1ではsubjectKeyIdentifier=hashとauthorityKeyIdentifierを指定しない場合も同じ値が自動で付きます。自動で付く版でも、要件に書かれた拡張なので明示しておくのが確実です。
もう1つ、生成直後のdata/tls.keyは-rw-------(600)で作られます。非rootのコンテナは別ユーザーで動くので、chmod 644を忘れるとここが詰まります。前節の表にあるpermission deniedの正体です。
Compose側では、TUNNEL_TOKENを未設定のままdocker compose configを実行しても、Docker Compose v2.2.3ではエラーも警告も出ません。出力にTUNNEL_TOKEN: nullと出るだけです。設定の構文検査ではexport漏れを拾えないので、up -dの前にecho "${TUNNEL_TOKEN:+設定済み}"のような確認を挟む手があります。
トークンをローテーションする
プログラム的アクセスでは、docker-compose.yaml内のsetupサービスの--token-versionを1つ増やし、識別子を再設定してsetupコンポーネントを再実行します。この値をコマンドライン引数ではなくファイルに書いておくのは、次回以降の実行でも新しい値が引き継がれるようにするためです。
# docker-compose.yamlの --token-version を 1 から 2 に書き換えてから実行
export ANTHROPIC_FEDERATION_RULE_ID=fdrl_...
export ANTHROPIC_ORGANIZATION_ID=00000000-0000-0000-0000-000000000000
export ANTHROPIC_IDENTITY_TOKEN=<再発行したOIDC JWT>
docker compose run --rm setup
export TUNNEL_TOKEN=$(sudo cat data/tunnel-token)
docker compose up -d cloudflared手動フローでは、Consoleの詳細ページでRotate tokenを押し、各ホストのTUNNEL_TOKENを更新してdocker compose up -d cloudflaredでcloudflaredを再起動します。
証明書を更新する
サーバー証明書の有効期限の監視と更新は利用者の責任です。プログラム的アクセスでは、次のコマンドで既存のCAを使って新しい証明書に差し替えます。
docker compose run --rm setup renew-cert --output=dir:/data--renew-before=720hを付けたコマンドなら、cronに乗せても安全です。手動フローでは、保存してある既存のCA(data/ca.key)で新しいサーバー証明書に署名し直し、data/tls.crtを置き換えます。
証明書更新の数字
サーバー証明書の有効期間
90日
手順の`-days 90`の値
renew-beforeの閾値
720h
残り30日以上ならno-op
CAの登録枠
2枚
新旧を並走させて入れ替える
更新後の再起動
不要
プロキシがtls.cert_fileをポーリング
期限の監視は自分で用意します。期限切れに気づかないと接続が突然切れるためです。
よくあるつまずき
TUNNEL_TOKENの再exportを忘れる。デフォルト値が無く、未設定でもdocker compose configは通るため、シェルを開き直すたびに再設定が要りますdata/の所有権を合わせ忘れる。コンテナは非rootのUID65532で動くため、ホスト側でchownしていないとボリュームに書き込めません--token-versionを上げずに再実行する。setupバイナリは値が変わっていないローテーションを拒否します- 旧CAを使わずに証明書だけ作り直す。証明書更新は既存のCAで署名し直す作業で、CA自体を作り直すとConsole側の登録もやり直しになります
まとめ
IDプロバイダを持つホストならプログラム的アクセス、持たないホストなら手動フローを選び、起動後に困ったらログの文言を表と突き合わせるのが近道です。運用で手がかかるのはトークンの回転と証明書の更新で、プログラム的アクセスなら--renew-before=720h付きのrenew-certをそのまま定期実行に乗せられます。自前でホストせずPaaSに載せる選択肢としてはRender MCPサーバーでWebサービスとDBを管理するがあります。