docker-compose.yml
Rama main · réplica de lectura del repositorio original.
# 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/<slug>
# 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: