MCP-Hosting auf AWS ECS Fargate + Cloudflare Tunnel
Setup-Pattern um einen Eigenbau-MCP als hosted Service in av-production (eu-central-1) bereitzustellen. Standard-Pattern seit 2026-05-11 nach hosted-mcp-architektur-2026.
Architektur
claude.ai Pro / iPhone
│ OAuth 2.1 (Scalekit EU) + JWT
▼
Cloudflare Edge (TLS, WAF, Rate, EU-Frankfurt-PoP)
│ mcp-<kunde>.agenticventures.de
▼
Cloudflare Tunnel (kein Public-Ingress am Origin)
│ outbound von cloudflared-Sidecar
▼
AWS ECS Fargate Task (eu-central-1, av-production)
├─ Container 1: FastMCP "vf-mono" mit ScalekitProvider
│ ├─ Stdio-Subprozess: mcp-papierkram → Papierkram-API
│ ├─ Stdio-Subprozess: mcp-ticketpay → TicketPAY-API
│ └─ Stdio-Subprozess: mcp-m365 → MS Graph API
└─ Container 2: cloudflared (Sidecar)
└─ Tunnel-Token aus AWS Secrets Manager
└─ verbindet outbound zu Cloudflare-Edge
Wichtige Eigenschaften:
- Keine Public IP am ECS-Task. Inbound-Security-Group leer.
- Outbound-only auf Port 443 zu Cloudflare-Anycast-IPs.
- Tunnel = einziger Public-Eingang. Kein ALB. Kein ACM-Cert.
- 2 Container im selben Task.
localhost:8080zwischen ihnen. - Multi-Replica moeglich: gleicher Tunnel-Token in N Tasks, Cloudflare-Edge load-balanced automatic.
Was AWS macht vs. was Cloudflare macht
| AWS | Cloudflare |
|---|---|
| ECS-Cluster + Task-Definition (klassisch, nicht Express) | TLS-Edge fuer mcp-<kunde>.agenticventures.de |
| Fargate Compute (1 vCPU / 2 GB Default) | Tunnel-Daemon (cloudflared) registriert outbound |
| ECR Container-Registry | Public DNS → CF-IPs, Tunnel proxiert intern |
| Secrets Manager (Sub-MCP-Tokens + Tunnel-Token) | WAF, Bot-Detection, Rate-Limit am Edge |
| CloudWatch Logs (App + Tunnel) | Analytics (Latency, 5xx, Geographic) |
| IAM (Exec-Role + Task-Role) | Optional: Access (Zero Trust), AI Gateway |
| VPC (default reicht, kein NAT noetig wenn Subnets Public sind) | — |
CDK-Stack-Skelett
Resourcen im Stack (klassisches Fargate, kein Express-Magic):
// 1. ECR Repository
new ecr.Repository(this, 'EcrRepo', { repositoryName: 'mcp-<name>-hosted', ... })
// 2. Secrets Manager — zwei Secrets:
// a) Sub-MCP-Tokens (papierkram, ticketpay, m365)
// b) Cloudflare-Tunnel-Token
new secretsmanager.Secret(this, 'UpstreamTokens', { secretName: '<name>/upstream-tokens' })
new secretsmanager.Secret(this, 'CloudflaredToken', { secretName: '<name>/cloudflared-token' })
// 3. IAM Roles (klassisch):
const execRole = new iam.Role('ExecRole', {
assumedBy: 'ecs-tasks.amazonaws.com',
policies: [TaskExecutionRolePolicy],
})
upstreamTokens.grantRead(execRole)
cloudflaredToken.grantRead(execRole)
const taskRole = new iam.Role('TaskRole', {
assumedBy: 'ecs-tasks.amazonaws.com',
})
// task-role kann leer bleiben — secrets sind via exec-role aufgeloest, container macht keine AWS-API-Calls
// 4. ECS Cluster (kann av-production-shared sein)
const cluster = ecs.Cluster.fromClusterAttributes(this, 'Cluster', { clusterName: 'default' })
// 5. Task Definition mit ZWEI Containern — Right-Sized default
const taskDef = new ecs.FargateTaskDefinition(this, 'TaskDef', {
cpu: 256, // 0.25 vCPU (Fargate-Min) — reicht fuer FastMCP-I/O-Workload
memoryLimitMiB: 512, // 0.5 GB
executionRole: execRole,
taskRole: taskRole,
})
// Container 1: vf-mono
taskDef.addContainer('vf-mono', {
image: ecs.ContainerImage.fromEcrRepository(repo, 'latest'),
essential: true,
portMappings: [{ containerPort: 8080 }],
environment: {
SCALEKIT_ENV_URL: 'https://...',
SCALEKIT_RESOURCE_ID: '...',
PUBLIC_BASE_URL: 'https://mcp-<kunde>.agenticventures.de',
AWS_REGION: this.region,
LOG_LEVEL: 'INFO',
},
secrets: {
PAPIERKRAM_TOKEN: ecs.Secret.fromSecretsManager(upstreamTokens, 'PAPIERKRAM_TOKEN'),
PAPIERKRAM_SUBDOMAIN: ecs.Secret.fromSecretsManager(upstreamTokens, 'PAPIERKRAM_SUBDOMAIN'),
TICKETPAY_API_KEY: ecs.Secret.fromSecretsManager(upstreamTokens, 'TICKETPAY_API_KEY'),
M365_TENANT_ID: ecs.Secret.fromSecretsManager(upstreamTokens, 'M365_TENANT_ID'),
M365_CLIENT_ID: ecs.Secret.fromSecretsManager(upstreamTokens, 'M365_CLIENT_ID'),
M365_CLIENT_SECRET: ecs.Secret.fromSecretsManager(upstreamTokens, 'M365_CLIENT_SECRET'),
},
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'vf-mono' }),
healthCheck: {
command: ['CMD-SHELL', 'curl -f http://localhost:8080/health || exit 1'],
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(5),
},
})
// Container 2: cloudflared-Sidecar
taskDef.addContainer('cloudflared', {
image: ecs.ContainerImage.fromRegistry('cloudflare/cloudflared:latest'),
essential: true,
command: ['tunnel', '--no-autoupdate', 'run'],
secrets: {
TUNNEL_TOKEN: ecs.Secret.fromSecretsManager(cloudflaredToken),
},
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'cloudflared' }),
})
// 6. ECS Service — kein Load-Balancer
new ecs.FargateService(this, 'Service', {
cluster,
taskDefinition: taskDef,
desiredCount: 1,
assignPublicIp: false, // private subnet — only outbound via NAT or via IGW with security-group restrict
// Wenn default-VPC mit public subnets: assignPublicIp: true geht auch, security-group nur outbound 443
})CFN-Resourcen total: ~14 (gegen ~8 bei ECS Express, aber dafuer haben wir Sidecar + keinen ALB-Posten).
Pre-Run-Setup fuer Tunnel-Anlage (Cloudflare-API)
Vor cdk deploy muss der Tunnel angelegt sein, damit der Token im AWS-Secret liegt:
export CF_API_TOKEN="..." # mit Account:Cloudflare Tunnel:Edit + Zone:DNS:Edit
export CF_ACCOUNT_ID="..."
export CF_ZONE_ID="..."
# 1. Tunnel anlegen
TUNNEL_RESPONSE=$(curl -sS -X POST \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"mcp-<name>-hosted","config_src":"cloudflare"}' \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel")
TUNNEL_ID=$(echo "$TUNNEL_RESPONSE" | jq -r '.result.id')
TUNNEL_TOKEN=$(echo "$TUNNEL_RESPONSE" | jq -r '.result.token')
# 2. Ingress-Config setzen
curl -sS -X PUT \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"config\":{\"ingress\":[
{\"hostname\":\"mcp-<name>.agenticventures.de\",\"service\":\"http://localhost:8080\"},
{\"service\":\"http_status:404\"}
]}}" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/configurations"
# 3. Token in AWS Secret pushen (CDK referenziert das Secret dann)
aws secretsmanager create-secret \
--name "mcp-<name>-hosted/cloudflared-token" \
--secret-string "$TUNNEL_TOKEN" \
--profile av-production --region eu-central-1Dann cdk deploy → der Stack bindet das Secret in den cloudflared-Sidecar ein.
Nach Stack-Active + Tunnel-Connected:
# 4. DNS-Record fuer mcp-<name> in Cloudflare anlegen
curl -sS -X POST \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"type\": \"CNAME\",
\"name\": \"mcp-<name>\",
\"content\": \"$TUNNEL_ID.cfargotunnel.com\",
\"proxied\": true
}" \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records"Cost-Modell
Pro hosted MCP, eu-central-1, Right-Sized 0.25 vCPU / 0.5 GB (Default seit 2026-05-12):
| Posten | Right-Sized (Default) | Wenn Workload > 70% saturiert: 1 vCPU / 2 GB |
|---|---|---|
| Fargate Compute (730h/Monat) | ~9 USD | ~36 USD |
| ECR Storage (~3 GB) | 0.30 USD | 0.30 USD |
| Secrets Manager (2 Secrets) | 0.80 USD | 0.80 USD |
| CloudWatch Logs (app + cloudflared) | ~3 USD | ~3 USD |
| Data Transfer Out (Cloudflare-Egress via NAT/IGW) | ~1 USD | ~1 USD |
| Cloudflare Tunnel | 0 USD (Free-Tier) | 0 USD |
| Cloudflare DNS/Edge | 0 USD (Free-Tier) | 0 USD |
| Total | ~14 USD ≈ 13 € | ~41 USD ≈ 38 € |
Vergleich zu altem ECS-Express-Pattern (~65 €/Monat): ~50 €/Monat eingespart pro MCP. (~25 € vom ALB-Wegfall, ~25 € vom Right-Sizing.)
Right-Sizing-Regel: FastMCP-Workload ist mostly async I/O (JWT-Validation gegen JWKS-Cache, stdio-IPC zu Sub-Prozessen, HTTPS-Calls zu Upstream-APIs). Beobachtete CPU-Auslastung bei 1 vCPU war 5-15%, also Faktor 4-6 Headroom. Fargate-Min reicht locker fuer Single-Tenant-MCPs mit bis zu ~50 parallelen Tool-Calls. Bei CloudWatch-Beobachtung von >70% CPU oder Memory: erst auf 512/1024 hochziehen, dann 512/2048, dann 1024/2048.
Bei mehreren MCPs in av-production: Cost ist linear pro MCP — kein ALB-Sharing-Effekt mehr (gab’s nur bei ECS Express). Aber: ECR-Repos und Secrets Manager skalieren billig.
Skalierungs-Patterns
- Multi-Task pro MCP:
desiredCounthochziehen, Cloudflare-Edge load-balanced automatic ueber alle Connector-Replicas. Kein Service-Discovery-Bullshit. Pro Task 4 outbound Connections, also bei 3 Tasks = 12 Connections im Tunnel. - Auto-Scaling: Application Auto Scaling auf CPU-Threshold haengen. Multi-Replica-Tunnel ist nativ unterstuetzt.
- Multi-Tenant (Phase 2 vom Pipeline-Projekt): ein Service pro Tenant, Multi-Tenant-Pattern (DynamoDB + ABAC) gleich wie bei klassischem Fargate.
Lessons Learned
- ECS Express Mode war fuer Custom-Domain ein No-Go — der managed ALB erlaubt nur den AWS-vergebenen Hostname, Custom-Cert + Listener-Rule manuell anflanschen ist nicht dokumentiert + fragil. Sidecar-Container sind hard requirement fuer Tunnel-Pattern → klassisches Fargate.
- Cloudflared-Container ist stateless. Token im Secret, Container kann jederzeit neu gestartet werden, Tunnel-Connection rebuilds automatic.
cloudflared tunnel run(ohne Tunnel-ID-Arg) liest die Tunnel-ID + Config aus dem Token — du musst die nicht separat injizieren. Sauberer als die altecloudflared --config /etc/cloudflared/config.yml-Welt.config_src: cloudflarebeim Tunnel-Anlegen → Config liegt zentral in CF, du brauchst keine config.yml im Container. Ingress-Rules sind via API setzbar, also reproducible.- Health-Check muss am App-Container haengen (curl
localhost:8080/health). Cloudflared selbst hat keinen/health-Endpoint, aberessential: truereicht — ECS killt den ganzen Task wenn cloudflared abkackt. - Logs zusammenhalten: zwei separate CloudWatch-Log-Groups oder gleiche Group mit verschiedenen Stream-Prefixes. Beim Debugging will man beide neben einander sehen (
aws logs tail --follow --filter-pattern '?vf-mono ?cloudflared').
Stolperer-Cheatsheet
| Symptom | Ursache | Fix |
|---|---|---|
Tunnel zeigt inactive im Cloudflare-Dashboard auch nach Task-Start | Token nicht korrekt im Container — pruefen ob Secret-Value das Token ist (nicht file://... als String) | aws secretsmanager get-secret-value checken |
Cloudflared startet aber unauthenticated in Logs | Token gehoert zu anderem Tunnel oder Token revoked | Tunnel-Token aus Cloudflare-Dashboard neu generieren + Secret updaten |
mcp-<kunde>.agenticventures.de gibt 522 (Connection Timed Out) | DNS-Record fehlt oder zeigt nicht auf <tunnel-id>.cfargotunnel.com | DNS-Record in CF pruefen, sollte proxied=true sein |
mcp-<kunde>.agenticventures.de gibt 530 (Origin DNS Error) | Tunnel-ID stimmt nicht oder Tunnel nicht connected | dig cname mcp-<kunde>... + Cloudflare-Dashboard Tunnel-Status |
| App-Container CrashLoopBackoff | Secret-Werte falsch oder fehlend (z.B. SCALEKIT_RESOURCE_ID) | CloudWatch app-Logs lesen, pydantic-Settings-Errors sind sprechend |
ECS Service stuck in PROVISIONING | Image fehlt in ECR oder Task-Role hat keinen secretsmanager:GetSecretValue | aws ecr describe-images + IAM-Policy pruefen |
Container kommt aktiv aber /health HTTPS-extern 5xx | cloudflared verbindet nicht zu localhost:8080 (Race-Condition?) | Health-Check-Interval/Retries hochziehen, app-Container braucht ~5-10s zum Boot |
Tunnel-Token-Rotation
# Quartalsweise oder bei Verdacht:
# 1. Neuen Token via Cloudflare-API generieren
NEW_TOKEN=$(curl -sS -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/token" \
| jq -r '.result')
# 2. AWS Secret updaten
aws secretsmanager put-secret-value \
--secret-id "mcp-<name>-hosted/cloudflared-token" \
--secret-string "$NEW_TOKEN" \
--profile av-production --region eu-central-1
# 3. ECS Force-Deploy
aws ecs update-service --cluster default --service mcp-<name>-hosted \
--force-new-deployment \
--profile av-production --region eu-central-1Cloudflare-Doku: alte Tokens bleiben aktiv bis Container restart — kein Service-Disruption.
Wann NICHT dieses Pattern
- Local Single-User-MCP in Claude Code → stdio bleibt, kein Hosting noetig
- Sehr seltene Calls (1 pro Stunde) → ECS-Task always-on ist Verschwendung, Lambda + API Gateway koennte besser sein
- Kunde will eigenes AWS-Konto → dann nicht in
av-productionsondern inav-<kunde>-Subaccount, gleiches Pattern aber dedicated Compute - Volle Mandanten-Trennung mit BSI-Pruefung → eigener Stack pro Kunde inkl. eigenem Cloudflare-Zone wenn noetig
Quirks + Gotchas
FastMCP DNS-Rebinding-Schutz blockt Tunnel-Host (421 Invalid Host header)
Symptom: Production-MCP-Endpoint antwortet auf POST /mcp mit HTTP 421 Invalid Host header. Health-Endpoint GET /health liefert 200. Lokal auf 127.0.0.1:8771 funktioniert alles.
Ursache: mcp.server.fastmcp.FastMCP haengt per Default eine TransportSecuritySettings-Middleware vor /mcp mit enable_dns_rebinding_protection=True und einer Allowlist nur fuer 127.0.0.1:*, localhost:*, [::1]:*. Cloudflared leitet den Public-Host-Header (mcp-<name>.agenticventures.de) ungeaendert durch, der Server lehnt ihn ab.
Fix: Env-Var MCP_ALLOWED_HOSTS=<public-domain> setzen und im main() des Servers vor mcp.run(...):
extra_hosts_raw = os.environ.get("MCP_ALLOWED_HOSTS", "")
extra_hosts = [h.strip() for h in extra_hosts_raw.split(",") if h.strip()]
if extra_hosts:
sec = mcp.settings.transport_security
sec.allowed_hosts = list(sec.allowed_hosts) + extra_hosts
sec.allowed_origins = list(sec.allowed_origins) + [f"https://{h}" for h in extra_hosts]Im CDK-Stack die Env-Var am Container setzen:
environment: {
// ...
MCP_ALLOWED_HOSTS: 'mcp-<name>.agenticventures.de',
},Pflicht fuer jeden Fargate-Tunnel-MCP der mit FastMCP gebaut wird. Erstmals 2026-05-15 bei mcp-whatsapp aufgeschlagen, weil das der erste Tunnel-MCP ohne ScalekitProvider war (Scalekit fing das Problem implizit ab). Bei reinen API-Key-MCPs ohne Auth-Layer kommt der Request direkt am FastMCP-Streamable-HTTP-Handler an und 421et.
Smoke nach Deploy:
curl -sS -X POST https://mcp-<name>.agenticventures.de/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-06-18' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0.1"}}}'
Erwartet: HTTP 200 + serverInfo-Antwort als SSE-Event. Bei 421 → Allowlist fehlt.
Re-Evaluate Q3-Q4 2026: Cloudflare Containers
Siehe hosted-mcp-architektur-2026. Wenn 5+ Kunden auf diesem Pattern laufen und Cloudflare Containers GA-mature ist:
- Test-Migration mit
mcp-gsuite-hosted(Marvin-only) - Vergleich Cost + Latenz + Operations ueber 30 Tage
- Wenn deutlich besser: Migration aller Kunden-MCPs
Bis dahin: dieses Pattern ist das einzige Standard-Pattern fuer hosted MCPs.
Related
- hosted-mcp-architektur-2026 — die Entscheidungs-Grundlage
- mcp-hosting-aws-ecs-express — Vorgaenger-Pattern (deprecated)
- cloudflare-capability-map — strategischer Cloudflare-Ueberblick
- cloudflare-dsgvo — DSGVO-Bewertung Cloudflare
- fargate-tunnel-migration-prompt — ausfuehrbarer Migrations-Prompt
- _index — Pipeline-Plan