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
AppPostgreSQL, API, portal, admin console, browser publisher, 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+ and PostgreSQL 18 on the app host (matching the local Compose target)
  • ffmpeg and curl on the relay host
  • Twitch and Kick developer applications plus a Google OAuth web client with YouTube Data API v3 enabled
  • A private S3-compatible bucket for live-path JPEG snapshots
  • DNS for your app, admin, relay, browser-publisher, 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, 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. Do not expose TCP 8554.

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

    HOOK_SECRET=replace-with-a-random-secret
    APP_ORIGIN=https://app.example.com
    MTX_AUTHHTTPADDRESS=https://app.example.com/api/mediamtx/auth
    MTX_APIADDRESS=100.64.0.10:9997
    MTX_WEBRTCADDITIONALHOSTS=relay.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 must match the app host.

  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.

  5. Open public UDP 8890 (SRT), TCP 1935 (RTMP), TCP 443 (RTT probe), and UDP/TCP 8189 (WebRTC; TCP is the ICE fallback). 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, Bun, Node.js 20+, Tailscale, and Caddy. 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).
    • 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.

    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
  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 systemctl daemon-reload
    sudo systemctl enable --now visp-server visp-web

    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, NATIVE_WEB_DOMAIN, OBS_REMOTE_WEB_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/native/dist, apps/obs-remote/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.

    Set AI_GATEWAY_API_KEY if Seppo is enabled. 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, 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 snapshots/ prefix. Keep versioning disabled.

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

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 and visp-web 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 transcode, bill users, or host OBS. 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