ROUND 1 ... FIGHT!

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:

PermissionOAuth scopePurpose
Vehicle Informationvehicle_device_dataIdentify the vehicle and read battery/charging data
Vehicle Charging Managementvehicle_charging_cmdsStart/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 settingValue used
Allowed Originhttps://dvkwong.com
Allowed Redirect URIhttps://app.myteslamate.com/auth/tesla/user/callback
Allowed Returned URLhttps://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.

Terminal window
sudo install -d -o 65532 -g 65532 -m 700 /etc/tesla-vehicle-command

Do 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 denied

An 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.

Terminal window
(
set -eu
umask 077
cd /etc/tesla-vehicle-command
for 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
fi
done
openssl ecparam -name prime256v1 -genkey -noout -out fleet-key.pem
openssl 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.pem
chmod 600 fleet-key.pem tls-key.pem
chmod 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.pem from 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:ro

Option 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:

Terminal window
sudo groupadd --system --gid 65532 tesla-proxy
sudo useradd --system --uid 65532 --gid 65532 \
--home-dir /nonexistent --shell /usr/sbin/nologin \
--comment "Tesla command proxy" tesla-proxy

Build the binary on the Pi, or cross-compile an arm64 build from another machine:

Terminal window
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -trimpath -ldflags="-s -w" \
-o tesla-http-proxy ./cmd/tesla-http-proxy
Terminal window
sudo install -m 755 tesla-http-proxy /usr/local/bin/tesla-http-proxy

Then create /etc/systemd/system/tesla-http-proxy.service:

[Unit]
Description=Tesla command proxy
After=network-online.target
Wants=network-online.target
[Service]
User=tesla-proxy
Group=tesla-proxy
Environment=TESLA_HTTP_PROXY_TLS_CERT=/etc/tesla-vehicle-command/tls-cert.pem
Environment=TESLA_HTTP_PROXY_TLS_KEY=/etc/tesla-vehicle-command/tls-key.pem
Environment=TESLA_HTTP_PROXY_HOST=127.0.0.1
Environment=TESLA_HTTP_PROXY_PORT=4443
Environment=TESLA_HTTP_PROXY_TIMEOUT=10s
Environment=TESLA_KEY_FILE=/etc/tesla-vehicle-command/fleet-key.pem
ExecStart=/usr/local/bin/tesla-http-proxy
Restart=always
RestartSec=10
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now tesla-http-proxy

Verify the listener

Terminal window
curl --cacert /etc/tesla-vehicle-command/tls-cert.pem \
https://127.0.0.1:4443/api/1/vehicles

Without 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:

Terminal window
cat /etc/tesla-vehicle-command/public-key.pem

Host its raw PEM contents at this exact path on your domain:

https://YOUR_DOMAIN/.well-known/appspecific/com.tesla.3p.public-key.pem

For 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.pem

Cloudflare 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.

Terminal window
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 700 /etc/tesla-vehicle-command/oauth
nano /etc/tesla-vehicle-command/oauth/client.json

Enter 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:

Terminal window
(
set -eu
umask 077
cd /etc/tesla-vehicle-command/oauth
domain=dvkwong.com
api=https://fleet-api.prd.na.vn.cloud.tesla.com
work=$(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:

Terminal window
(
set -eu
umask 077
cd /etc/tesla-vehicle-command/oauth
state=$(openssl rand -hex 32)
printf '%s' "$state" > authorization-state
jq -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:

Terminal window
(
set -eu
umask 077
cd /etc/tesla-vehicle-command/oauth
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
read -r -s -p 'New authorization code: ' code
printf '\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 1
fi
)

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.

  1. Wake the vehicle and select it in the Tesla app.
  2. On the phone, open https://tesla.com/_ak/dvkwong.com (replace the domain for another installation).
  3. Approve the virtual-key request.
  4. 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:

Terminal window
sudo install -m 644 /etc/tesla-vehicle-command/tls-cert.pem \
/usr/local/share/ca-certificates/tesla-proxy.crt
sudo update-ca-certificates
sudo systemctl restart evcc

Verify trust without disabling verification:

Terminal window
curl --silent --show-error --max-time 10 \
-o /dev/null -w 'HTTP %{http_code}\n' \
https://127.0.0.1:4443/api/1/vehicles

Expect 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.pem for this reason.

11. Configure the vehicle and loadpoint in evcc

Open Configuration → Vehicles → Add → Tesla, not Tesla BLE.

FieldValue
Client IDYour Tesla application’s Client ID
Access tokenLatest user OAuth access token
Refresh tokenMatching user OAuth refresh token
VINYour vehicle’s VIN
Command Proxy, under advanced settingshttps://127.0.0.1:4443
Proxy TokenLeave empty; this is for the paid myTeslaMate proxy

To display the saved tokens only in your own Pi terminal:

Terminal window
jq '{access_token, refresh_token}' \
/etc/tesla-vehicle-command/oauth/tokens.json

Copy 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:

Terminal window
systemctl is-active tesla-http-proxy evcc
journalctl -u tesla-http-proxy -n 50 --no-pager
journalctl -u evcc -n 50 --no-pager | grep -i tesla

13. Migrating from a separate host

If the proxy previously ran elsewhere, the signing key must be copied, not regenerated:

Terminal window
# On the old host
scp /path/to/config/fleet-key.pem pi@evcc.local:/tmp/
# On the Pi
sudo install -o 65532 -g 65532 -m 600 \
/tmp/fleet-key.pem /etc/tesla-vehicle-command/fleet-key.pem
rm /tmp/fleet-key.pem

Confirm the copy is the enrolled key before trusting it. Compare the public key derived from the private key against the published one:

Terminal window
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 | sha256sum

The 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:

  1. Stop and disable the old proxy.
  2. Remove any SSH tunnel service and its forwarding key from the old host’s authorized_keys.
  3. Remove the old certificate from the trust store.
  4. Confirm evcc still reads battery data before deleting the old keys.

Verify the forwarding key is actually rejected, rather than assuming:

Terminal window
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,...)

References