HopToDesk

Self-hosting HopToDesk with a web client, because every alternative I tried wanted my wallet

October 17, 2026 · 9 min read

I wanted a way to reach my home machine from my work PC, which locks down what I can install and what I can reach on the network. The only realistic entry point was a browser-based web client, which meant I needed something I could self-host for free. RustDesk’s web client moved behind a paid plan a while back. The domestic alternatives were worse. RayLink and ToDesk both want a subscription for the web client, and the pricing is the kind of pricing where you stop reading after the second zero. The enterprise plans have everything, but you cannot buy a single seat.

So I went looking for something self-hostable that also has a real web client. HopToDesk fits. It is a RustDesk fork with a simpler server (HopSignal), a web client you can host yourself, and a normal desktop client you point at your own rendezvous. No keys, no account, no telemetry to a third party.

This post is what I ended up with after a weekend of fiddling.

What HopToDesk actually is

HopToDesk forked from RustDesk a few releases back. The desktop client still looks like a RustDesk build, the protocol is the same shape, and the wire format did not need much translation. The big difference for self-hosters is the server side. Instead of the hbbs/hbbr pair RustDesk ships, HopToDesk splits into two roles, plus an extra piece only the web client needs. The desktop side: a HopSignal binary for the rendezvous (where the two peers find each other) and a stock coturn TURN server that picks up the relay when peer-to-peer fails. The web side adds a third piece — a custom relay bridge — because the web client does not speak TURN or ICE and coturn cannot be coaxed into emitting what it speaks instead. coturn is a separate third-party project, not part of HopToDesk. The relay bridge is also not part of HopToDesk; I had to build it, and it is the part the official self-host guide leaves out.

Installing HopSignal

The server is a single binary. On Linux:

curl -L -o /tmp/hopsignal https://github.com/hoptodesk/HopSignal/releases/latest/download/hopsignal-linux-x86_64
chmod +x /tmp/hopsignal
sudo mv /tmp/hopsignal /usr/local/bin/hopsignal

Verified flags as of 1.0.0:

--ADDR       bind address (default :21116)
--LOGFILE    write logs to this file
--FEDLIST    federation peer list
--FEDADDR    federation peer address
--FEDPWD     federation peer shared secret

For a single-machine setup you only need --ADDR. I run it under a systemd unit. The signal WebSocket endpoint listens on port 9000 internally. The relay piece is a separate part of the stack: a coturn install speaking the TURN protocol to the native clients, not anything inside HopSignal. HopSignal itself stays stateless: no database, no shared volume, nothing else.

Adding coturn (native clients only)

The TURN relay is coturn, a third-party open-source TURN server maintained independently of HopToDesk. It is in the official repositories of every major Linux distribution, so apt install coturn (or the distro equivalent) is enough. No container, no build step. Container images also exist if you want one.

Standard ports: 3478 for plain TURN over UDP and TCP, 5349 for TURN over TLS. Configure a realm, a long shared secret, and credentials. HopToDesk’s only requirement is that the realm, host, and credentials match the entries you put in api.json (next section).

One thing coturn does not help with: the web client. The web client bundle contains zero RTCPeerConnection calls. It never speaks TURN, never does ICE, never opens a UDP socket, because browsers sandbox all of that behind WebRTC and the bundle simply does not use it. Coturn is what makes the native-client stack function — without it, when peer-to-peer fails on a strict NAT, the desktop client has no fallback. For the web client, it is irrelevant.

The web client, and the piece HopToDesk does not give you

Here is the gap that cost me the most time to understand. HopToDesk’s official self-host guide covers the signal server and TURN, which is enough for the native desktop apps. It does not cover the web client at all. The hosted web client at web.hoptodesk.com hardcodes its own infrastructure: signal2.hoptodesk.com:443 for signal, and turn.hoptodesk.com:21118 for the relay path. Port 21118 is exactly the kind of non-standard port corporate firewalls block, which is why the hosted web client dies with “Could not establish a relay connection” on my work network. None of that is in any self-host guide because the self-hostable web client does not exist as such.

What the web client actually speaks, decoded from its JavaScript:

  1. Browser opens wss://<web-host>/relay/?v=2 for the relay channel.
  2. Something on the other end replies with JSON: {"relay_addr": "<host>:21119"}.
  3. Browser tells the signal server RelayConnection {protocol:"one-to-one", endpoint:<id>, addr:<relay_addr>}.
  4. The Mac’s native client receives RelayConnection over its signal WebSocket and dials plain TCP to <host>:21119.
  5. Once the Mac connects, all session bytes flow: browser WebSocket binary frames on one side, plain TCP on the other, with a length-prefix (BytesCodec) added and stripped in the middle.

So the relay side is a custom WebSocket-to-TCP splice speaking HopToDesk’s own v=2 framing. It is not TURN, not WebSocket-TURN, not anything coturn can be configured to emit. The official servers run one; nobody published one; the self-host guide is silent.

I had an AI write it. The result is github.com/david-adam/self-host-hoptodesk: a Python 3 bridge (relay_bridge.py) that speaks the v=2 protocol to the browser, the length-prefixed TCP protocol to the native client, and glues them together. It listens on 8443 for the browser side (Caddy fronts that as /relay*) and on 21119 for the Mac-side TCP dial-in, with one concurrent web session at a time, which is all I need. The repo also ships patch_web_client.py, which rewrites the hardcoded hostnames inside the official web client bundle (it handles both the pre-2025 readable form and the minified form they ship now) so the bundle points at your signal host and your /relay path, plus a one-shot setup_web_client.sh that deploys the whole thing to a VPS: bridge, patched bundle, Caddyfile, Let’s Encrypt cert, systemd units.

Caddy, roughly:

hop.{$DOMAIN} {
    root * /var/www/web
    file_server
    handle_path /relay* {
        reverse_proxy localhost:8443 {
            transport http { versions 1.1 }
        }
    }
}

The versions 1.1 matters: the bridge is HTTP/1.1-only, and Caddy’s default of trying HTTP/2 upstream breaks the WebSocket upgrade.

Cloudflare sits in front in DNS-only mode (orange cloud off) and does no TLS termination, so there is no Cloudflare intermediate in the chain. The certbot-issued Let’s Encrypt certificate is what every TLS client sees. The browser-based web client connects over 443 without trouble, because every modern browser handles the current LE chain fine.

For issuing and renewing the cert I went with whatever challenge fit my host. DNS-01 is the easy answer if Cloudflare holds your DNS and you are willing to give certbot an API token. HTTP-01 works too if Caddy listens on 80 long enough to answer the challenge when it fires.

Pointing the Mac client at it

The Mac client config lives at ~/Library/Preferences/com.hoptodesk.HopToDesk/config.toml. The TOML spec is older and most modern docs steer you toward api.json instead. A working api.json for this setup looks like this:

{
  "turnservers": [
    {
      "protocol": "turn",
      "host": "turn.your-domain.com",
      "port": "3478",
      "username": "you",
      "password": "long-shared-secret"
    },
    {
      "protocol": "turn-tls",
      "host": "turn.your-domain.com",
      "port": "5349",
      "username": "you",
      "password": "long-shared-secret"
    }
  ],
  "rendezvous":    { "host": "hop.your-domain.com", "port": "80" },
  "rendezvousssl": { "host": "hop.your-domain.com", "port": "443" },
  "winversion":  "1.0.0",
  "macversion":  "1.0.0",
  "linuxversion":"1.0.0"
}

Five pieces: the TURN fallback servers (plain and TLS, both useful since strict firewalls often let only one through), the plain rendezvous entry on port 80, the TLS rendezvous entry on port 443, and per-platform client version pins.

Both rendezvous and rendezvousssl point at the same hostname. The difference is the scheme: rendezvous is plain ws:// on port 80, rendezvousssl is wss:// on port 443. The desktop client concatenates the two into one host list and dials them in order, wss first and ws second, and uses whichever connects first. On Mac the wss handshake fails (next section), so the plain-ws entry at port 80 picks up the slack and the user never notices. I keep both in the file because the wss entry is the working path on every other platform; leaving it costs nothing on Mac.

If you only filled in rendezvousssl, the Mac client would sit at a handshake that never completes. The HopToDesk Mac client is built on native-tls 0.2.11, which on macOS routes through Apple Secure Transport. The current Let’s Encrypt chain that certbot issues by default trips the handshake on a few intermediates that Apple’s stack does not yet trust the way NSS or BoringSSL does. This is independent of Cloudflare: even with certbot directly on Caddy and DNS-only Cloudflare, the Mac client still fails on wss://.

The working path is to keep the Mac client on plain HTTP and let Caddy forward:

Mac client  →  hop.your-domain.com:80  →  Caddy:80  →  HopSignal:9000  (signal WebSocket)

The browser web client keeps using 443 because the browser handles TLS fine. Two paths, one Caddy, both work.

What still annoys me

Why bother

For me: free, no per-seat billing, no “upgrade to business” popups. The web client is what made this viable in the first place, since the work PC that drove the search is exactly the kind of locked-down machine that benefits most from a browser-based entry point. Self-hosting means everything runs on 443 through my own Caddy, which is the one port my work network does not block.

The RustDesk ecosystem is more polished and has more eyes on it, and if you only need native clients you should probably stay on hosted RustDesk or its self-hosted variant. HopToDesk is the right answer if the web client is the thing you actually need; just know the official self-host guide stops short of it, and budget an evening for the bridge piece.

Sources: HopSignal binary from hoptodesk.com/self-host.html, the relay bridge and web-client patcher from github.com/david-adam/self-host-hoptodesk. I will update this post if the bridge needs changes for a newer web client bundle, or if the Mac client’s TLS situation gets fixed upstream.

← Back to all posts