UniClipboard

Deploy an always-online sync node

Run UniClipboard headless on a VPS as an always-online Space member with a phone gateway.

Edit on GitHub

Since 0.13, UniClipboard can run on a server (VPS or container) as an always-online member of your Space — a background service with no GUI and no system clipboard (a "headless" node). It syncs clipboard over iroh exactly like a desktop peer, and serves the mobile-sync gateway to your phone behind TLS.

Use it when you want clipboard sync to keep working even when all your desktops are asleep, and a stable HTTPS endpoint your phone can always reach.

This guide covers:

  • What a server node is and isn't
  • Deploying with Docker Compose (prebuilt image, or build from source)
  • Provisioning: joining your Space and registering your phone
  • TLS via the bundled Caddy, or behind a reverse proxy you already run
  • Verifying, operating, and backing it up

What it is and isn't

A server node is a normal iroh member that happens to be always online and has no display:

  • Is: an online peer that receives clipboard pushed by your desktops and persists it; a phone gateway that serves GET /SyncClipboard.json (pull) and PUT (push, fanned out back to your desktops over iroh).
  • Is not: a relay, and not a central store-and-forward mailbox. It only keeps what it received while it was online — it is not an offline inbox. When a peer is offline, undelivered data stays on the sender, consistent with UniClipboard's P2P model.

If what you actually want is a transit point for NAT hole-punching failures, that's a different thing — see Self-hosting an iroh relay.

The node never writes a system clipboard (there's no display on a VPS); inbound items are persisted and fanned out, which is all a background member needs to do.

How cross-network sync flows

The phone never joins the iroh trust mesh — it takes a simpler HTTP path, only ever talking to the server node's gateway. That always-online node does the iroh P2P sync with your desktops on its behalf. So even with the phone on cellular, your desktops at home, and the node in a datacenter, all three on separate networks still reach each other.

  • Phone pulls: GET /SyncClipboard.json over HTTPS to Caddy, proxied to the gateway, reading the latest clipboard the node has persisted.
  • Phone pushes: PUT comes in the same way; the gateway accepts it and fans it out back to your desktops over iroh.
  • Desktop-pushed items: sync to the node over iroh and get persisted, so the phone's next pull picks them up.

The node only persists what it received while online (see the previous section) — it's a sync hub, not an offline inbox.

Prerequisites

  • A VPS with a public IPv4 address, Docker + Docker Compose v2.
  • A domain name whose A/AAAA record points at the VPS — the phone reaches the node over HTTPS, and the bundled Caddy needs it to obtain a certificate. DNS must resolve before you start the stack.
  • The following inbound ports open in the VPS firewall / security group:
    • TCP/443 (and optionally UDP/443 for HTTP/3) — the phone's HTTPS gateway.
    • TCP/80 — the ACME HTTP-01 challenge Caddy uses to issue the cert.
    • A fixed UDP port for iroh direct connections (e.g. 42999/udp) — so desktops on other networks can dial the node directly.
  • An existing Space with another device online to pair with. The server node joins a Space; it does not create one. Run uniclip invite on a desktop already in the Space to get an invitation code.

Get the stack

Everything lives in the repository under deploy/vps/:

git clone https://github.com/UniClipboard/UniClipboard.git
cd UniClipboard/deploy/vps

There are two ways to get the image. Pick one:

Option A — pull the prebuilt image (recommended). Every release publishes a multi-arch (amd64 + arm64) image to the GitHub Container Registry. No compiler, and you only need this deploy/vps/ directory:

docker compose pull

Pin a release in .env with UC_IMAGE=ghcr.io/uniclipboard/uniclipboard-server:vX.Y.Z (defaults to :latest).

Option B — build from source. Needs ~4 GB of RAM:

docker compose build

A small VPS (≤ 2 GB RAM) cannot build from source — it will OOM. Use Option A, or build the image on a bigger machine of the same architecture and ship it with docker save … | ssh vps 'docker load'.

Configure

Copy the env template and fill in your values:

cp .env.example .env
# .env
UC_DOMAIN=clip.example.com      # A/AAAA record → this VPS; Caddy gets a cert for it
UC_PUBLIC_IP=203.0.113.7        # this VPS's public IPv4, advertised to desktop peers
UC_IROH_BIND_PORT=42999         # fixed UDP port for iroh direct connections

Under the hood these map to the daemon's networking knobs documented in CLI — environment variables: UC_IROH_BIND_PORT pins the iroh UDP port and UC_IROH_PUBLIC_ADDR (${UC_PUBLIC_IP}:${UC_IROH_BIND_PORT}) advertises this node's public socket so peers can dial it directly.

Provision (one-time, before the daemon)

join and the mobile write commands refuse to run while a daemon is up, so all provisioning happens in one-off containers first. They share the same state volume the long-running daemon will read.

1. Join your Space. On a desktop already in the Space, run uniclip invite to get a code, then:

docker compose run --rm app uniclip join

This prompts interactively for the invitation code and the Space passphrase (run it from an interactive terminal so the passphrase never lands in your shell history).

2. Enable the mobile-sync gateway for your domain. --url makes the phone's install URL/QR point at your HTTPS endpoint:

docker compose run --rm app \
  uniclip mobile network set \
  --url https://clip.example.com \
  --accept-network-risk

3. Register your phone. Mints credentials and renders the install QR:

docker compose run --rm app uniclip mobile add --label "My iPhone"

Copy the one-time password it prints — it is not shown again.

Start the stack

docker compose up -d

This brings up two containers:

  • app — the headless daemon (uniclip start --server). Joins the Space over iroh and serves the mobile-sync gateway. The iroh UDP port is published to the host; the plaintext gateway port is only on the internal Docker network.
  • caddy — terminates TLS on 443, obtains a certificate for UC_DOMAIN automatically, and reverse-proxies to the app's gateway.

The first start runs the ACME flow; tail docker compose logs -f caddy to confirm the certificate was issued.

Connect your phone

Scan the QR from step 3 (or open the printed https://<domain> install URL) in the UniClipboard / SyncClipboard mobile client. The phone now pulls the latest clipboard and pushes new content through Caddy; pushes fan out to your desktops over iroh. See Mobile sync for the client side.

Behind a reverse proxy you already run

If the host already serves 80/443 (nginx, Caddy, Traefik, 1Panel's OpenResty, …), don't run the bundled Caddy — it would conflict. Instead, expose the gateway on loopback and let your existing proxy front it.

Add an override next to the compose file:

# docker-compose.override.yml
services:
  app:
    ports:
      - '127.0.0.1:42720:42720'

Start only the daemon (not Caddy):

docker compose up -d app

Then add a virtual host on your existing proxy for clip.example.com that terminates TLS and reverse-proxies to http://127.0.0.1:42720, forwarding the Authorization header. The gateway is plain HTTP + Basic Auth, with no websockets, so a default reverse-proxy block is enough.

Never publish the plaintext gateway port (42720) to a public interface — it's HTTP + Basic Auth, designed to sit behind TLS. Keep it on loopback or the internal Docker network and only expose 443 through your proxy.

Forwarding requirements for /healthz

Mobile clients that have gone offline poll GET /healthz to discover when the service is reachable again. It must reverse-proxy to the same upstream as /SyncClipboard.json — the probe exists to prove that path is live, so routing it elsewhere makes the phone believe the service recovered right before the real sync fails.

The default block that forwards everything to http://127.0.0.1:42720 already satisfies this. Only two situations need attention:

  • You enabled response caching on the proxy: exclude /healthz. The server sends Cache-Control: no-store, so a proxy that honours upstream cache headers skips it automatically. But a proxy_cache configuration that ignores those headers will cache a 204 and keep reporting a dead daemon as alive — exactly the failure this endpoint exists to detect.
  • You want rate limiting: /healthz is public (no Basic Auth, see below), and a generic edge rate limit is fine. Its handler is constant cost, so this is optional hardening rather than a requirement.

/healthz needs no authentication, by design. It returns an empty 204 with no version, device, username, or clipboard information — it reveals only that a listener answers at this address, which a TCP connect already reveals. The real abuse surface would be the opposite: making every probe run a full Argon2id verification (what /SyncClipboard.json costs per request — roughly 64MB of memory and tens of milliseconds of CPU).

Don't rewrite or proxy the daemon control API's GET /health to /healthz. They are different endpoints on different listeners: control /health is on the loopback API, /healthz is on the mobile LAN listener. Passing one off as the other makes the probe report success while the mobile path is actually unreachable.

State and backups

Everything needed to keep identity, Space membership, and mobile credentials lives under HOME=/data on the uniclip-state volume, so the node never re-pairs across restarts. Under /data/.local/share/app.uniclipboard.desktop/:

StatePath
iroh identity (node secret)iroh-identity/
file-based KEKkeyring/
keyslot + device idvault/keyslot.json, vault/device_id.txt
databaseuniclipboard.db
iroh blob cacheiroh-blobs/blobs.db
settings (mobile creds, LAN)settings.json

On a server with no desktop environment there's no system keyring, so the daemon falls back to file-based secure storage automatically — the iroh secret and KEK live on the volume. Back it up (and Caddy's volume, which holds the issued certificate):

docker run --rm -v uniclip-state:/data -v "$PWD":/backup alpine \
  tar czf /backup/uniclip-state.tgz -C /data .

Operations

docker compose logs -f app                       # follow daemon logs
docker compose restart app                       # restart the daemon (state kept)
docker compose down                              # stop the stack (volumes kept)
docker compose pull && docker compose up -d      # update (Option A: prebuilt image)
docker compose up -d --build                     # update (Option B: build from source)

Updates keep the state volume, so a new image just swaps the binary — no re-pairing. To change the advertised domain or add devices later, stop the daemon first (docker compose down), rerun the relevant mobile command, then docker compose up -d — the write commands still refuse to run while the daemon is up.

Verifying

docker compose ps                                # app should be "healthy"

# TLS + gateway reachable; 401 = listener up, auth required (expected without creds):
curl -i https://clip.example.com/SyncClipboard.json

# Mobile recovery probe; expect 204 with Cache-Control: no-store and no body.
# A 404 means the proxy isn't forwarding /healthz to the gateway, and phones
# will fall back to the slower compatibility probe:
curl -i https://clip.example.com/healthz

# The plaintext gateway port is NOT exposed on the host (should refuse):
curl -i http://clip.example.com:42720/SyncClipboard.json

For an end-to-end check: copy something on a paired desktop on another network and watch it land in docker compose logs app; then push from the phone and confirm it appears on your desktops.

Troubleshooting

SymptomWhere to look
setup not complete on up -dProvisioning (join) didn't persist. Confirm the docker compose run commands hit the same volume.
Caddy never gets a certificateDNS for UC_DOMAIN must resolve to this VPS and 80/443 must be open before up -d.
Phone gets the wrong URLRe-run mobile network set --url https://<domain> (daemon stopped), then up -d.
Desktop on another network won't syncOpen UC_IROH_BIND_PORT/udp in the firewall and check UC_PUBLIC_IP is the real public IP.
401 through your reverse proxyThat means the gateway is up and asking for Basic Auth — use the credentials from add.

On this page