Self-hosted backend for comma.ai devices running openpilot or sunnypilot. Collects dashcam video, driving logs, GPS tracks, and device telemetry -- then lets you review everything through a built-in web UI.
No account required. No data leaves your network.
- Drop-in API replacement -- mirrors the official comma backend endpoints (
/v2/pilotauth/,/v1.4/upload_url/,/v1/route/,/v1.1/devices/, etc.) so openpilot and sunnypilot connect without any client-side patches - Video ingestion and transcoding -- receives HEVC uploads from three cameras (road, driver, wide) and transcodes them to HLS for browser playback, with automatic fallback from container copy to libx264 re-encoding
- Live device communication -- persistent WebSocket channel per device for real-time JSON-RPC: push config changes, request file uploads and snapshots, set nav destinations, query network/SIM/thermal status, inspect and cancel the device upload queue
- Device configuration -- read and write device parameters through the API or the web UI, with changes pushed to the device in real time over WebSocket
- GPS track storage -- stores route geometry as PostGIS LineStrings for spatial queries and map display
- Trip aggregation -- background worker computes per-route distance, duration, and speed stats from the GPS geometry; reverse-geocodes start/end addresses via Nominatim
- Event detection -- background worker parses qlogs and extracts "moments" (hard brakes, disengagements, FCW alerts, warnings) with tunable thresholds
- Log signal overlay -- the video player renders a canvas timeline of speed, steering, and engagement state pulled from parsed qlogs, with click-to-seek
- Web dashboard -- home page with lifetime stats and recent drives, route browser with multi-camera HLS playback, Leaflet/OpenStreetMap GPS tracks, virtualized log viewer, moments/events listing, upload-queue monitor, device control center, and storage/retention settings
- Shareable read-only links -- signed, time-limited tokens grant unauthenticated access to a single route's video and map with no write access and no leakage of other routes
- Retention and cleanup -- per-route preserve flag, configurable retention window, and a cleanup worker that deletes old non-preserved routes (dry-run by default for safety)
- Optional license-plate stalking detection -- opt-in feature that processes dashcam frames locally to warn when the same vehicle appears across multiple trips. Off by default. See docs/ALPR.md.
- Observability -- Prometheus
/metricsendpoint for request rate and latency, upload throughput, transcode timing, RPC latency, connected device count, and worker health, plus an importable Grafana dashboard
comma device this server
athenad ---- WebSocket (JSON-RPC) ----> ws/hub
uploader ---- PUT /upload/... ---------> storage layer --> FFmpeg transcoder
PostgreSQL + PostGIS
|
Next.js web UI
Backend (Go): Echo HTTP framework, pgx + sqlc for type-safe PostgreSQL access, gorilla/websocket for persistent device connections, FFmpeg for HEVC-to-HLS transcoding.
Frontend (TypeScript): Next.js + React, hls.js for multi-camera HLS playback, Leaflet + OpenStreetMap for GPS track maps, TanStack Table + Virtual for virtualized log viewing.
cmd/server/ -- entrypoint
internal/
api/ -- HTTP handlers (device, route, upload, config, pilotauth, signals, stats, events, live, snapshot, upload-queue, share, session login, storage, settings)
api/middleware/ -- JWT auth + session/cookie middleware (SessionRequired, SessionOrJWT)
cereal/ -- cereal capnp log parser + driving-signal extractor
config/ -- environment/config loader
db/ -- sqlc-generated queries
geocode/ -- Nominatim reverse-geocoding client
metrics/ -- Prometheus collectors
sessioncookie/ -- signed session cookie HMAC helpers
settings/ -- runtime settings store (retention, etc.)
share/ -- signed share-link token helpers
storage/ -- filesystem storage layer
worker/ -- background jobs (HEVC->HLS transcode, cleanup, trip aggregator, event detector)
ws/ -- WebSocket hub, JSON-RPC client/server, device RPC calls
sql/
migrations/ -- PostgreSQL migrations
queries/ -- sqlc query files
web/
src/app/ -- Next.js pages (dashboard home, routes, devices, moments, share, login, settings)
src/components/ -- video player + signal timeline, map, log viewer, snapshot button, share button, device status panel, UI primitives
docs/
grafana-dashboard.json -- importable Grafana dashboard
- Go 1.22+
- Node.js 18+ and pnpm
- PostgreSQL 14+ with PostGIS
- FFmpeg (for video transcoding)
1. Create the database
createdb comma
psql comma -c "CREATE EXTENSION IF NOT EXISTS postgis;"2. Run migrations
Apply every migration in order:
for m in sql/migrations/*.up.sql; do psql comma < "$m"; done3. Configure environment
Copy the example env file and fill in your values:
cp .env.example .env
# Edit .env with your DATABASE_URL| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
yes | -- | PostgreSQL connection string |
STORAGE_PATH |
no | ./data |
Directory for uploaded video/log files |
PORT |
no | 8080 |
API server listen port |
ALLOWED_SERIALS |
no | -- | Comma-separated allowlist of device serials permitted to register (all allowed if unset). The dongle ID is assigned server-side, so restriction is by hardware serial. |
CORS_ALLOWED_ORIGINS |
no | -- | Comma-separated list of origins permitted by the CORS middleware. Required when the frontend is served from a different origin than the API (e.g. the docker prod stack puts the frontend on :80 and the API on :7070). Cannot be * -- the API uses cookie-based session auth so credentialed CORS needs explicit origins. Leave unset for same-origin-only deployments. |
SESSION_SECRET |
no | -- | Required to enable the web UI login. Used as the HMAC key for signed session cookies. If unset, UI auth is disabled (a warning is logged) -- device auth still works. |
ADMIN_USERNAME |
no | -- | When both ADMIN_USERNAME and ADMIN_PASSWORD are set, the server bootstraps (or updates) this user row in ui_users on startup so you can log into the dashboard with env-configured credentials. |
ADMIN_PASSWORD |
no | -- | Plaintext admin password; stored hashed with bcrypt (cost 12). See ADMIN_USERNAME. |
RETENTION_DAYS |
no | 0 |
Default retention window (in days) for non-preserved routes before the cleanup worker deletes them; 0 means never delete. Seeds the retention_days row in the settings table on first boot; overridable at runtime via PUT /v1/settings/retention. |
CLEANUP_ENABLED |
no | true |
When true, runs the background cleanup worker that deletes non-preserved routes older than the effective retention window. Set to false to disable the worker (e.g. in replica deployments). |
DELETE_DRY_RUN |
no | true |
When true (the default, for safety), the cleanup worker logs what it would delete but does not touch the filesystem or the database. Set to false to enable real deletion. |
ALPR_ENCRYPTION_KEY |
no | -- | Base64-encoded 32-byte AES-256 key used to encrypt plate text and derive the keyed plate hash. ALPR cannot be enabled without it. Generate with go run ./cmd/alpr-keygen. Treat like a database password -- losing it means losing all plate data. See docs/ALPR.md. |
ALPR_ENGINE_URL |
no | http://alpr:8081 |
Base URL of the ALPR engine sidecar. The default targets the alpr service on the Compose network and does not need to change in the docker stack. See docs/ALPR.md. |
ALPR_REGION |
no | us |
Country/jurisdiction-specific plate-format model. One of us, eu, uk, other. See docs/ALPR.md. |
ALPR_FPS |
no | 2 |
Frame-extractor sampling rate, in frames per second. Runtime overrides accepted in [0.5, 4]. See docs/ALPR.md. |
ALPR_CONFIDENCE_MIN |
no | 0.75 |
Minimum OCR confidence to retain a plate read. Runtime overrides accepted in [0.5, 0.95]. See docs/ALPR.md. |
ALPR_RETENTION_DAYS_UNFLAGGED |
no | 30 |
Retention window for plate reads not tied to a flagged event. 0 means never delete (not recommended). See docs/ALPR.md. |
ALPR_RETENTION_DAYS_FLAGGED |
no | 365 |
Retention window for plate reads tied to a flagged event. 0 means never delete. See docs/ALPR.md. |
ALPR_NOTIFY_MIN_SEVERITY |
no | 4 |
Minimum alert severity (1..5) that triggers an external notification (email/webhook). See docs/ALPR.md. |
4. Start the backend
go run ./cmd/server5. Start the frontend
pnpm install --dir web
pnpm --dir web devThe API server runs on :8080 and the frontend dev server on :3000 by default.
ALPR (license-plate stalking detection) is off by default and
must be opted into explicitly. It runs as a separate Docker container
(comma-alpr) gated by the alpr Compose profile, so bare
docker compose up and make prod-up do not start it. Enabling it
requires three independent steps: setting ALPR_ENCRYPTION_KEY in
.env, acknowledging the in-UI disclaimer at Settings -> Optional
services -> ALPR, and bringing the engine sidecar up with
make alpr-up. Read docs/ALPR.md before enabling
this -- it covers the threat model, privacy and legal posture
(including jurisdictional notes for Illinois, EU, UK, and Canada),
hardware requirements, the data wipe procedure, and the FAQ.
Set two environment variables on your comma device before openpilot starts:
export API_HOST="https://your-server.example.com"
export ATHENA_HOST="wss://your-server.example.com"See docs/DEVICE-SETUP.md for the full walkthrough -- authentication flow, upload mechanics, WebSocket path rewriting, and troubleshooting.
For a containerized stack -- Postgres + PostGIS, the Go backend, and the Next.js frontend all together -- with no local Go/Node toolchain required beyond Docker:
cp .env.example .env
# Edit .env if you want to override ports or set SESSION_SECRET / admin creds.
make prod-upService URLs with the default .env:
| Service | URL | Container port |
|---|---|---|
| Frontend | http://localhost | :3000 |
| Backend | http://localhost:7070 | :8080 |
| Postgres | localhost:5432 |
:5432 |
Overridable via .env: BACKEND_PORT, FRONTEND_PORT, NEXT_PUBLIC_API_URL, CORS_ALLOWED_ORIGINS, plus every backend env var from the table above.
Two cross-cutting things to know about the Docker stack:
NEXT_PUBLIC_API_URLis baked into the frontend image at build time (Next.js requirement). If you changeBACKEND_PORT, you also need to updateNEXT_PUBLIC_API_URLto the matching browser-reachable URL and rebuild:make prod-build && make prod-up. The variable must be reachable from the user's browser, not from inside the frontend container.- CORS is required because the frontend (
:80) and backend (:7070) are on different origins. The compose file defaultsCORS_ALLOWED_ORIGINStohttp://localhost; override in.envif your frontend lives elsewhere.*is rejected because the API uses cookie-based session auth. - Avoid host port
7000on macOS. macOS's AirPlay Receiver binds*:7000and silently intercepts all traffic on that port before Docker sees it (you'll getServer: AirTunes/...responses). The defaultBACKEND_PORTis7070for this reason; if you change it, steer clear of5000and7000.
Other targets: make prod-down (stop), make prod-build (rebuild images), make prod-logs (tail all services).
On Apple Silicon, the stack uses imresamu/postgis (multi-arch) instead of the official postgis/postgis (amd64-only), so Postgres runs natively without Rosetta emulation.
The local dev setup and the Docker quickstart above are fine for testing or a single-machine deployment behind your home network. For an always-on server that receives uploads from your car over the public internet, see docs/DEPLOYMENT.md -- covers TLS with Caddy/nginx, systemd services, storage planning, and database backups.
Endpoints are grouped by auth mode:
- Device JWT -- issued by
/v2/pilotauth/, required for device-facing endpoints - Session cookie -- issued by
/v1/session/login, required for dashboard mutations - Session-or-JWT -- either works, used by read endpoints shared between the UI and the device
- Public -- no auth (health, session login, share-link consumption,
/metrics)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v2/pilotauth/ |
public | Device registration, returns a signed JWT |
| POST | /v1/session/login |
public | Dashboard login (username + password). Sets an HttpOnly session cookie. Enabled only when SESSION_SECRET is set. Rate limited to 5 attempts / 15 min per IP. |
| POST | /v1/session/logout |
session | Dashboard logout. Clears the session cookie. |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/devices |
session-or-JWT | List registered devices |
| GET | /v1.1/devices/:dongle_id/ |
JWT | Device info |
| GET | /v1/devices/:dongle_id/params |
session-or-JWT | List device config params |
| PUT | /v1/devices/:dongle_id/params/:key |
session | Set a config param (pushed via WebSocket if connected) |
| DELETE | /v1/devices/:dongle_id/params/:key |
session | Delete a config param |
| GET | /v1/devices/:dongle_id/live |
session-or-JWT | Live status (network, metered, SIM, free disk, thermal) with a 5-minute cache when the device is offline |
| GET | /v1/devices/:dongle_id/stats |
session-or-JWT | Lifetime totals + recent trips (?limit= ?offset=) |
| GET | /v1/devices/:dongle_id/events |
session-or-JWT | Detected events/moments (?type= ?severity= ?limit= ?offset=) |
| POST | /v1/devices/:dongle_id/snapshot |
session-or-JWT | Request an on-demand JPEG snapshot from both cameras (rate limited to 1 per 5s per device) |
| GET | /v1/devices/:dongle_id/upload-queue |
session-or-JWT | List the device's current upload queue |
| POST | /v1/devices/:dongle_id/upload-queue/cancel |
session | Cancel queued uploads by ID |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/route/:dongle_id |
session-or-JWT | List routes (paginated, ?limit= ?offset=) |
| GET | /v1/route/:dongle_id/:route_name |
session-or-JWT | Route detail with full segment list |
| GET | /v1/routes/:dongle_id/:route_name/signals |
JWT | Parsed log signals (speed, steering, engagement, alerts) for the signal timeline; results are cached to signals.json next to each qlog |
| GET | /v1/routes/:dongle_id/:route_name/trip |
JWT | Aggregated trip row for the route (404 if not yet aggregated) |
| GET | /v1/routes/:dongle_id/:route_name/export.gpx |
session-or-JWT | Download the GPS track as GPX |
| GET | /v1/routes/:dongle_id/:route_name/export.mp4 |
session-or-JWT | Download a concatenated MP4 of the route's HLS video |
| POST | /v1/routes/:dongle_id/:route_name/share |
session | Create a signed, time-limited public share link (body: {"expires_in_hours": int (default 72, max 720)}; 501 if SESSION_SECRET is unset) |
| GET | /v1.4/:dongle_id/upload_url/ |
JWT | Get self-hosted upload URL for a segment file |
| PUT | /upload/:dongle_id/* |
JWT | Upload a segment file (up to 100 MB) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/share/:token |
token | Route metadata, segment list, geometry, and media base URL for a shared route (410 Gone on expiry, 401 on tampered token) |
| GET | /v1/share/:token/segments/:seg/:file |
token | Stream a whitelisted HLS file (qcamera.ts, index.m3u8) scoped to the share token's route |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/storage/usage |
session-or-JWT | Disk usage breakdown: total, per-device, filesystem available |
| GET | /v1/settings/retention |
session-or-JWT | Read current retention window in days (0 means never delete) |
| PUT | /v1/settings/retention |
session | Update retention window (body: {"retention_days": int}, must be >= 0) |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /ws/v2/:dongle_id |
JWT | Persistent device channel (JSON-RPC over WebSocket) |
Supported RPC methods (server-initiated calls to the device): uploadFileToUrl, uploadFilesToUrls, getNetworkType, getNetworkMetered, getNetworks, getSimInfo, getMessage, setNavDestination, setParam, deleteParam, listDataDirectory, takeSnapshot, listUploadQueue, cancelUpload, setRouteViewed, getVersion, getPublicKey, getSshAuthorizedKeys, getGithubUsername.
The ALPR feature is off by default and must be enabled per docs/ALPR.md. When the alpr_enabled setting is off, mutation paths return 503 and read paths return the off state; when no ALPR_ENCRYPTION_KEY is configured the PUT /v1/settings/alpr enable path is rejected with 409. Endpoints currently registered on this branch:
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/settings/alpr |
session-or-JWT | Read effective ALPR settings (merged env defaults + DB overrides), engine reachability, encryption-key configured flag, current disclaimer version (2026-04-v1), and whether re-ack is required |
| PUT | /v1/settings/alpr |
session | Update runtime overrides (alpr_enabled, region, fps, confidence, retention, notify severity). Enabling requires both ALPR_ENCRYPTION_KEY set and the current disclaimer acked; 409 otherwise |
| POST | /v1/settings/alpr/disclaimer/ack |
session | Acknowledge the current disclaimer (version must equal 2026-04-v1); records the ack timestamp and selected jurisdiction |
| GET | /v1/routes/:dongle_id/:route_name/plates |
session-or-JWT | Per-route plate encounters (decrypted server-side). Returns 503 alpr_disabled when alpr_enabled=false so the frontend can render a "feature disabled" state. Includes per-encounter watchlist (severity / ack) state, first-bbox, and a sample_thumb_url placeholder pointing at /v1/alpr/detections/:id/thumbnail (handler is a follow-up; the path is stable) |
| GET | /v1/plates/:hash_b64 |
session | Cross-route history for a single plate hash (URL-safe base64, no padding). Returns watchlist state, paginated encounters[] (?limit=, ?offset=; default 50, max 200), and a stats envelope (distinct_routes_30d, total_detections, etc.). 404 when no encounters exist for the hash; 503 alpr_disabled when ALPR is off |
| GET | /v1/alpr/alerts |
session | Paginated alert feed (alerted-kind watchlist rows). Filters: ?status=open|acked|all (default open), ?severity=N[,N...], ?dongle_id=. Pagination ?limit= (default 25, max 100) + ?offset=. Each item includes decrypted plate, severity, evidence summary, latest route, and ack timestamp |
| GET | /v1/alpr/alerts/summary |
session | Cheap dashboard-badge counts: {open_count, max_open_severity, last_alert_at}. Single-page query path so latency stays under 50ms even on a populated watchlist |
| POST | /v1/alpr/alerts/:hash_b64/ack |
session | Mark an alert acknowledged (acked_at = now()). Idempotent. 404 on unknown hash. Audited action='alert_ack' |
| POST | /v1/alpr/alerts/:hash_b64/unack |
session | Clear acked_at. 404 on unknown. Audited action='alert_unack' |
| POST | /v1/alpr/alerts/:hash_b64/note |
session | Set plate_watchlist.notes. Empty string clears the note. Audit payload omits the note text. 404 on unknown |
| GET | /v1/alpr/whitelist |
session | Paginated whitelist entries with decrypted labels. Same ?limit=/?offset= defaults as the alerts feed |
| POST | /v1/alpr/whitelist |
session | Body {plate: string, label?: string}. Hash + encrypt via internal/alpr/crypto, upsert with kind='whitelist'. A transition from kind='alerted' clears severity/timestamps and emits an internal AlertSuppressed event so notification subsystems can decrement open counts. 400 when plate is empty after normalization. Audited action='whitelist_add' |
| DELETE | /v1/alpr/whitelist/:hash_b64 |
session | Remove a whitelist row. 404 when missing; 409 when the row exists with a non-whitelist kind (use the appropriate endpoint instead). Audited action='whitelist_remove' |
The following ALPR endpoints are described by the broader ALPR feature batch but are not yet present on this branch. They are listed here so the table reflects the eventual surface; they will be filled in as their implementation features land:
GET /v1/routes/:dongle_id/:route_name/turns-- per-route turn-signal events used by the stalking heuristicPATCH /v1/alpr/detections/:id-- correct or annotate a detectionPOST /v1/alpr/plates/merge-- merge OCR-near-duplicate plate hashesGET,POST /v1/alpr/backfill/*-- historical-route backfill controlPOST /v1/alpr/heuristic/reevaluate-- re-score existing encountersPOST /v1/alpr/notify/test-- send a test notification
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
public | Returns {"status": "ok"} |
| GET | /metrics |
public | Prometheus metrics (HTTP, upload, transcode, RPC, worker, WebSocket). See docs/DEPLOYMENT.md for the Grafana import flow. |
$STORAGE_PATH/
<dongle_id>/
<route_name>/
<segment_number>/
fcamera.hevc # road camera (raw HEVC)
ecamera.hevc # wide camera
dcamera.hevc # driver camera
qcamera.ts # low-res preview
rlog # full log
qlog # compressed log
fcamera/index.m3u8 # HLS (generated by transcoder)
ecamera/index.m3u8
dcamera/index.m3u8
go test ./... # backend tests
pnpm --dir web test # frontend tests
go vet ./... # lint
pnpm --dir web lint # frontend lint
pnpm --dir web type-check # TypeScript type check
go build -o server ./cmd/server # production binary