Deploy MCP tunnels with Docker Compose ​
Install the MCP tunnel stack on a VM using Docker Compose.
INFO
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:
bash
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")
EOFThe 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
```bash nocheck
mkdir -p mcp-tunnel/{config,data}
cd mcp-tunnel
sudo chown 65532:65532 data
```
The containers run as the non-root UID `65532` and need write access to `data/`.
## Write docker-compose.yaml
```bash
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"
EOF
```
The 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](#optional-use-a-sample-mcp-server), append it as a service:
```bash
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
EOF
```
## Provision the tunnel
Set the identifiers from the [Console create-tunnel flow](/docs/en/agents-and-tools/mcp-tunnels/console#create-a-tunnel):
```bash
export TUNNEL_ID=tnl_...
export ANTHROPIC_FEDERATION_RULE_ID=fdrl_...
export ANTHROPIC_ORGANIZATION_ID=00000000-0000-0000-0000-000000000000
```
If 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_TOKEN` to an OIDC JWT from this host's identity provider. Follow the [WIF guide for your provider](/docs/en/manage-claude/workload-identity-federation#identity-providers) 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:
```bash
docker compose run --rm setup
```
`setup init` is idempotent over `data/`: re-running it reuses the existing CA and skips registration. A new CA is only generated and registered when `data/` is empty or `TUNNEL_ID` has 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](/docs/en/agents-and-tools/mcp-tunnels/troubleshooting#setup-job-authentication-failures) if it errors.
Retrieve your tunnel domain and export it for later steps:
```bash nocheck
export TUNNEL_DOMAIN=$(sudo cat data/tunnel-domain)
echo "$TUNNEL_DOMAIN"
```
::: info
Workload 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_domain` is **required**: the proxy uses it to strip the domain suffix from incoming hostnames before looking up the subdomain in `routes`. `routes` is a flat map from subdomain to upstream URL.
```bash
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
EOF
```
The `echo:` route targets the [sample MCP server](#optional-use-a-sample-mcp-server); replace it with (or add) your own routes. See the [proxy configuration](/docs/en/agents-and-tools/mcp-tunnels/reference#proxy-configuration) reference for all available fields.
Start the deployment ​
```bash nocheck
export TUNNEL_TOKEN=$(sudo cat data/tunnel-token)
docker compose up -d
```
``` [Without programmatic access]
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:
```bash
export TUNNEL_DOMAIN=YOUR_TUNNEL_DOMAIN_HERE
export TUNNEL_TOKEN='eyJ...'
```
## Scaffold and generate certificates
```bash
mkdir -p mcp-tunnel/{data,config}
cd mcp-tunnel
```
The proxy listens on `:8080` over 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](/docs/en/agents-and-tools/mcp-tunnels/reference#certificate-requirements).
```bash
# 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.key
```
## Register the CA certificate in the Console
On the tunnel detail page, scroll to the **Certificates** section and click **Add certificate**. Upload `data/ca.crt` directly with **Choose file** (the modal accepts `.pem`, `.crt`, and `.cer`), or paste its contents:
```bash
cat data/ca.crt
```
The tunnel's status flips to **Active** once a certificate is registered. See [Add a CA certificate](/docs/en/agents-and-tools/mcp-tunnels/console#add-a-ca-certificate).
## Write the proxy config
`tunnel_domain` is **required**: the proxy uses it to strip the domain suffix from incoming hostnames before looking up the subdomain in `routes`. `routes` is a flat map from subdomain to upstream URL, not a list.
```bash
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
EOF
```
The `echo:` route targets the [sample MCP server](#optional-use-a-sample-mcp-server); replace it with (or add) your own routes. See the [proxy configuration](/docs/en/agents-and-tools/mcp-tunnels/reference#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:8080` so 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).
```bash
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"
EOF
```
If you're using the [sample MCP server](#optional-use-a-sample-mcp-server), append it as a service:
```bash
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
EOF
```
## Start the deployment
```bash
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:
bash
# 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 cloudflaredThe 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).
WARNING
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:
bash
docker compose run --rm setup renew-cert --output=dir:/dataTIP
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.
bash
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.extIn either flow the proxy polls tls.cert_file and reloads it automatically, so no restart is required.
Next steps ​
Attach a routed MCP server to a Managed Agent or the Messages API.
Hardening guidance, credential rotation, and breach response.
Diagnose connectivity, TLS, and routing issues.