Skip to content

RustDesk Server in Docker

Marc Pope edited this page Jul 26, 2026 · 2 revisions

RustDesk server in Docker

CortenDesk is a console for a RustDesk server you already run. It does not include one. If devices never appear, the cause is usually the server rather than the console — a key that does not match, UDP 21116 closed, or a relay address the clients cannot reach.

This page gets you from nothing to a working hbbs/hbbr pair, then points CortenDesk at it.

What the two processes do

hbbs — the ID/rendezvous server. Devices register their ID and public key with it, heartbeat to it so it knows where they are, and ask it to broker a connection to another ID. It keeps the peer table in a SQLite file next to its key material.

hbbr — the relay. When two peers cannot reach each other directly, both connect to hbbr and it forwards bytes between them. It knows nothing about IDs, users or address books; it pairs two sockets by a UUID and copies traffic.

Most sessions never touch hbbr. The ones behind symmetric NAT or a restrictive firewall always do, which is why a server that "mostly works" is usually one with a broken relay.

Images

Two official images are published, from the same source:

Image Contents
rustdesk/rustdesk-server Both binaries, no init system. Run two containers, one per role.
rustdesk/rustdesk-server-s6 Both processes supervised in a single container.

Both are mirrored to ghcr.io/rustdesk/rustdesk-server and ghcr.io/rustdesk/rustdesk-server-s6. Each release pushes latest, the exact version (1.1.16), and the major tag (1).

The rest of this page uses the two-container image. Splitting hbbs and hbbr makes the ports and the logs obvious, and you can move the relay to another host later without changing anything else.

Do not run a second API server. CortenDesk is the API server and console — the thing clients set as their API Server, and the thing that owns users, address books and device records. Some third-party server bundles ship their own API server and web UI in the same container. Running one alongside CortenDesk gives you two systems claiming the same clients, two user databases, and address books that silently diverge. Use the official images above, which are hbbs and hbbr and nothing else.

Compose

services:
  hbbs:
    image: rustdesk/rustdesk-server:latest
    command: hbbs -r rustdesk.example.com:21117
    ports:
      - "21115:21115"
      - "21116:21116"
      - "21116:21116/udp"
      - "21118:21118"
    volumes:
      - ./data:/root
    depends_on:
      - hbbr
    restart: unless-stopped

  hbbr:
    image: rustdesk/rustdesk-server:latest
    command: hbbr -k _
    ports:
      - "21117:21117"
      - "21119:21119"
    volumes:
      - ./data:/root
    restart: unless-stopped
mkdir -p data
docker compose up -d
docker compose logs -f

Three things in there matter.

-r rustdesk.example.com:21117 is the relay address hbbs hands out to clients. It has to be an address the clients can reach — a public hostname or IP. hbbr, localhost or a private address works on your bench and fails for every device outside it. This is the "connects, then sits at connecting" failure.

Both services mount the same ./data. /root is the working directory of both binaries. hbbs writes the keypair and the peer database there; hbbr's -k _ reads the same private key file. If they do not share the directory, hbbr generates a different keypair and rejects every relay request.

-k _ on hbbr turns on key enforcement for the relay. hbbs enforces a key by default; hbbr does not unless you ask. Without it, anyone who can reach 21117 can use your relay as an open proxy.

hbbs also writes db_v2.sqlite3 into that directory — the peer table. Back up ./data as a whole.

Ports

Port Protocol Process Carries Closed means
21115 TCP hbbs NAT-type test, and the "which of these peers are online" query Clients cannot determine their NAT type; peers in the client's own list all show offline
21116 UDP hbbs ID registration and heartbeat Devices never come online. See below.
21116 TCP hbbs Connection setup — punch-hole requests, local address exchange, relay negotiation Sessions never start
21117 TCP hbbr The relay itself Any session that cannot go peer-to-peer fails
21118 TCP hbbs The rendezvous protocol over WebSocket The in-browser web client cannot reach the ID server
21119 TCP hbbr The relay over WebSocket The in-browser web client cannot relay

21115 and 21116 are derived from the ID port, not configurable independently: hbbs listens on --port, --port - 1 and --port + 2. hbbr listens on --port and --port + 2. Change the base port and everything else moves with it.

UDP 21116 is the one people miss

Registration and heartbeat are UDP. Everything else on hbbs is TCP, so a firewall rule set written by pattern — or copied from a TCP-only example — ends up with 21115:21119/tcp and no UDP at all.

The symptom is specific and misleading: clients start normally, show an ID, report no error, and never come online. Not to each other, and not in CortenDesk. There is nothing in the client log to look at, because from the client's point of view it sent a packet and simply got no reply.

sudo ufw allow 21115:21119/tcp
sudo ufw allow 21116/udp

Check the cloud security group separately — that is a second firewall, and on most providers UDP is not in the default template. Publishing a container port does not open a security group.

Port scanning UDP is unreliable; an open UDP port and a filtered one usually look identical from outside. The definitive test is the server log — see Check it works.

The keypair

On first start hbbs generates an ed25519 keypair in its working directory and writes two files:

File Contents
id_ed25519 The private key, base64. Stays on the server.
id_ed25519.pub The public key, base64, a single line with no trailing newline.

With the compose above they appear in ./data on the host:

cat data/id_ed25519.pub

Read them on the host, not through docker exec. The two-container image is built FROM scratch — it contains the two binaries and no shell, so there is nothing to exec into.

The public key is the one you distribute. It goes in three places:

  • the Key field of every RustDesk client
  • CORTENDESK_PUBLIC_KEY in the console's environment
  • the console's Settings screen, which then shows it back to your users as a copy-paste value

The private key never leaves the server.

What enforcement actually does

hbbs enforces by default. --key defaults to -, which means "load or generate the keypair, and require clients to present the matching public key". A client with a wrong or empty key gets a licence mismatch on every connection attempt.

hbbr does not enforce unless started with -k. -k _ means "read the public key out of id_ed25519 in the working directory and require it". You can also pass a literal key: -k <base64 public key>.

Registration itself is not key-checked. That is why a key mismatch looks like "the device is online but nothing can connect to it" rather than "the device is missing".

Losing or regenerating it

Delete id_ed25519 and hbbs generates a fresh pair on the next start. Every client still configured with the old key is locked out of connecting — they keep registering and keep showing online, and every session attempt fails with a mismatch. There is no gradual migration and no way to accept both. You push the new key to every device, and you update CORTENDESK_PUBLIC_KEY.

Back up ./data. Restoring or moving a server is a matter of placing id_ed25519 and id_ed25519.pub into the directory before the first start; hbbs uses existing files and only generates when both are absent. A malformed private key is fatal — hbbs exits rather than quietly minting a new one.

Pointing CortenDesk at it

Three values, all from the server you just built:

Variable Value
CORTENDESK_ID_SERVER hbbs.example.com:21116
CORTENDESK_RELAY_SERVER hbbs.example.com:21117 — wherever hbbr is reachable
CORTENDESK_PUBLIC_KEY the contents of id_ed25519.pub, the bare base64 string

The public key is a single line. Do not add a newline, quotes or whitespace — it is compared byte for byte against what the client sends.

CortenDesk does not manage hbbs and does not read its database. Devices report to CortenDesk over its own HTTP API, which is why each client needs its API Server field pointed at the console as well as the three fields above. The console's Settings screen prints all four as copy-paste values.

Full install instructions: Install with Docker or Install on a VM.

The web client and the WebSocket ports

The in-browser client speaks the same protocol over WebSocket, on 21118 (hbbs) and 21119 (hbbr). The CortenDesk container already bridges /ws/id to 21118 and /ws/relay to 21119, so the browser only ever talks to the console's hostname.

Those two ports have to be reachable from the CortenDesk container. They do not have to be reachable from the internet, and preferably are not: the WebSocket listeners trust X-Real-IP and X-Forwarded-For as sent, so anything that can reach them directly can claim any source address it likes. Keep them behind the console's bridge or your own proxy.

If the console is served over HTTPS, browsers refuse a plain ws:// connection from the page, so CORTENDESK_WS_ID_URL and CORTENDESK_WS_RELAY_URL must be wss:// URLs on a hostname your proxy terminates. That is the whole of Reverse proxy and TLS, including why the in-browser client cannot work over plain HTTP at all.

Check it works

A healthy hbbs start:

Listening on tcp/udp :21116
Listening on tcp :21115, extra port for NAT test
Listening on websocket :21118
relay-servers=["rustdesk.example.com:21117"]
Key: 3gJK...=

Two lines to actually read:

  • relay-servers=[...] — entries that fail to resolve are dropped silently, so an empty list here means your -r argument is wrong and clients will be told there is no relay.
  • Key: — this is the public key, and it must equal cat data/id_ed25519.pub and CORTENDESK_PUBLIC_KEY.

On the very first start you also get Private/public key written to id_ed25519/id_ed25519.pub. On every start after that, Private key comes from id_ed25519. Seeing the first message twice means the volume is not persisting and you have just invalidated every client.

hbbr:

Listening on tcp :21117
Listening on websocket :21119

Then register a device. Set ID Server, Relay Server and Key in the client, restart it, and watch:

docker compose logs -f hbbs

A device registering logs a line beginning update_pk, with the ID, address, UUID and public key. If it never appears, UDP 21116 is not getting through — that log line is written from the UDP handler and from nowhere else.

It is written on the first registration and whenever a device's key, UUID or IP changes, not on every heartbeat. A device that is already registered and has not moved is silent, which is normal.

Troubleshooting

Devices never come online. UDP 21116. Check the host firewall and the cloud security group, then confirm with update_pk in the hbbs log.

Devices are online, connections fail immediately. Key mismatch. Compare the client's Key field, data/id_ed25519.pub, and the Key: line in the hbbs log. All three are the same string or nothing works.

Connections hang at connecting, then time out. Direct connection failed and the relay is unreachable. Check relay-servers=[...] in the hbbs log, that -r names an address the client can resolve, and that 21117 is open.

hbbr rejects everything after you add -k _. hbbr cannot see id_ed25519 — the volume is not shared with hbbs, or hbbr started first and gave up waiting. It then generates its own keypair, which matches nothing. Make sure both services mount the same directory, and delete the stray keypair if hbbr wrote one into its own.

Everything works except the in-browser client. 21118 and 21119, reachable from the CortenDesk container, plus wss:// URLs — see Reverse proxy and TLS.

Clients report an unknown NAT type, or every peer shows offline in the client's own list. TCP 21115.

Clone this wiki locally