Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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: