-
-
Notifications
You must be signed in to change notification settings - Fork 20
Unraid Deployment
Community-contributed, not maintained by the project. This guide was written by @batubozkan for an Unraid + Cloudflare Access setup and is preserved here as-is. It may drift from current releases. The Unraid Community Applications template lives in the repository at
deploy/unraid/teslamate-mcp.xml.
This runbook deploys the teslamate-mcp Docker container on an Unraid server and exposes it to Claude (claude.ai / Claude Desktop / mobile / Claude Code) through Cloudflare Tunnel + a Cloudflare Zero Trust MCP Server Portal — no ports opened on the home network.
claude.ai / Claude Desktop / mobile / Claude Code
│ OAuth 2.1 (Cloudflare Access Managed OAuth; policy: allow you@example.com)
▼
https://mcp.your-domain.com/mcp ← MCP Server Portal (Cloudflare edge)
│ Authorization: Bearer <AUTH_TOKEN> (portal "Custom Headers" upstream auth)
▼
https://teslamate-mcp.your-domain.com/mcp ← Tunnel public hostname
│ Cloudflare Tunnel (outbound-only cloudflared on Unraid)
▼
[Unraid] cloudflared ──► http://192.168.1.100:8888 (teslamate-mcp container)
└──► TeslaMate PostgreSQL (DATABASE_URL)
Why this shape:
-
claude.ai / Claude Desktop custom connectors are OAuth-only — they cannot send a static
bearer token. The portal's Managed OAuth handles the client-side login (via your
Cloudflare Access policy), while the portal authenticates upstream to teslamate-mcp
with its built-in
AUTH_TOKENas a custom header. - Cloudflare Tunnel makes the container reachable from Cloudflare's edge without any inbound firewall/NAT rules.
- Defense in depth: the direct hostname stays bearer-protected;
run_sqlis additionally sandboxed server-side (PostgresREAD ONLYtransaction + forced rollback); use a SELECT-only DB role for a final layer.
Substitute your own values wherever these appear:
| Placeholder | Meaning |
|---|---|
your-domain.com |
Domain active on Cloudflare |
192.168.1.100 |
LAN IP of the Unraid server |
<AUTH_TOKEN> |
Bearer token generated in Phase 1 (never written to disk/docs) |
you@example.com |
Email allowed by the Access policy |
- TeslaMate + PostgreSQL running on Unraid (Docker UI).
- A domain added and active in your Cloudflare account.
- A Cloudflare Zero Trust organization (free plan is fine — first-time setup asks you to pick a team name at https://one.dash.cloudflare.com). The default One-time PIN identity provider (email code) is sufficient.
openssl rand -base64 32 # or: uv run teslamate-mcp gen-tokenSave the value — it is used in the container env and in the Cloudflare portal config.
In the TeslaMate PostgreSQL container console (Unraid Docker UI → PostgreSQL container →
Console): psql -U teslamate teslamate, then:
CREATE ROLE teslamate_ro LOGIN PASSWORD '<choose-a-password>';
GRANT CONNECT ON DATABASE teslamate TO teslamate_ro;
GRANT USAGE ON SCHEMA public TO teslamate_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO teslamate_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO teslamate_ro;Option A — template: copy deploy/unraid/teslamate-mcp.xml to
/boot/config/plugins/dockerMan/templates-user/ on the Unraid flash share, then
Docker → Add Container → select template teslamate-mcp.
Option B — manual (Docker → Add Container):
| Field | Value |
|---|---|
| Name | teslamate-mcp |
| Repository |
ghcr.io/cobanov/teslamate-mcp:latest (this repo's release image, v0.4.0+) |
| Network type | bridge |
| Port | host 8888 → container 8888 (TCP) |
Env DATABASE_URL
|
postgresql://teslamate_ro:<pw>@192.168.1.100:5432/teslamate (or the main teslamate user) |
Env AUTH_TOKEN
|
<AUTH_TOKEN> from step 1.1 |
Env REPORT_TIMEZONE
|
Europe/Istanbul (IANA name; daily/monthly buckets follow local midnight) |
Env LOG_LEVEL
|
INFO |
Private image:
ghcr.io/cobanov/teslamate-mcpis a private GHCR package. Before the first pull, rundocker login ghcr.io -u <your-github-username>on the Unraid terminal with a GitHub PAT (classic,read:packagesscope) — or make the package public in its GitHub settings. Releases are published by pushing av*tag (git tag v0.x.y && git push origin v0.x.y).
The image's default command already runs the HTTP transport (
teslamate-mcp http --host 0.0.0.0 --port 8888 --json-response --stateless, as of 0.6.0), runs as a non-root user, and has a built-in DockerHEALTHCHECKon/health. Leave Post Arguments empty.--stateless(new in 0.6.0, SDK v2 / MCP 2026-07-28) serves legacy-era clients like the portal without per-session server state; drop the flag with Post Arguments if a client ever misbehaves without sessions.
Historical (only if running the upstream
cobanov/…:0.3.1image): that image's--json-responseflag crashes at startup. Workaround: set Post Arguments toteslamate-mcp http --host 0.0.0.0 --port 8888. Fixed in 0.4.0 — the workaround must be removed when upgrading, or you'll silently keep SSE-response mode.
Postgres reachability:
192.168.1.100:5432works when the TeslaMate PostgreSQL container publishes port 5432 (the standard Unraid TeslaMate setup does). If it doesn't, either publish it or put both containers on the same custom Docker network and use the container name as host.
curl http://192.168.1.100:8888/health
# → {"status":"ok","version":"0.6.0"} (or newer)
curl -i http://192.168.1.100:8888/mcp
# → HTTP 401 (bearer auth active)Important:
/healthand the 401 check do not touch the database. A missingDATABASE_URLfails at startup, but a wrong one (bad host/password) only surfaces on the first real MCP request — the session hangs (SSE pings only), times out, and all subsequent requests return500until the container is restarted. Never uselocalhostas the DB host: inside the container it points at the container itself, not the Unraid host. Use192.168.1.100. Test connectivity from inside the container with:docker exec teslamate-mcp python -c "import os,psycopg; psycopg.connect(os.environ['DATABASE_URL'], connect_timeout=5); print('DB OK')"
-
https://one.dash.cloudflare.com → Networks → Tunnels → Create a tunnel →
connector type Cloudflared → name it
unraid. - Copy the tunnel token (long
eyJ…string) from the install command shown.
Install Cloudflared from Unraid Community Apps (image cloudflare/cloudflared), with
post arguments / command:
tunnel --no-autoupdate run --token <TUNNEL_TOKEN>
The dashboard should show the tunnel status HEALTHY within a minute.
In the tunnel's Public hostnames tab → Add a public hostname:
| Field | Value |
|---|---|
| Subdomain | teslamate-mcp |
| Domain | your-domain.com |
| Type | HTTP |
| URL | 192.168.1.100:8888 |
Cloudflare creates the DNS CNAME automatically.
curl https://teslamate-mcp.your-domain.com/health # → 200 {"status":"ok",...}
curl -i https://teslamate-mcp.your-domain.com/mcp # → 401 without token
curl -s -X POST https://teslamate-mcp.your-domain.com/mcp \
-H "Authorization: Bearer <AUTH_TOKEN>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
# → MCP initialize result (serverInfo "teslamate")Since 0.6.0 the server runs SDK v2 and speaks both protocol eras: the legacy
initializehandshake above (what the Cloudflare portal uses) and the stateless MCP 2026-07-28 revision (server/discover, no sessions). The 2025-06-18 curl stays valid as the portal-path check.
Zero Trust → Access controls → AI controls → MCP servers tab → Add an MCP server:
| Field | Value |
|---|---|
| Name | teslamate-mcp |
| HTTP URL | https://teslamate-mcp.your-domain.com/mcp |
| Authentication |
Custom Headers → header Authorization, value Bearer <AUTH_TOKEN>
|
| Access policy | Allow → Emails → you@example.com
|
teslamate-mcp does not implement OAuth/dynamic client registration, so Custom Headers
is the correct (and documented) auth method for it. Save — Cloudflare connects, syncs
capabilities, and the server status becomes Ready (27 tools with writes enabled as of
0.7.0 — incl. the show_charging_curve MCP Apps chart — 7 prompts, 3 resources).
After deploying a new server version with added/changed tools, force ⋯ → Sync capabilities
on the MCP server entry (auto-resync is ~2h).
MCP Apps note (0.7.0+):
show_charging_curvecarries aui://chart the client can render in-conversation (extensionio.modelcontextprotocol/ui). Whether the interactive chart appears depends on the client and on the portal passing the extension through; on clients without Apps support the tool degrades to plain data rows, identical toget_charging_curve.
Zero Trust → Access controls → AI controls → Add MCP server portal:
| Field | Value |
|---|---|
| Name |
home-mcp (any) |
| Custom domain | subdomain mcp, domain your-domain.com (CNAME to gateway.agents.cloudflare.com is auto-created) |
| MCP servers | add teslamate-mcp
|
| Access policy | Allow → Emails → you@example.com
|
Then edit the portal → Advanced settings → enable Managed OAuth (this is what lets MCP clients like claude.ai authenticate without a browser-cookie flow).
Without this, Claude fails at Dynamic Client Registration with "Couldn't register with …'s
sign-in service" — Access rejects the client's redirect_uri
(invalid_client_metadata: redirect_uri is not allowed by the account configuration).
Zero Trust → Access controls → Applications → the portal's auto-created application → Edit → Advanced settings → Managed OAuth:
| Setting | Value |
|---|---|
| Allowed redirect URIs | https://claude.ai/api/mcp/auth_callback |
| (second entry) | https://claude.com/api/mcp/auth_callback |
| Allow localhost clients | On (CLI clients like Claude Code) |
| Allow loopback clients | On |
Optional: under the portal's server settings you can disable individual tools (e.g. hide
run_sql if you only want the predefined analytics).
The portal endpoint is now: https://mcp.your-domain.com/mcp
Capabilities re-sync automatically ~every 2 hours; after adding new queries to the server, use ⋯ → Sync capabilities to refresh immediately.
Settings → Connectors → Add custom connector → URL https://mcp.your-domain.com/mcp →
Connect → complete the Cloudflare Access login (email one-time PIN). Tools appear under
the connector; try "What's my car's current status?".
Note: connectors run from Anthropic's cloud, which is why the portal must be publicly reachable (it is — on Cloudflare's edge).
claude mcp add --transport http teslamate https://mcp.your-domain.com/mcp
# then inside a session: /mcp → Authenticate (opens browser OAuth){
"mcpServers": {
"teslamate": {
"command": "npx",
"args": ["-y", "mcp-remote@latest", "https://mcp.your-domain.com/mcp"]
}
}
}Two distinct failures, in the order you're likely to meet them:
-
"Couldn't register with …'s sign-in service" at Connect → Dynamic Client Registration
rejected. Fix: allowlist Claude's callback URIs (Phase 3.3). Diagnose by POSTing a fake
registration to
https://<team>.cloudflareaccess.com/cdn-cgi/access/oauth/registration— aredirect_uri is not allowed by the account configurationbody confirms it. -
"Authorization with the MCP server failed" before any login screen (historical,
reported mid-2026, since fixed on Cloudflare's side in this deployment's testing) —
caused by a missing
WWW-Authenticate: … resource_metadata=…header on the portal's 401. Verify withcurl -si -X POST https://mcp.<domain>/mcp | grep -i www-authenticate; the header should reference/.well-known/oauth-protected-resource/mcp. If it's absent: use Claude Desktop withmcp-remote(config above) as the workaround, or front the tunnel origin with a Workers OAuth proxy (workers-oauth-provider) as a last resort.
-
curl http://192.168.1.100:8888/health→ 200 (LAN) -
curl https://teslamate-mcp.your-domain.com/health→ 200 (tunnel) -
curl https://teslamate-mcp.your-domain.com/mcpwithout token → 401 - MCP initialize with bearer token → success (Phase 2.4)
- Portal dashboard: server Ready, 25 tools synced (26 with
ENABLE_CHARGING_WRITES=true, 0.5.0+) - Client connected via portal URL;
get_basic_car_informationreturns your car - Negative:
run_sqlwithDELETE FROM cars→ rejected (validator + READ ONLY txn)
Lets Claude write charging prices into the database — e.g. paste receipts and have it match
them to sessions (search_charging_sessions) and set each cost (set_charging_cost).
-
Grant the single writable column — TeslaMate Postgres container console
(
psql -U teslamate teslamate):This column-scoped grant is the security boundary: no other table or column can ever be written, regardless of any bug.GRANT UPDATE (cost) ON charging_processes TO teslamate_ro;
run_sqladditionally stays READ ONLY at the transaction level. -
Enable the flag — teslamate-mcp container → add env
ENABLE_CHARGING_WRITES=true→ Apply. - Re-sync the portal — AI controls → MCP servers → teslamate-mcp → ⋯ → Sync capabilities → 26 tools.
- claude.ai will ask for approval on each write call (or choose "always allow" for the
connector). The portal's per-tool toggle can hide
set_charging_costagain at any time without touching the server.
Receipt workflow: use the backfill_costs_from_receipts prompt, or just tell Claude
"Here are my charging receipts — match them to my sessions and set the costs."
| Symptom | Likely cause / fix |
|---|---|
Crash at startup: no field "streamable_http_json_response"
|
Only on the old upstream 0.3.1 image — upgrade to ghcr.io/cobanov/teslamate-mcp:0.4.0+ (or see the historical workaround in Phase 1.3) |
| Portal shows old tool count after upgrading the container | Sync ran while the container was restarting — re-run ⋯ → Sync capabilities once /health responds |
set_charging_cost fails with InsufficientPrivilege
|
The column grant is missing — run GRANT UPDATE (cost) ON charging_processes TO teslamate_ro; in the Postgres console |
GHCR push from release workflow fails 403 Forbidden
|
An existing teslamate-mcp package isn't linked to the repo (e.g. orphaned from a deleted repo) — delete the stale package or grant the repo Write under the package's Manage Actions access
|
First MCP call hangs (SSE pings only), then every call 500s |
Container can't reach Postgres (e.g. localhost in DATABASE_URL) — fix the URL host to 192.168.1.100, then restart the container (0.5.1+ contains the failure per-call instead of poisoning the transport) |
500 on every request, log shows Task group is not initialized + Postgres says remaining connection slots are reserved
|
Pre-0.5.1 per-session pool leak — restart the container to free the connections and upgrade to 0.5.1+ (shared pool) |
522 on teslamate-mcp.your-domain.com
|
cloudflared container down or wrong service URL in public hostname |
522 on mcp.your-domain.com
|
Portal DNS CNAME missing (should point to gateway.agents.cloudflare.com) |
| Container restarts at boot | Bad DATABASE_URL (fail-fast) — check container log |
401 even with token |
Token mismatch: compare container AUTH_TOKEN vs portal custom header (Bearer prefix included?) |
| Portal server stuck "Sync Required" | Re-save the custom header credentials; ⋯ → Sync capabilities |
Sync fails HTTP 403 / Error POSTing to endpoint: error code: 1003
|
Trailing-slash mismatch: the portal's saved hostname ends in /mcp/ (SDK v1's canonical form, and the hostname field is immutable) while SDK v2 serves /mcp and 307-redirects /mcp/; uvicorn's redirect behind the tunnel has a malformed Location, which Cloudflare rejects as 1003. Fixed in 0.6.1 — the server normalizes /mcp/ to /mcp, so both forms answer directly. Upgrade the container, then Sync capabilities
|
| claude.ai "Authorization failed" at Connect | Known Managed-OAuth/connector issue — see Phase 4 fallbacks |
| Tools list stale after adding a query | Portal syncs ~2h; force with Sync capabilities |
-
AUTH_TOKENis shared only between the container and the portal config. Anyone hitting the direct hostname without it gets 401. - The MCP server enforces read-only execution for
run_sqlat the Postgres level (READ ONLYtransaction, forced rollback, statement timeout, row cap). - The
teslamate_roSELECT-only role guarantees even the predefined-query path can't write. - Access policy restricts the portal (and OAuth) to your email; add MFA/IdP in Zero Trust if you later share access.