VISP Relay

Self-hosting

Run VISP on your own app and relay hosts with the versioned deploy templates.

VISP is designed to be self-hosted. The hosted beta at visp-stream.com is the easy path for broadcasters; this page is for operators who want to run the relay and control plane themselves.

You need one app host and one or more Linux relay hosts:

HostRuns
Relay(s)MediaMTX, Caddy (RTT probe), snapshot hook, Direct FFmpeg forwarders; optionally srtla_rec, visp-bond, and the Cloud Studio compositor
AppPostgreSQL, API, Typography worker, portal, admin console, multi-chat, Typography, browser publisher, OBS Remote web, docs, Caddy

Templates live in the repository's deploy/ directory. Keep domains, IPs, and secrets out of Git. Put production env files under /etc/visp/ with mode 0600.

Prerequisites

  • An app host and at least one relay with public IPv4 (relays also need UDP open for SRT and WebRTC)
  • Tailscale on both hosts for admin and the MediaMTX Control API
  • Caddy on both hosts
  • Bun 1.3.14+, Node.js 20+, FFmpeg, and PostgreSQL 18 on the app host (matching the local Compose target)
  • ffmpeg, curl, jq, and DejaVu fonts on the relay host
  • Twitch and Kick developer applications plus a Google OAuth web client with YouTube Data API v3 enabled (the server requires all three credential pairs)
  • A private S3-compatible bucket for snapshots, BRB media, OBS backups, and Typography uploads
  • DNS for your app, admin, multi-chat, Typography, relay, browser-publisher, OBS Remote, and docs hostnames

Clone the repository as root into /opt/visp on the app host. The systemd units expect that path.

1. Prove the relay with static credentials

Install MediaMTX v1.19.3. For Linux amd64, the pinned archive SHA-256 is:

a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe  mediamtx_v1.19.3_linux_amd64.tar.gz

Download the archive and checksums.sha256 from the official v1.19.3 release, then:

grep 'mediamtx_v1.19.3_linux_amd64.tar.gz' checksums.sha256 | sha256sum --check
sudo install -m 0755 mediamtx /usr/local/bin/mediamtx

Start with deploy/relay/mediamtx.static-auth.yml, replace both bootstrap passwords, and verify one real SRT publisher and reader against the bootstrap path. Only after that works, install deploy/relay/mediamtx.yml and switch to HTTP authentication against the app.

2. Relay host

  1. Install Tailscale, Caddy, curl, jq, ffmpeg, and the pinned MediaMTX binary.

  2. Install the snapshot hook and production config:

    sudo install -D -m 0755 deploy/relay/visp-snapshot \
      /usr/local/libexec/visp-snapshot
    sudo install -D -m 0644 deploy/relay/mediamtx.yml \
      /etc/mediamtx/mediamtx.yml

    RTSP binds only to 127.0.0.1 for the snapshot hook and Direct forwarders. Do not expose TCP 8554.

  3. Create /etc/visp/relay.env:

    HOOK_SECRET=replace-with-a-random-secret
    STUDIO_MEDIA_PASSWORD=same-value-as-app-env
    APP_ORIGIN=https://app.example.com
    RELAY_NAME=relay-1
    MTX_AUTHHTTPADDRESS=https://app.example.com/api/mediamtx/auth
    MTX_APIADDRESS=100.64.0.10:9997
    MTX_WEBRTCADDITIONALHOSTS=relay.example.com
    MTX_WEBRTCALLOWORIGINS=https://app.example.com,https://stream.example.com

    Use the app's public HTTPS origin, the relay's Tailscale address for the Control API bind, and the relay's public hostname for WebRTC. MediaMTX maps MTX_* variables onto the matching YAML settings. HOOK_SECRET and STUDIO_MEDIA_PASSWORD must match the app host. RELAY_NAME must match the relay's name in VISP Admin. Direct encoder, BRB, and bonding knobs are listed in deploy/README.md.

  4. Install deploy/systemd/mediamtx.service. Use Caddy's packaged unit with deploy/relay/Caddyfile, install deploy/systemd/caddy-relay.conf as a caddy.service.d/visp.conf drop-in, and set RELAY_DOMAIN and APP_DOMAIN in /etc/visp/caddy.env. Optional: for bonded phone uplinks, also build and enable visp-bond (bonded SRT) and srtla_rec (SRTLA) with deploy/systemd/visp-bond.service and deploy/systemd/srtla-rec.service; deploy/README.md has the build steps.

  5. Open public UDP 8890 (SRT), TCP 1935 (RTMP), TCP 443 (RTT probe), and UDP/TCP 8189 (WebRTC; TCP is the ICE fallback). With bonding enabled, also open UDP 8891 (bonded SRT) and UDP 5000 (SRTLA). Allow SSH and MediaMTX TCP 9997 only on the Tailscale interface. Mirror the same rules in your cloud firewall.

  6. In Tailscale ACLs, allow only the app host to reach relay TCP 9997.

The Control API excludes only the api action from HTTP auth. It stays protected by the Tailscale bind, ACL, and host firewall. Metrics and pprof remain disabled.

3. App host

  1. Install PostgreSQL (or use a managed database), Bun, Node.js 20+, Tailscale, Caddy, FFmpeg, and DejaVu fonts. Clone the repository as root into /opt/visp. The API service runs under Node; Bun is still used for builds, migrations, and the portal.

  2. Fill /etc/visp/app.env from apps/server/.env.example:

    • Set Twitch, Kick, and Google credentials and the snapshot bucket settings.
    • Point MEDIAMTX_API_URL at the relay's Tailscale Control API (http://100.x.x.x:9997).
    • With relays in more than one region, optionally set DEFAULT_RELAY_REGION to the region new accounts should use when the client sent no relay measurement. Unset, it is the default relay's region. See deploy/REGIONS.md for adding a region.
    • Generate PUBLISH_URL_ENCRYPTION_KEY with openssl rand -base64 32 and back it up with the other secrets.
    • Set NATIVE_WEB_ORIGIN to the browser publisher's public origin (for example https://stream.example.com).
    • Set OBS_REMOTE_WEB_ORIGIN to the OBS Remote web app's public origin (for example https://remote.example.com).
    • Set HOOK_SECRET to the same value as on the relay.
    • Set BETTER_AUTH_URL and CORS_ORIGIN to the public app and portal origins (same host when Caddy terminates TLS for both).
    • Set ADMIN_ORIGIN to the admin console origin and ADMIN_USER_IDS to comma-separated Better Auth user IDs that need break-glass access.
    • Set MULTICHAT_ORIGIN and TYPOGRAPHY_ORIGIN to those apps' public origins.
    • Set STUDIO_MEDIA_PASSWORD (32+ characters, same as the relay), RESEND_API_KEY, AI_GATEWAY_API_KEY, and VISP_CHAT_BOT_USER_ID; the server refuses to start without them.
    • Optionally set ANTHROPIC_API_KEY for the AI chat moderator.
    • After the server is up, sign in as the VISP_CHAT_BOT_USER_ID account and press Set up this account as the shared VISP bot on the dashboard chat bot card. That one consent lets the bot read chat for multichat, post alerts and replies, and delete, time out, and clip in channels that /mod it. Repeat it whenever you switch to a different bot account.

    Then install dependencies and migrate:

    cd /opt/visp
    bun install --frozen-lockfile
    bun run db:migrate
  3. Fill /etc/visp/web.env from apps/web/.env.example (VITE_SERVER_URL). Create /etc/visp/native-web.env:

    EXPO_PUBLIC_SERVER_URL=https://app.example.com
    EXPO_PUBLIC_RELAY_WEBRTC_URL=https://relay.example.com

    Create /etc/visp/obs-remote-web.env:

    EXPO_PUBLIC_SERVER_URL=https://app.example.com

    Create /etc/visp/typography-web.env:

    VITE_API_URL=https://app.example.com
  4. Build and install services:

    bun run build
    sudo install -m 0644 deploy/systemd/visp-server.service \
      /etc/systemd/system/visp-server.service
    sudo install -m 0644 deploy/systemd/visp-web.service \
      /etc/systemd/system/visp-web.service
    sudo install -m 0644 deploy/systemd/visp-typography-worker.service \
      /etc/systemd/system/visp-typography-worker.service
    sudo systemctl daemon-reload
    sudo systemctl enable --now visp-server visp-web visp-typography-worker

    Use Caddy's packaged unit with deploy/app/Caddyfile and deploy/systemd/caddy-app.conf as the drop-in. In /etc/visp/caddy.env set APP_DOMAIN, ADMIN_DOMAIN, MULTICHAT_DOMAIN, NATIVE_WEB_DOMAIN, OBS_REMOTE_WEB_DOMAIN, TYPOGRAPHY_DOMAIN, DOCS_DOMAIN, and RELAY_PUBLIC_IPS. The latter is a space-separated list of every relay public IP. Caddy serves apps/admin/dist, apps/multichat/dist, apps/native/dist, apps/obs-remote/dist, apps/typography/dist, and apps/fumadocs/.output/public as static files; none needs its own systemd unit.

  5. Register provider callbacks:

    Twitch: https://APP_DOMAIN/api/auth/callback/twitch
    Kick OAuth: https://APP_DOMAIN/api/auth/oauth2/callback/kick
    Kick webhook: https://APP_DOMAIN/api/webhooks/kick
    Google: https://APP_DOMAIN/api/auth/callback/google

    The Google client type is Web application. Add https://APP_DOMAIN as an authorized JavaScript origin, enable YouTube Data API v3, and add https://www.googleapis.com/auth/youtube.force-ssl to the consent screen. A public external app needs Google verification for this scope. Store the credentials only as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in /etc/visp/app.env; every VISP client uses the same server callback.

    The Kick app needs the user:read scope. Chat delivery uses the server's app token and chat.message.sent webhook subscriptions.

    AI_GATEWAY_API_KEY powers Seppo and Typography caption cleanup. Optional hosted speech features use ELEVENLABS_API_KEY; ELEVENLABS_VOICE_ID additionally enables hosted chat text-to-speech. Leave both ElevenLabs values unset to keep captions, audio isolation, and speech on-device. Disclose and contract for these external processors before enabling them for users.

  6. Expose only public TCP 443. Allow SSH only over Tailscale. Create DNS for the app, admin, multi-chat, Typography, browser-publisher, OBS Remote, and docs hostnames before Caddy obtains certificates.

Do not put MediaMTX auth or hook routes behind a CDN or WAF. Caddy accepts them only from relay direct public IPs (RELAY_PUBLIC_IPS), and the hook endpoints also require the shared secret. The Kick webhook is a separate public route protected by Kick's RSA signature, timestamp window, and replay detection. /api/obs/* and /api/auth/* must stay publicly reachable over HTTPS so the OBS plugin can authenticate and operate from the broadcaster's computer.

4. Add another relay

Provision the same MediaMTX, Caddy, systemd, firewall, and Tailscale setup on the new host. Give it distinct RELAY_DOMAIN, MTX_APIADDRESS, and MTX_WEBRTCADDITIONALHOSTS values while keeping the shared HOOK_SECRET. Add its public IP to RELAY_PUBLIC_IPS, reload app Caddy, then register its public host, Tailscale Control API URL, /ping URL, region, and capacities in VISP Admin. Draining prevents new assignments without moving existing paths.

5. Snapshot storage

Use a private S3-compatible bucket (UpCloud Managed Object Storage works) with its public HTTPS endpoint. Grant the app credential GET, HEAD, PUT, and DELETE only on the prefixes VISP writes: snapshots/, brb/, obs-backups/, typography/, and (with Cloud Studio) studio-staging/ and studio-verified/, plus listing under brb/*/highlights/uploads/ and obs-backups/ so cleanup works. Keep versioning disabled. deploy/README.md lists the BRB upload lifecycle rule and the Typography CORS policy.

Apply a lifecycle rule so stopped paths disappear after one day:

{
  "Rules": [
    {
      "ID": "ExpireVispSnapshots",
      "Status": "Enabled",
      "Prefix": "snapshots/",
      "Expiration": { "Days": 1 }
    }
  ]
}

If versioning was previously enabled, suspend it and add "NoncurrentVersionExpiration": { "NoncurrentDays": 1 }. The relay receives only 60-second presigned PUT URLs; S3 access keys never leave the app host.

6. OBS plugin (optional)

Skip this if nobody controls OBS through VISP; Direct output does not need OBS.

Deploy the API and the /api/obs/* Caddy rule, and apply migrations, before pairing OBS. The device-authorization endpoints under /api/auth/* must also remain publicly reachable. The reverse proxy must preserve WebSocket upgrades for /api/obs/live; pairing and one-use ticket acquisition stay on HTTPS. Build the plugin on the OS that runs OBS, or download packages from a GitHub Release.

Local macOS example:

cd apps/obs-plugin
cmake --preset macos
cmake --build --preset macos
mkdir -p "$HOME/Library/Application Support/obs-studio/plugins"
cp -R build_macos/RelWithDebInfo/visp-obs.plugin \
  "$HOME/Library/Application Support/obs-studio/plugins/"

Windows uses the windows-x64 preset; Linux uses ubuntu-x86_64. See OBS remote control for install paths and pairing.

The normal path is Tools → VISP Remote Control → Sign in with browser. For manual recovery or older installations, start OBS once so it logs the config.ini path, close OBS, and write:

[visp]
control_url=https://APP_DOMAIN/api/obs/control
token=the-token-shown-by-visp

Restart OBS. The dashboard should show Connected within a few seconds. control_url remains the HTTPS API base for setup and legacy clients; current plugins derive /api/obs/live-ticket and the outbound WSS endpoint from it.

Treat config.ini as a machine credential. Creating an OBS publishing device from the plugin replaces the active profile's streaming service only after an explicit confirmation.

7. Rollout order

Bring the stack up in this order so each layer is proven before the next:

  1. Static MediaMTX publish/read proof
  2. Twitch (and Kick) login plus schema migration
  3. MediaMTX HTTP authentication
  4. Path and credential portal
  5. Hooks and ten-second reconciliation
  6. Snapshot uploads
  7. RTT guidance and OBS scene export
  8. Browser WebRTC publisher and OBS remote control

Maintenance

Restart the API or portal at any time — established streams keep running. Restart MediaMTX only in a maintenance window; it ends active streams. Path provisioning and credential rotation do not require a relay restart.

The accepted app-outage behavior: existing streams continue, new publish/read connections fail authentication.

For ongoing updates on a host that uses the release bootstrap, see Releases and updates. Manual updates are the same sequence the helper runs: install frozen dependencies, migrate, build, then restart visp-server, visp-web, and visp-typography-worker and reload Caddy.

Acceptance test

  1. Publish and read one feed over SRT, then repeat over RTMP.
  2. Confirm dashboard state converges within ten seconds of reality.
  3. Stop the app while streaming: the stream must survive; a new connection must fail.
  4. Rotate one publishing device and verify other device URLs still connect; rotate the read credential separately and verify only readers are affected.
  5. Import the generated scene collection and play every source in OBS.
  6. Start two live paths and confirm each keeps a snapshots/{pathId}.jpg object whose modification time advances about once a minute; stopping a path hides its dashboard tile.
  7. From a current Chrome/Edge or Safari over HTTPS: OAuth returns to the browser-publisher origin, Go Live marks the path live, and OBS can read that path over SRT. Block UDP 8189 at the client and confirm TCP ICE fallback.

Scope

Self-hosting uses one app host and one or more relay hosts. VISP does not bill users or host OBS. The relay transcodes only for Direct output; the relay-to-OBS path is never transcoded. Phone store builds and notarized OBS packages are optional extras from the project's release pipeline; you can still run the relay and portal without them.

On this page