Running your own relay

The relay carries encrypted clips between your devices. It never sees anything but a room hash and ciphertext, and it stores nothing — but you do not have to take that on trust, because you can run it yourself. It is one Python file with no database.

The published image is waiting on the first release. The docker run line below is what the release pipeline pushes to GHCR; until it has run against a tag, use the from-source path in the next section, which works today.

Run it

docker run -p 8000:8000 ghcr.io/akshaynikhare/realtimeclipboard-relay

Or from a clone, with no container at all:

pip install -r backend/requirements.txt
python -m uvicorn main:app --app-dir backend --port 8000

That is the whole relay. No database, no volume, no migration step, and nothing to back up — every session lives in memory and is gone when the process exits, which is the design rather than a limitation.

Check it worked

curl http://localhost:8000/health

It answers with the instance's identifier. That field is not decoration: it is how a client can tell it has been load-balanced onto a different replica mid-session, which is the failure this design has to be careful about — see the replicas section below.

Point the app at it

  1. Open the app and go to Settings → Relay.
  2. Enter your relay's URL — ws://localhost:8000 for a local test, wss://clip.example.com for a real one.
  3. The status bar reads Connected once it answers. Do the same on a second device and sync a clip between them; nothing has touched the public relay.

The command-line client takes --relay or REALTIMECLIPBOARD_RELAY for the same purpose.

TLS and a real hostname

cd deploy
REALTIMECLIPBOARD_DOMAIN=clip.example.com docker compose up -d

The deploy directory has a compose file that puts Caddy in front of the relay and obtains a certificate automatically, and a Helm chart beside it for Kubernetes.

TLS is not optional in practice. Browsers only expose crypto.subtle in a secure context, so a page served over plain HTTP on a LAN address cannot derive a key at all — the app cannot work there regardless of what the relay does.

Before you scale it out

Pin replicas to one, or give it Redis. A room is held in the memory of the process serving it, so two replicas behind a round-robin load balancer put the two halves of a session on different machines and neither sees the other. It presents as clips silently not arriving, which looks like a client bug and is not one. The Helm chart refuses replicaCount > 1 without Redis for exactly this reason. The self-hosting notes cover the environment variables and the HA path in full.

When it does not work

Connected, but clips never arrive

Almost always more than one replica without shared state — see above. Compare the /health identifier from each device: if they differ, that is the whole answer.

The app cannot reach it at all

Two candidates. The page's own Content-Security-Policy lists the relay origins it may connect to, so a self-hosted copy of the static site needs connect-src updated in index.html, app.html and each page under src/pages/. And if you are using the hosted site with your own relay, the hosted policy will not permit it — self-host both halves.

It connects then drops, repeatedly

A reverse proxy that is not forwarding WebSocket upgrades. The client then falls back to server-sent events plus POST and keeps working, more slowly, which is why this shows up as sluggishness rather than failure. Forward Upgrade and Connection.

An idle session disappears

Rooms are reaped when empty. That is deliberate — a relay that keeps nothing has nothing to leak.

Shutting it down

docker compose down            # or: docker rm -f <container>

There is no data to delete. Everything the relay held was in memory, and it was ciphertext.

The full operational notes, including the environment-variable table and the high-availability path: docs/SELF-HOSTING.md. Every platform: the installation guide. Something wrong or out of date here? Open an issue.