# Pin the Compose project name so Docker Desktop / `docker compose # ps` show "vfusion" instead of inferring it from the local # directory (which is still named vSplice on disk even though the # GitHub repo was renamed to vFusion). Without this, every container # / network / volume gets the old vsplice_ prefix. name: vfusion services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: verkada POSTGRES_PASSWORD: verkada POSTGRES_DB: vfusion volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U verkada -d vfusion"] interval: 3s timeout: 3s retries: 10 redis: image: redis:7-alpine restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 3s timeout: 3s retries: 10 backend: build: context: ./backend restart: unless-stopped environment: DATABASE_URL: postgresql+asyncpg://verkada:verkada@postgres:5432/vfusion REDIS_URL: redis://redis:6379/0 SECRET_KEY: ${SECRET_KEY:-} FERNET_KEY: ${FERNET_KEY:-} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:15173} PUBLIC_WEBHOOK_BASE: ${PUBLIC_WEBHOOK_BASE:-} # "beta" (default), "stable", or "off" to stop asking GitHub # whether a newer release exists. See backend/app/updates.py. UPDATE_CHANNEL: ${UPDATE_CHANNEL:-beta} # Was documented in .env.example but never passed through, so # setting it there had no effect: the backend reads it from the # process environment, and compose only used .env for its own # substitutions. GEOIP_PROVIDER: ${GEOIP_PROVIDER:-ip-api} # Force UTF-8 everywhere. python:3.x slim images default to the # C/POSIX locale, which makes locale.getpreferredencoding() # return 'ANSI_X3.4-1968' (ASCII). Any library that emits a # log line / exception with non-ASCII chars (the google-genai # upload path is one — a status indicator like a check-mark # bombs the worker on encode) crashes the call. PYTHONUTF8=1 # is the canonical fix for Python 3.7+; LANG/LC_ALL backstop # any subprocess or C-extension that reads the system locale. PYTHONUTF8: "1" PYTHONIOENCODING: utf-8 LANG: C.UTF-8 LC_ALL: C.UTF-8 MQTT_INGEST_ENABLED: ${MQTT_INGEST_ENABLED:-} MQTT_HOST: ${MQTT_HOST:-mqtt-broker} MQTT_PORT: ${MQTT_PORT:-1883} MQTT_USERNAME: ${MQTT_USERNAME:-} MQTT_PASSWORD: ${MQTT_PASSWORD:-} MQTT_BROKER_HOST_PORT: ${MQTT_BROKER_HOST_PORT:-} # The port the Command Connector dials. Only differs from 8554 when # something else on this host already holds it, in which case remap # the published port below to match. RTSP_PUBLIC_PORT: ${RTSP_PUBLIC_PORT:-8554} # The port an ONVIF client is told to reach, which has to match the # published mapping below — it goes inside the URLs the device # hands out for itself, its snapshot and its streams. ONVIF_PUBLIC_PORT: ${ONVIF_PUBLIC_PORT:-8090} ports: - "18080:8000" # A second mapping onto the same app, so the ONVIF client can be # given a port of its own. Verkada's Add Cameras dialog defaults to # 80 but lets you type one, so this does not have to fight anything # already on the host for it. - "${ONVIF_PUBLIC_PORT:-8090}:8000" depends_on: postgres: condition: service_healthy redis: condition: service_healthy volumes: - ./backend:/app - webhook_assets:/app/data # Persisted FERNET_KEY lives here when FERNET_KEY env is unset. # Survives container/image rebuilds; back up this volume for DR. - vfusion_secrets:/app/secrets # Read-write: vFusion generates the broker certs and password file # itself, then hands root_ca.pem to cameras when it pushes config. - ./mqtt:/app/mqtt-host # Read-write: vFusion generates the RTSP server's config, including # the credentials it hands to the Command Connector. - ./rtsp:/app/rtsp-host command: > sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" # Healthcheck hits the public /api/health endpoint (no auth, returns # {"ok": true}). Lets `docker compose ps` show backend as healthy # once migrations + uvicorn boot complete, and lets dependants # (worker scheduled jobs, ops scripts) gate on actual readiness # instead of just "container running". start_period covers the # ~5-10s alembic upgrade on cold starts so we don't false-fail. healthcheck: test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health', timeout=2).status == 200 else 1)"] interval: 10s timeout: 3s retries: 5 start_period: 20s worker: build: context: ./backend restart: unless-stopped environment: DATABASE_URL: postgresql+asyncpg://verkada:verkada@postgres:5432/vfusion REDIS_URL: redis://redis:6379/0 FERNET_KEY: ${FERNET_KEY:-} # See backend service: forces UTF-8 throughout the worker # process so Gemini SDK log lines / file-upload metadata with # non-ASCII chars (status check-marks, etc.) don't blow up # encode(). PYTHONUTF8: "1" PYTHONIOENCODING: utf-8 LANG: C.UTF-8 LC_ALL: C.UTF-8 depends_on: postgres: condition: service_healthy redis: condition: service_healthy volumes: - ./backend:/app - webhook_assets:/app/data # Same key the backend uses — worker needs to decrypt API keys # to run actions against Verkada / Gemini. - vfusion_secrets:/app/secrets command: arq app.worker.WorkerSettings # Cloudflare Tunnel — lab mode. Stable URL on your own domain. # Requires CF_TUNNEL_TOKEN in .env. See README "Lab deploy". # docker compose --profile cloudflared up -d cloudflared: image: cloudflare/cloudflared:latest restart: unless-stopped command: tunnel --no-autoupdate run environment: - TUNNEL_TOKEN=${CF_TUNNEL_TOKEN:-} depends_on: - backend profiles: ["cloudflared"] # Quick-mode path filter. Sits between cloudflared-quick and the # backend so the trycloudflare URL only exposes /hooks/verkada (the # path Verkada Command actually POSTs to). Everything else returns # 404 — locks down /api/*, the dashboard, and synthetic /hooks/ # paths from accidental URL leaks. LAN traffic (port 18080 → backend) # is unaffected. caddy-quick: image: caddy:2-alpine restart: unless-stopped volumes: - ./caddy/quick.Caddyfile:/etc/caddy/Caddyfile:ro depends_on: - backend profiles: ["quick"] # Cloudflare TryCloudflare — quick mode. Ephemeral random URL, no # account, no domain. The URL changes every restart, so don't point # production Verkada webhooks at this. Useful for kicking the tires # end-to-end without setting up DNS. The backend's /api/config endpoint # reads the assigned hostname from cloudflared's metrics endpoint and # surfaces it in the Webhook Explorer banner. # Points at caddy-quick (path-filtered) rather than backend directly, # so a leaked trycloudflare URL can't be used to hit /api/*. # docker compose --profile quick up -d cloudflared-quick: image: cloudflare/cloudflared:latest restart: unless-stopped command: tunnel --no-autoupdate --metrics 0.0.0.0:2000 --url http://caddy-quick:8080 depends_on: - caddy-quick profiles: ["quick"] # ---- Object-position MQTT ingest (opt-in: --profile mqtt) ---- # # Off by default because mqtt-tls binds host port 443, which most # deployments are already using for something. Verkada accepts only # 443, 123 or 53 in broker_host_port, so there is no polite alternative # port to fall back to. mqtt-broker: image: eclipse-mosquitto:2 profiles: ["mqtt"] restart: unless-stopped volumes: - ./mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro - ./mqtt/passwd:/mosquitto/config/passwd:ro networks: [default] mqtt-tls: image: nginx:alpine profiles: ["mqtt"] restart: unless-stopped # The camera opens a post-quantum TLS 1.3 handshake, so this image # must ship OpenSSL 3.5+. nginx:alpine currently does. ports: - "443:443" volumes: - ./mqtt/nginx.conf:/etc/nginx/nginx.conf:ro - ./mqtt/certs:/etc/nginx/certs:ro depends_on: - mqtt-broker networks: [default] # The RTSP server the Verkada Command Connector connects to. vFusion # publishes one continuous stream into it and the Connector pulls that # stream as a third-party camera. # # MediaMTX watches its own config file and reloads on change, so # turning the camera on in the UI takes effect without restarting this. # If a reload is ever missed, `docker compose restart rtsp-server` costs # the Connector one reconnect. rtsp-server: # Pin this in a deployment you care about. `latest` is here so a # fresh clone works; a version bump that changes the config schema # would otherwise arrive unannounced. image: bluenviron/mediamtx:latest profiles: ["rtsp"] restart: unless-stopped ports: # The Connector reaches this from the LAN, so it is published on # the host rather than kept on the compose network. 8554 is the # standard RTSP port and hosts running another MediaMTX already # have it; set RTSP_PUBLIC_PORT to move both this and the URL # vFusion hands out. - "${RTSP_PUBLIC_PORT:-8554}:8554" volumes: # The directory, not the file. vFusion writes the config at boot, # and bind-mounting a path that does not exist yet makes Docker # helpfully create a directory with that name instead. - ./rtsp:/rtsp command: ["/rtsp/mediamtx.yml"] networks: [default] frontend: build: context: ./frontend restart: unless-stopped environment: VITE_API_BASE: ${VITE_API_BASE:-http://localhost:18080} # Optional: comma-separated hostnames Vite's dev server will # accept Host headers from. Leave blank (default) to allow any # host — the dashboard isn't supposed to face the public # internet anyway. Set explicitly only if you want Vite's # DNS-rebinding protection (e.g. ``dash.example.com,homelab.local``). VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-} ports: - "15173:5173" volumes: - ./frontend:/app - /app/node_modules # Force a full re-install on every startup. The bind mount above # echoes package-lock.json back to the host, and npm's broken # optional-deps handling (bug #4828) means that a single bad lock # state can persist across restarts forever — even after the # anonymous node_modules volume is wiped, because npm sees a lock # file and reports "up to date" without ever installing the # missing platform-specific rollup binary. # # Trade-off: ~30s startup instead of <1s. Worth it for the # reliability — vFusion is restarted infrequently in production. # --include=optional belt-and-suspenders the rollup native bin. # find -mindepth 1 clears the *contents* of node_modules, not the # directory itself — necessary because /app/node_modules is the # anonymous-volume mount point and ``rm -rf node_modules`` fails # with "Device or resource busy". command: > sh -c "find node_modules -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null; rm -f package-lock.json; npm install --no-audit --no-fund --include=optional && npm run dev -- --host 0.0.0.0" # End-to-end tests. Behind a profile, so `docker compose up` never # starts it -- it is a one-shot run against a stack that is already up: # # docker compose --profile test run --rm e2e # # network_mode: host rather than the compose network, because the app # in the browser talks to VITE_API_BASE, which is a hostname the # operator chose. Resolving it the same way the host does is the only # way that address means the same thing inside the container. e2e: profiles: ["test"] build: context: ./e2e network_mode: host environment: E2E_BASE_URL: ${E2E_BASE_URL:-http://localhost:15173} # The admin password. The tests sign in through the form like a # person does, because the session cookie is the thing being # exercised -- an injected token would skip the bug most likely to # break the app for everyone. E2E_PASSWORD: ${E2E_PASSWORD:-} volumes: # Mounted rather than baked, so editing a test does not mean # rebuilding an image with a browser in it. - ./e2e/tests:/e2e/tests - ./e2e/support:/e2e/support - ./e2e/playwright.config.ts:/e2e/playwright.config.ts - ./e2e/test-results:/e2e/test-results volumes: postgres_data: webhook_assets: vfusion_secrets: