Tesla’s Vehicle Command SDK includes an HTTPS proxy that signs vehicle commands. I run it alongside evcc on a Raspberry Pi 4, controlling a Tesla Wall Connector Gen 3.
The difficult part was not starting the proxy: it was connecting the application registration, account permissions, and vehicle key in the correct order.
Verification status: The proxy runs on the Pi, its HTTPS certificate validates, Tesla’s API returns my vehicle, the virtual key is enrolled, and evcc reads live battery and charging data through the proxy. A deliberate charging-command test is the remaining end-to-end check.
What is needed
- A Raspberry Pi 4 (or newer) running a 64-bit OS. The official image is published for arm64; a 32-bit armv6/armv7 host cannot run it.
- evcc on the same host, so the proxy can stay bound to loopback.
- A Tesla developer application and access to the vehicle-owner account.
- A public HTTPS domain for hosting the application’s public key.
- The Tesla mobile app, signed in as the vehicle owner.
This setup uses Tesla’s Internet-based Fleet API, not Bluetooth. Self-hosting the proxy does not bypass Tesla’s API billing or quotas.
Architecture
The proxy and evcc share one host, so the proxy listens only on 127.0.0.1:4443 and never touches the network:
Tesla Fleet API <--HTTPS--> tesla-http-proxy <--loopback--> evcc (127.0.0.1:4443) (systemd)Co-locating removes the need for a cross-host tunnel, a forwarding key, and a second machine to keep patched. The signing key and the component that decides to charge live together.
Earlier revision: This guide previously described running the proxy on Unraid with an SSH tunnel from the Pi. That worked, but the tunnel was an extra moving part. The steps below are the co-located version.
1. Create the Tesla developer application
Create an application in the Tesla developer dashboard. Save its Client ID and Client Secret privately.
For charging-only evcc use, enable:
| Permission | OAuth scope | Purpose |
|---|---|---|
| Vehicle Information | vehicle_device_data | Identify the vehicle and read battery/charging data |
| Vehicle Charging Management | vehicle_charging_cmds | Start/stop charging and set charging current |
The authorization request also includes openid and offline_access so the client can maintain access with refresh tokens. Broader Vehicle Commands and Vehicle Location permissions are not needed for this charging-only setup.
I used myTeslaMate’s OAuth callback to display the authorization code, while hosting my own command-signing key:
| Developer setting | Value used |
|---|---|
| Allowed Origin | https://dvkwong.com |
| Allowed Redirect URI | https://app.myteslamate.com/auth/tesla/user/callback |
| Allowed Returned URL | https://app.myteslamate.com/ |
The callback and key-hosting domain serve different purposes. The blog does not implement an OAuth callback. If using a different OAuth client, use its actual registered callback instead.
2. Store configuration on the Pi
The official image runs as UID/GID 65532. Keep the keys outside any container-managed directory so a container rebuild cannot touch them.
sudo install -d -o 65532 -g 65532 -m 700 /etc/tesla-vehicle-commandDo not place the keys on a FAT or vfat filesystem, and do not rely on a relative ./config path. On the earlier Unraid deployment, /boot was FAT with root-only permissions and the container failed with:
Error: open /config/fleet-key.pem: permission deniedAn absolute path on a normal Linux filesystem avoids that class of problem.
3. Generate separate signing and HTTPS keys
The signing key authenticates commands to the vehicle. The TLS key secures the proxy connection. They must be different keys.
Run once, before any key has been enrolled. Do not overwrite existing keys when repeating the setup.
(set -euumask 077cd /etc/tesla-vehicle-commandfor file in fleet-key.pem public-key.pem tls-key.pem tls-cert.pem; do if [ -e "$file" ]; then echo "STOP: $file already exists; preserve the existing keys." exit 1 fidone
openssl ecparam -name prime256v1 -genkey -noout -out fleet-key.pemopenssl pkey -in fleet-key.pem -pubout -out public-key.pem
openssl req -x509 -nodes -newkey ec \ -pkeyopt ec_paramgen_curve:secp384r1 \ -subj '/CN=evcc' \ -keyout tls-key.pem -out tls-cert.pem -sha256 -days 825 \ -addext 'subjectAltName=DNS:evcc.local,DNS:localhost,IP:127.0.0.1' \ -addext 'extendedKeyUsage=serverAuth' \ -addext 'keyUsage=digitalSignature'
chown 65532:65532 fleet-key.pem public-key.pem tls-key.pem tls-cert.pemchmod 600 fleet-key.pem tls-key.pemchmod 644 public-key.pem tls-cert.pem)Back up the keys securely. Replacing the signing key requires enrolling the new public key in the vehicle.
Migrating an existing deployment: copy
fleet-key.pemfrom the old host rather than generating a new one. The enrolled public key stays valid, so the vehicle does not need re-enrolment. Regenerate only the TLS pair, since the certificate should name the new host.
4. Run the proxy
The Pi 4 is arm64, so the published image runs natively. Docker is optional: on a 1 GB Pi, running the binary directly under systemd is leaner than adding the Docker daemon.
Option A: Docker Compose
services: tesla_http_proxy: image: tesla/vehicle-command:latest restart: unless-stopped ports: - "127.0.0.1:4443:4443" environment: TESLA_HTTP_PROXY_TLS_CERT: /config/tls-cert.pem TESLA_HTTP_PROXY_TLS_KEY: /config/tls-key.pem TESLA_HTTP_PROXY_HOST: 0.0.0.0 TESLA_HTTP_PROXY_PORT: "4443" TESLA_HTTP_PROXY_TIMEOUT: 10s TESLA_KEY_FILE: /config/fleet-key.pem TESLA_VERBOSE: "false" security_opt: - no-new-privileges:true volumes: - /etc/tesla-vehicle-command:/config:roOption B: systemd without Docker
The Docker image runs as numeric UID 65532, which exists inside the container but not on a normal Linux host. systemd refuses to start a service as an unassigned UID and fails with:
Process: ExecStart=/usr/local/bin/tesla-http-proxy (code=exited, status=217/USER)Create a matching system user first:
sudo groupadd --system --gid 65532 tesla-proxysudo useradd --system --uid 65532 --gid 65532 \ --home-dir /nonexistent --shell /usr/sbin/nologin \ --comment "Tesla command proxy" tesla-proxyBuild the binary on the Pi, or cross-compile an arm64 build from another machine:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ go build -trimpath -ldflags="-s -w" \ -o tesla-http-proxy ./cmd/tesla-http-proxysudo install -m 755 tesla-http-proxy /usr/local/bin/tesla-http-proxyThen create /etc/systemd/system/tesla-http-proxy.service:
[Unit]Description=Tesla command proxyAfter=network-online.targetWants=network-online.target
[Service]User=tesla-proxyGroup=tesla-proxyEnvironment=TESLA_HTTP_PROXY_TLS_CERT=/etc/tesla-vehicle-command/tls-cert.pemEnvironment=TESLA_HTTP_PROXY_TLS_KEY=/etc/tesla-vehicle-command/tls-key.pemEnvironment=TESLA_HTTP_PROXY_HOST=127.0.0.1Environment=TESLA_HTTP_PROXY_PORT=4443Environment=TESLA_HTTP_PROXY_TIMEOUT=10sEnvironment=TESLA_KEY_FILE=/etc/tesla-vehicle-command/fleet-key.pemExecStart=/usr/local/bin/tesla-http-proxyRestart=alwaysRestartSec=10NoNewPrivileges=truePrivateTmp=true
[Install]WantedBy=multi-user.targetsudo systemctl daemon-reloadsudo systemctl enable --now tesla-http-proxyVerify the listener
curl --cacert /etc/tesla-vehicle-command/tls-cert.pem \ https://127.0.0.1:4443/api/1/vehiclesWithout an OAuth token, the expected result is HTTP 403 with client did not provide an OAuth token. This verifies the HTTPS listener, not vehicle access.
Only loopback is bound. Do not expose port 4443 through a router or Cloudflare Tunnel. Because evcc runs on the same host, it reaches the proxy over loopback with no tunnel and no firewall rule.
5. Publish only the public key
Display or copy this file:
cat /etc/tesla-vehicle-command/public-key.pemHost its raw PEM contents at this exact path on your domain:
https://YOUR_DOMAIN/.well-known/appspecific/com.tesla.3p.public-key.pemFor my Astro blog, the source file is public/.well-known/appspecific/com.tesla.3p.public-key.pem, published at:
https://dvkwong.com/.well-known/appspecific/com.tesla.3p.public-key.pemCloudflare or any HTTPS static host can serve it. The response must be the public key, not HTML, a login page, or a bot challenge. Keep the URL available after enrollment.
Never publish fleet-key.pem, tls-key.pem, OAuth tokens, or the client secret.
6. Store OAuth application credentials privately
These credentials are used for token requests, not placed in the proxy’s environment.
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 700 /etc/tesla-vehicle-command/oauthnano /etc/tesla-vehicle-command/oauth/client.jsonEnter your own values:
{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET"}This directory is outside the proxy’s mounted configuration. Do not commit it to Git or share its contents.
7. Register the public-key domain with Tesla
Publishing the key and adding Allowed Origins do not perform Fleet API partner registration.
The following requests a temporary partner token and registers the domain directly with Tesla. It uses the NA Fleet API endpoint, which was confirmed as the correct region for my New Zealand account. Other accounts should use the appropriate regional endpoint.
Change domain before running this on another installation:
(set -euumask 077cd /etc/tesla-vehicle-command/oauthdomain=dvkwong.comapi=https://fleet-api.prd.na.vn.cloud.tesla.comwork=$(mktemp -d)trap 'rm -rf "$work"' EXIT
jq -rj --arg audience "$api" \ '{grant_type:"client_credentials",client_id,client_secret, scope:"openid vehicle_device_data vehicle_charging_cmds", audience:$audience} | to_entries | map((.key|@uri)+"="+(.value|@uri)) | join("&")' \ client.json > "$work/request"
curl --fail-with-body --silent --show-error --max-time 45 \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-binary @"$work/request" \ https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \ -o "$work/token"
jq -er '"Authorization: Bearer " + (.access_token | select(length > 0))' \ "$work/token" > "$work/header"jq -n --arg domain "$domain" '{domain:$domain}' > "$work/registration"
curl --fail-with-body --silent --show-error --max-time 45 \ -H @"$work/header" -H 'Content-Type: application/json' \ --data-binary @"$work/registration" \ "$api/api/1/partner_accounts" -o "$work/result"jq '{response,error,error_description}' "$work/result")Successful registration returned HTTP 200 with domain: dvkwong.com. The published public key was also checked against the proxy’s private key.
Use jq -rj, not just jq -r, when creating the form body: a trailing newline in the final audience value caused an invalid_audience error during setup.
Do not rerun myTeslaMate’s full registration script for this self-hosted domain. Its registration step targets a myTeslaMate hostname, which produced a root-domain/Allowed Origin mismatch in this setup.
8. Authorize the vehicle-owner account
A partner token registers the application; it is not a user access token. The vehicle owner must separately grant access.
Generate a login URL on the Pi:
(set -euumask 077cd /etc/tesla-vehicle-command/oauthstate=$(openssl rand -hex 32)printf '%s' "$state" > authorization-statejq -r --arg state "$state" \ '"https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/authorize?" + ({client_id,redirect_uri:"https://app.myteslamate.com/auth/tesla/user/callback", response_type:"code", scope:"openid offline_access vehicle_device_data vehicle_charging_cmds", state:$state,prompt:"consent"} | to_entries | map((.key|@uri)+"="+(.value|@uri)) | join("&"))' \ client.json)Open that URL, sign in with the same owner account used in the Tesla app, select the vehicle, and grant Vehicle Information and Vehicle Charging Management. For a self-hosted callback, validate the returned state against the stored value; this walkthrough uses myTeslaMate’s callback/code-display flow.
If no consent screen appears and the required permissions remain missing, revoke only this application under the Tesla account’s Security → Third-Party Apps, then start a fresh authorization flow. Revocation invalidates existing tokens for that application.
Exchange the authorization code immediately
Codes expire quickly and work only once. Run this on the Pi and paste only the newly issued code at the hidden prompt:
(set -euumask 077cd /etc/tesla-vehicle-command/oauthwork=$(mktemp -d)trap 'rm -rf "$work"' EXITread -r -s -p 'New authorization code: ' codeprintf '\n'printf '%s' "$code" > "$work/code"unset code
jq -rj --rawfile code "$work/code" \ '{grant_type:"authorization_code",client_id,client_secret, code:$code,redirect_uri:"https://app.myteslamate.com/auth/tesla/user/callback"} | to_entries | map((.key|@uri)+"="+(.value|@uri)) | join("&")' \ client.json > "$work/request"
curl --silent --show-error --max-time 45 \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-binary @"$work/request" \ https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \ -o "$work/response"
if jq -e '(.access_token|type=="string" and length>0) and (.refresh_token|type=="string" and length>0)' \ "$work/response" >/dev/null; then install -m 600 "$work/response" tokens.json rm -f authorization-state echo 'SUCCESS: OAuth tokens saved privately.'else jq '{error,error_description}' "$work/response" exit 1fi)A successful exchange does not prove that all requested permissions were granted. Initially, my token contained charging permission only; listing vehicles returned 403 Unauthorized missing scopes. Reauthorizing with Vehicle Information fixed vehicle access.
9. Enroll the virtual key in the Tesla app
Do this after account consent, not before.
- Wake the vehicle and select it in the Tesla app.
- On the phone, open
https://tesla.com/_ak/dvkwong.com(replace the domain for another installation). - Approve the virtual-key request.
- Check Controls → Locks in the vehicle for the new key.
Before account consent was complete, the app reported:
Adding a virtual key for "dvkwong.com" is forbidden because you have not granted "dvkwong.com" access to your account.The key was successfully enrolled after correcting the OAuth consent flow. No new signing key was needed.
10. Let evcc trust the proxy certificate
Because evcc runs natively on the same host, install the certificate into the system trust store:
sudo install -m 644 /etc/tesla-vehicle-command/tls-cert.pem \ /usr/local/share/ca-certificates/tesla-proxy.crtsudo update-ca-certificatessudo systemctl restart evccVerify trust without disabling verification:
curl --silent --show-error --max-time 10 \ -o /dev/null -w 'HTTP %{http_code}\n' \ https://127.0.0.1:4443/api/1/vehiclesExpect HTTP 403 (client did not provide an OAuth token). A TLS error instead means the certificate is not trusted or the SAN does not cover 127.0.0.1.
Do not disable TLS verification, and never mount or copy either private key into evcc.
If evcc runs in a container instead: mount the certificate read-only into the container’s trust directory and persist the mapping in the container template, not with a one-off
docker cp. The earlier Unraid deployment used a path mapping to/etc/ssl/certs/tesla-proxy.pemfor this reason.
11. Configure the vehicle and loadpoint in evcc
Open Configuration → Vehicles → Add → Tesla, not Tesla BLE.
| Field | Value |
|---|---|
| Client ID | Your Tesla application’s Client ID |
| Access token | Latest user OAuth access token |
| Refresh token | Matching user OAuth refresh token |
| VIN | Your vehicle’s VIN |
| Command Proxy, under advanced settings | https://127.0.0.1:4443 |
| Proxy Token | Leave empty; this is for the paid myTeslaMate proxy |
To display the saved tokens only in your own Pi terminal:
jq '{access_token, refresh_token}' \ /etc/tesla-vehicle-command/oauth/tokens.jsonCopy them into evcc; do not share screenshots or paste them into chat. evcc should manage subsequent token refreshes. The manually saved file is a bootstrap snapshot, not an automatically synchronized copy of evcc’s current tokens.
Then open Configuration → Loadpoints → Tesla Charger → Edit, select the newly added vehicle, and save. Keep charging mode Off until testing is ready.
12. Verify before enabling automation
- Confirm the proxy service stays running without a restart loop.
- Confirm HTTPS works with certificate verification enabled.
- Confirm evcc can read the correct vehicle’s battery level.
- With the car plugged in and ready, deliberately test charging start/stop and current adjustment.
- Check evcc and proxy logs for TLS, OAuth, or key-pairing errors.
Reading battery data verifies OAuth access, but does not verify signed commands. Only a successful charging command completes the end-to-end check.
Useful checks:
systemctl is-active tesla-http-proxy evccjournalctl -u tesla-http-proxy -n 50 --no-pagerjournalctl -u evcc -n 50 --no-pager | grep -i tesla13. Migrating from a separate host
If the proxy previously ran elsewhere, the signing key must be copied, not regenerated:
# On the old hostscp /path/to/config/fleet-key.pem pi@evcc.local:/tmp/
# On the Pisudo install -o 65532 -g 65532 -m 600 \ /tmp/fleet-key.pem /etc/tesla-vehicle-command/fleet-key.pemrm /tmp/fleet-key.pemConfirm the copy is the enrolled key before trusting it. Compare the public key derived from the private key against the published one:
openssl pkey -in /etc/tesla-vehicle-command/fleet-key.pem -pubout \ | openssl pkey -pubin -outform DER | sha256sum
curl -s https://YOUR_DOMAIN/.well-known/appspecific/com.tesla.3p.public-key.pem \ | openssl pkey -pubin -outform DER | sha256sumThe hashes must match. If they do, the vehicle does not need re-enrolment. Regenerate only the TLS pair, since the certificate should name the new host.
Then remove the old path:
- Stop and disable the old proxy.
- Remove any SSH tunnel service and its forwarding key from the old host’s
authorized_keys. - Remove the old certificate from the trust store.
- Confirm evcc still reads battery data before deleting the old keys.
Verify the forwarding key is actually rejected, rather than assuming:
ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o IdentitiesOnly=yes -i /path/to/tunnel-key root@OLD_HOST true# expect: Permission denied (publickey,...)
