Deploy MCP tunnels with Docker Compose
Install the MCP tunnel stack on a VM using Docker Compose.
MCP tunnels is a research preview feature. Request access to try it.
This guide deploys the MCP tunnel stack as hardened containers on a single host. The same configuration can be replicated across multiple hosts for availability.
Before you begin
You need:
- A tunnel created in the Console. Follow Create a tunnel and record the tunnel ID (
tnl_...). - A way for the host to authenticate to the Tunnels API.
- Programmatic access (recommended). Turn on Set up programmatic access when creating the tunnel so the
setupservice can authenticate through Workload Identity Federation. Record the federation rule ID (fdrl_...) and your organization ID. - Manual. Skip programmatic access. You'll get the tunnel token from the Console, generate a CA and server certificate yourself, and register the CA in the Console.
- Programmatic access (recommended). Turn on Set up programmatic access when creating the tunnel so the
- A host with Docker and Docker Compose installed. The manual flow also requires
openssl(1.1.1 or newer). - Outbound network connectivity from the host to
api.anthropic.com(443 TCP) and the tunnel edge (7844 TCP and UDP). See the full network requirements. - One or more MCP servers running and reachable from the host on the addresses you'll configure under
routes. If you don't have one yet, use the sample server.
Optional: Use a sample MCP server
If you don't have an MCP server available for testing, use this minimal one:
mkdir -p mcp-tunnel/{config,data}
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
The following Install steps cd into mcp-tunnel/ and note where to add the corresponding service and route.
Install
This guide provides one reference approach using Docker Compose. You are responsible for adapting it to meet your organization's security requirements.
The setup service uses Workload Identity Federation to fetch the tunnel token, generate a CA and server certificate, and register the CA with Anthropic.
Prepare the deployment directory
mkdir -p mcp-tunnel/{config,data} cd mcp-tunnel sudo chown 65532:65532 dataThe containers run as the non-root UID
65532and need write access todata/.Write docker-compose.yaml
cat > docker-compose.yaml <<'EOF' services: setup: image: us-docker.pkg.dev/anthropic-public-registry/images/mcp-proxy@sha256:6b9adedbf2763143ec72f106ecaf0ce7fd3294e89b208f54a1db97a33d14c5ba 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 security_opt: - no-new-privileges:true cap_drop: - ALL profiles: ["setup"] cloudflared: image: cloudflare/cloudflared@sha256:6b599ca3e974349ead3286d178da61d291961182ec3fe9c505e1dd02c8ac31b0 command: tunnel --no-autoupdate run --url http://localhost:8080 environment: - TUNNEL_TOKEN # Share the proxy's netns so localhost:8080 reaches it. network_mode: "service:mcp-proxy" restart: unless-stopped user: "65532:65532" read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL stop_grace_period: 30s logging: options: max-size: "10m" max-file: "3" mcp-proxy: image: us-docker.pkg.dev/anthropic-public-registry/images/mcp-proxy@sha256:6b9adedbf2763143ec72f106ecaf0ce7fd3294e89b208f54a1db97a33d14c5ba volumes: - ./config/mcp-proxy.yaml:/etc/mcp-gateway/config.yaml:ro - ./data:/data:ro restart: unless-stopped user: "65532:65532" read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL stop_grace_period: 30s logging: options: max-size: "10m" max-file: "3" EOFThe compose file pins images by SHA-256 digest, runs every container as non-root with a read-only filesystem, drops all Linux capabilities, and disables privilege escalation.
If you're using the sample MCP server, append it as a service:
cat >> docker-compose.yaml <<'EOF' hello-mcp: image: python:3.13-slim working_dir: /app volumes: - ./hello_server.py:/app/hello_server.py:ro command: sh -c "pip install --quiet mcp && python hello_server.py" restart: unless-stopped EOFProvision the tunnel
Set the identifiers from the Console create-tunnel flow:
export TUNNEL_ID=tnl_... export ANTHROPIC_FEDERATION_RULE_ID=fdrl_... export ANTHROPIC_ORGANIZATION_ID=00000000-0000-0000-0000-000000000000If your federation rule is scoped to a workspace other than your organization's default, also set
ANTHROPIC_WORKSPACE_ID=wrkspc_...; setup uses the default workspace otherwise.Set
ANTHROPIC_IDENTITY_TOKENto an OIDC JWT from this host's identity provider. Follow the WIF guide for your provider to register the issuer, set the rule's subject, and mint the token; the rule's audience must match the audience you request when minting. If this host has no identity provider, switch to the Without programmatic access tab.Run setup:
docker compose run --rm setupsetup initis idempotent overdata/: re-running it reuses the existing CA and skips registration. A new CA is only generated and registered whendata/is empty orTUNNEL_IDhas changed; in that case the cap of two active certificates applies, so revoke one in the Console first if both slots are filled.See Setup Job authentication failures if it errors.
Retrieve your tunnel domain and export it for later steps:
export TUNNEL_DOMAIN=$(sudo cat data/tunnel-domain) echo "$TUNNEL_DOMAIN"NoteWorkload Identity Federation tokens are short-lived (one hour by default) and expire automatically; there is nothing to revoke after setup completes.
Write the proxy config
tunnel_domainis required: the proxy uses it to strip the domain suffix from incoming hostnames before looking up the subdomain inroutes.routesis a flat map from subdomain to upstream URL.cat > config/mcp-proxy.yaml <<EOF listen_addr: ":8080" log_level: info shutdown_timeout: 30s tunnel_domain: ${TUNNEL_DOMAIN} tls: cert_file: /data/tls.crt key_file: /data/tls.key routes: echo: http://hello-mcp:9000 EOFThe
echo:route targets the sample MCP server; replace it with (or add) your own routes. See the proxy configuration reference for all available fields.Start the deployment
export TUNNEL_TOKEN=$(sudo cat data/tunnel-token) docker compose up -d
Use this flow if you didn't turn on Set up programmatic access, or for local development and testing. There is no setup service.
Get the tunnel token and domain from the Console
On the tunnel detail page, copy the Domain (it has the form
abcd1234.tunnel.anthropic.com), then click the eye icon next to Token to fetch the tunnel token and use the copy icon to copy it.Set both as shell variables for the rest of the guide:
export TUNNEL_DOMAIN=YOUR_TUNNEL_DOMAIN_HERE export TUNNEL_TOKEN='eyJ...'Scaffold and generate certificates
mkdir -p mcp-tunnel/{data,config} cd mcp-tunnelThe proxy listens on
:8080over plain WebSocket; the inner TLS handshake happens inside that WebSocket stream using these certificates. Anthropic verifies the inner handshake against the CA you register in the Console. The server certificate's SAN must include*.<tunnel-domain>per the certificate requirements.# Self-signed CA. Explicit extensions so it satisfies the certificate # requirements regardless of distro openssl.cnf defaults. 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" # Extension file for the server certificate. Using -extfile (instead of # -copy_extensions, which is OpenSSL 3.0+ only) keeps this working on # OpenSSL 1.1.x. cat > data/tls.ext <<EOF subjectAltName = DNS:${TUNNEL_DOMAIN},DNS:*.${TUNNEL_DOMAIN} authorityKeyIdentifier = keyid,issuer extendedKeyUsage = serverAuth EOF # Server certificate signed by the CA 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.keyRegister the CA certificate in the Console
On the tunnel detail page, scroll to the Certificates section and click Add certificate. Upload
data/ca.crtdirectly with Choose file (the modal accepts.pem,.crt, and.cer), or paste its contents:cat data/ca.crtThe tunnel's status flips to Active once a certificate is registered. See Add a CA certificate.
Write the proxy config
tunnel_domainis required: the proxy uses it to strip the domain suffix from incoming hostnames before looking up the subdomain inroutes.routesis a flat map from subdomain to upstream URL, not a list.cat > config/mcp-proxy.yaml <<EOF listen_addr: ":8080" log_level: info tunnel_domain: ${TUNNEL_DOMAIN} tls: cert_file: /data/tls.crt key_file: /data/tls.key routes: echo: http://hello-mcp:9000 EOFThe
echo:route targets the sample MCP server; replace it with (or add) your own routes. See the proxy configuration reference for all available fields.Write docker-compose.yaml
In this flow no ingress rules are configured server-side, so cloudflared needs an explicit local target. Share the proxy's network namespace and pass
--url http://localhost:8080so cloudflared forwards traffic to the proxy in the same netns; without it, requests reach cloudflared with no route and fail with a 503 (which surfaces to callers as a 500).cat > docker-compose.yaml <<'EOF' services: cloudflared: image: cloudflare/cloudflared@sha256:6b599ca3e974349ead3286d178da61d291961182ec3fe9c505e1dd02c8ac31b0 # --url is required: no ingress rules are pushed in the manual flow, # so without it cloudflared 503s every request. command: tunnel --no-autoupdate run --url http://localhost:8080 environment: - TUNNEL_TOKEN # Share the proxy's netns so localhost:8080 reaches it. network_mode: "service:mcp-proxy" restart: unless-stopped user: "65532:65532" read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL stop_grace_period: 30s logging: options: max-size: "10m" max-file: "3" mcp-proxy: image: us-docker.pkg.dev/anthropic-public-registry/images/mcp-proxy@sha256:6b9adedbf2763143ec72f106ecaf0ce7fd3294e89b208f54a1db97a33d14c5ba volumes: - ./config/mcp-proxy.yaml:/etc/mcp-gateway/config.yaml:ro - ./data:/data:ro restart: unless-stopped user: "65532:65532" read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL stop_grace_period: 30s logging: options: max-size: "10m" max-file: "3" EOFIf you're using the sample MCP server, append it as a service:
cat >> docker-compose.yaml <<'EOF' hello-mcp: image: python:3.13-slim working_dir: /app volumes: - ./hello_server.py:/app/hello_server.py:ro command: sh -c "pip install --quiet mcp && python hello_server.py" restart: unless-stopped EOFStart the deployment
docker compose up -d
For a multi-VM deployment, copy your deployment directory to each host, set TUNNEL_TOKEN ($(sudo cat data/tunnel-token) in the programmatic flow, or the revealed value in the manual flow), and run docker compose up -d. The compose file reads TUNNEL_TOKEN from the environment with no default, so the export must run in every fresh shell, including after a reboot. The same tunnel token and certificates work across all replicas.
Verify the deployment
Verify end to end by calling a routed server from Anthropic's side: see Use the tunneled MCP servers. With the sample MCP server, the routed URL is https://echo.<your-tunnel-domain>/mcp. If verification fails, see Troubleshooting.
Upgrades
Run the commands in this section from inside the mcp-tunnel/ deployment directory.
Rotate the tunnel token
With programmatic access, increment --token-version in the setup service command, set the Workload Identity Federation identifiers, mint a fresh OIDC JWT (it will have expired since install), and re-run setup:
# Edit docker-compose.yaml: increment the integer in the setup service's
# --token-version argument (for example, --token-version=1 to
# --token-version=2). The setup binary refuses to rotate when the value
# hasn't changed.
export TUNNEL_ID=tnl_...
export ANTHROPIC_FEDERATION_RULE_ID=fdrl_...
export ANTHROPIC_ORGANIZATION_ID=00000000-0000-0000-0000-000000000000
# export ANTHROPIC_WORKSPACE_ID=wrkspc_... # if your rule is workspace-scoped
# Re-mint ANTHROPIC_IDENTITY_TOKEN per the WIF provider guide for your
# environment (it will have expired since install).
export ANTHROPIC_IDENTITY_TOKEN=...
docker compose run --rm setup
export TUNNEL_TOKEN=$(sudo cat data/tunnel-token)
docker compose up -d cloudflared
The setup binary authenticates with Workload Identity Federation; there is no API token to revoke.
Without programmatic access, click Rotate token on the tunnel detail page in the Console, then update the TUNNEL_TOKEN environment variable on each host and restart cloudflared (docker compose up -d cloudflared).
Clicking Rotate token invalidates the current token immediately. Between that moment and updating TUNNEL_TOKEN on every host and restarting cloudflared, any host whose cloudflared restarts (crash, host reboot) cannot reconnect. Update each host promptly after rotating.
Certificate renewal
You're responsible for monitoring expiry and renewing the server certificate before it expires.
With programmatic access:
docker compose run --rm setup renew-cert --output=dir:/data
Pass --renew-before=720h to make the command a no-op when more than 30 days of validity remain. This makes it safe to run on a fixed schedule.
Without programmatic access, sign a new server certificate with your existing CA (the CA registered in the Console doesn't change) and replace data/tls.crt. Set TUNNEL_DOMAIN first if you're running this from a fresh shell.
export TUNNEL_DOMAIN=YOUR_TUNNEL_DOMAIN_HERE
openssl req -new -key 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
In either flow the proxy polls tls.cert_file and reloads it automatically, so no restart is required.