↓Přeskočit na hlavní obsah

ClipCascade – Self-Hosted Clipboard Sync for Linux, TigerVNC and Windows

·12 min

Sharing the clipboard between a Windows machine and a Linux desktop running in TigerVNC is unreliable — sometimes the paste works, sometimes nothing arrives at all. ClipCascade solves this by syncing the clipboard through your own server instead of the VNC connection. This guide covers the complete setup: the server in Docker, persistent sessions, an nginx reverse proxy with Let’s Encrypt, restricting access on MikroTik, and a Linux client that starts automatically in an LXQt session inside TigerVNC.

Why ClipCascade #

I work a lot in a Linux desktop (LXQt) running inside TigerVNC, and connect to it from Windows. In theory, the VNC client forwards the clipboard in both directions. In practice, it often doesn’t — formatted text gets lost, large content silently fails, and sometimes the clipboard just stops syncing until you reconnect. I described one workaround for formatted text in VNC Not Pasting Formatted Text into the Windows Clipboard , but it only fixes one symptom.

ClipCascade takes a different approach: every device runs a small client that watches the local clipboard and sends changes to a central server over a WebSocket. The server forwards them to all other devices logged in with the same account. The VNC connection is no longer involved at all.

What makes it a good fit for a homelab:

  • Self-hosted — a single Docker container, no third-party cloud.
  • End-to-end encryption — clients encrypt the content with a key derived from your username and password, so the server never sees what you copy.
  • Cross-platform — clients for Windows, macOS, Linux (GUI and CLI) and Android.
  • Text, images and files — not just plain text.

Architecture #

Windows client ─┐
Android client ─┼── HTTPS/WSS :5540 ──> MikroTik (dst-nat, allowed IPs only)
Linux client  ──┘                            │
                                             v
                              nginx :5540 (Let's Encrypt cert)
                                             │
                                             v
                         ClipCascade container 127.0.0.1:5530 -> :8080
                                             │
                                  H2 database (users + sessions)

The container itself only listens on localhost. Everything external goes through nginx, which terminates TLS. The router only forwards the port from a list of allowed source addresses.

Server in Docker #

Add the service to your compose.yml:

services:
  clipcascade:
    image: sathvikrao/clipcascade:latest
    container_name: clipcascade
    restart: unless-stopped
    volumes:
      - /home/<user>/docker/clipcascade:/database
    environment:
      - TZ=Europe/Prague
      - CC_MAX_MESSAGE_SIZE_IN_MiB=10
      - CC_P2P_ENABLED=false
      - CC_SIGNUP_ENABLED=false
      # behind a reverse proxy: trust X-Forwarded-Proto/Port
      - SERVER_FORWARD_HEADERS_STRATEGY=native
      # keep client logins across container restarts
      - CC_SESSION_PERSISTENCE_ENABLED=true
    ports:
      - "127.0.0.1:5530:8080"
mkdir -p ~/docker/clipcascade
docker compose up -d clipcascade

A few notes on the options:

  • 127.0.0.1:5530:8080 — publish the port on localhost only. If you publish it as just 5530:8080, Docker binds it on all interfaces, including a global IPv6 address if the host has one, and Docker port publishing bypasses host firewalls like UFW.
  • CC_P2P_ENABLED=false — all messages go through the server (P2S mode). This is the simplest and most reliable mode when clients sit behind NAT.
  • CC_SIGNUP_ENABLED=false — nobody can create an account themselves; users are created by the admin.
  • CC_MAX_MESSAGE_SIZE_IN_MiB — the default is 1 MiB, which is too small for screenshots.

Persistence #

The /database volume holds an H2 database with users. By default, login sessions are kept only in memory. Every container restart or image upgrade logs out all clients. They then keep trying to reconnect to the WebSocket, get a 302 redirect to /login, and don’t log in again on their own. On a desktop you see the client sitting in the tray doing nothing.

CC_SESSION_PERSISTENCE_ENABLED=true stores the sessions in the same database. Clients stay logged in across restarts. The session timeout defaults to about one year (CC_SESSION_TIMEOUT, in minutes). You can verify it easily:

# cookie of a logged-in client
curl -s -o /dev/null -w '%{http_code}\n' -b "JSESSIONID=<cookie>" http://127.0.0.1:5530/csrf-token
docker restart clipcascade
# should still be 200, not 302
curl -s -o /dev/null -w '%{http_code}\n' -b "JSESSIONID=<cookie>" http://127.0.0.1:5530/csrf-token

Change the Default Password #

The server starts with admin / admin123. Change it right away: log in to the web UI and choose Change Password. Generate a long random password (openssl rand -base64 24) and store it in your password or secrets manager. In the Admin Panel you can create additional users later. Keep in mind that devices only see each other’s clipboard when they are logged in with the same account.

Reverse Proxy with nginx #

ClipCascade relies on a WebSocket (/clipsocket), so the proxy has to pass the Upgrade headers and keep long-lived connections open:

server {
    listen 5540 ssl;
    listen [::]:5540 ssl;
    server_name clipcascade.example.com;

    ssl_certificate     /etc/letsencrypt/live/clipcascade.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/clipcascade.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://127.0.0.1:5530/;
        # $http_host includes the port – important on a non-standard port
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_read_timeout 3600s;
        client_max_body_size 20M;
    }
}

Two problems I ran into:

Redirect to the wrong port after login. The server is a Spring Boot application, and after a successful login it redirects to an absolute URL. With the usual proxy_set_header Host $host, the port is dropped, so the login on :5540 redirected to https://clipcascade.example.com/ (port 443). Without SERVER_FORWARD_HEADERS_STRATEGY=native, it was even http://. The fix is Host $http_host plus X-Forwarded-Port in nginx, together with the environment variable on the container so Spring trusts the forwarded headers.

The client’s login silently fails after an HTTP → HTTPS redirect. If you also have a plain HTTP vhost that redirects to HTTPS, use return 308 instead of return 301. The Linux client uses Python requests, which turns a POST into a GET when it follows a 301. The login form is never actually submitted, but the client sees 200 from the login page, reports “Login successful”, and then fails with Error fetching CSRF token: Expecting value: line 1 column 1 (char 0). A 308 preserves the method. Better still, configure clients with the https:// URL directly.

Let’s Encrypt via DNS-01 #

A non-standard port like 5540 rules out the HTTP-01 challenge, which needs port 80. Use DNS-01 instead. It doesn’t need any open port at all. With Cloudflare DNS:

sudo apt install certbot python3-certbot-dns-cloudflare
# /etc/letsencrypt/cloudflare/credentials.ini (chmod 600):
#   dns_cloudflare_api_token = <token with Zone:DNS:Edit for your zone>
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare/credentials.ini \
  --dns-cloudflare-propagation-seconds 30 \
  -d clipcascade.example.com

Renewal is handled by certbot.timer. nginx doesn’t pick up a renewed certificate on its own, so add a deploy hook:

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run

Watch out for IP-restricted API tokens and IPv6. If your Cloudflare token only allows your public IPv4 address, while the server also has a global IPv6 address, certbot will reach the API over IPv6 and get 9109 Cannot use the access token from location: <IPv6 address>. Either add your IPv6 prefix to the token’s allowed addresses, or make the system prefer IPv4 by adding this line to /etc/gai.conf:

precedence ::ffff:0:0/96  100

IPv6 still works as a fallback; IPv4 is just tried first. This silently breaks certificate renewals for every domain on the host, so it’s worth running certbot renew --dry-run to check the others too.

DNS Record: DNS-Only, No Proxy #

Create the record (e.g. a CNAME to your home dynamic hostname) as DNS-only (grey cloud in Cloudflare), not proxied. Cloudflare’s proxy only supports a handful of ports (443, 2053, 2083, 2087, 2096, 8443), so 5540 wouldn’t work through it anyway. The proxy would also terminate TLS at Cloudflare’s edge and see your traffic unencrypted. With DNS-only, clients connect directly to your router and TLS ends on your own nginx.

Why HTTPS Matters Here #

“The content is end-to-end encrypted anyway, so plain HTTP is fine” — no, it isn’t:

  • The login is password-equivalent. The client sends a SHA3 hash of the password, but the server accepts that hash as the password. Anyone who captures it can log in as you.
  • The session cookie is the account. Sessions now last about a year and survive restarts. A sniffed JSESSIONID gives an attacker a long-lived connection to your clipboard channel. They can’t decrypt it, but they can see when and how much you copy, and push messages to your devices if any client has encryption disabled.
  • No E2E without TLS on first contact. Over plain HTTP, a man-in-the-middle can serve a modified login page or capture credentials before any encryption key exists.
  • Clipboards contain secrets. Passwords, tokens, private notes. TLS is the second layer that protects you if a client is ever misconfigured with encryption off.

Let’s Encrypt is free and renews itself — there’s no reason to run this over plain HTTP on the internet.

Restricting Access on MikroTik (or Using a VPN) #

A clipboard sync server is not something the whole internet needs to reach. On a MikroTik router, forward the port only from addresses you trust:

/ip firewall address-list
add list=clipcascade-allowed address=203.0.113.25 comment="office"
add list=clipcascade-allowed address=198.51.100.0/24 comment="mobile operator range"

/ip firewall nat
add chain=dstnat in-interface-list=WAN protocol=tcp dst-port=5540 \
    src-address-list=clipcascade-allowed \
    action=dst-nat to-addresses=192.168.1.10 to-ports=5540 \
    comment="ClipCascade"

Connections from other addresses don’t match the NAT rule and get dropped by your default WAN input/forward rules. Port scanners won’t even see that something is running there.

If your clients don’t have stable public IPs (phones, laptops on the move), a VPN is the cleaner option. With WireGuard on the router or a mesh VPN like Tailscale, don’t forward the port at all. Let clients connect to the internal address through the tunnel. You still keep HTTPS, because the VPN protects the path but not a misconfigured client.

Linux Client #

The Linux client is a Python application. Download ClipCascade_Linux.tar.xz from the releases page and install the dependencies into a virtualenv:

sudo apt install python3-tk python3-venv xclip gir1.2-ayatanaappindicator3-0.1
mkdir -p ~/apps && cd ~/apps
tar xJf ~/Downloads/ClipCascade_Linux.tar.xz   # creates ~/apps/ClipCascade
cd ClipCascade
python3 -m venv --system-site-packages .venv   # system site-packages for GTK (gi)
.venv/bin/pip install -r requirements.txt python-xlib

You could now run .venv/bin/python main.py and fill in the login window. For a client that should always run, even after a reboot, a VNC server restart, or an expired session, I wanted no window that waits for input. The client stores its configuration, session cookie and encryption key in a DATA file in its directory. You can create that file with a small script that reuses the client’s own modules:

#!/home/<user>/apps/ClipCascade/.venv/bin/python
"""Non-interactive ClipCascade login: (re)creates the DATA file."""
import os, sys
APP = "/home/<user>/apps/ClipCascade"
sys.path.insert(0, APP)
os.chdir(APP)
from core.config import Config
from core.constants import WEBSOCKET_ENDPOINT
from utils.request_manager import RequestManager
from utils.cipher_manager import CipherManager

raw = os.environ["CLIPCASCADE_PASSWORD"]
cfg = Config(file_name=os.path.join(APP, "DATA"))
cfg.load()
cfg.data.update({
    "server_url": os.environ.get("CLIPCASCADE_URL", "https://clipcascade.example.com:5540"),
    "username": os.environ.get("CLIPCASCADE_USER", "admin"),
    "cipher_enabled": True,
})
cfg.data["password"] = CipherManager.string_to_sha3_512_lowercase_hex(raw)
rm = RequestManager(cfg)
ok, msg, cookie = rm.login()
if not ok:
    sys.exit(f"login failed: {msg}")
cfg.data["cookie"] = cookie
cfg.data["csrf_token"] = rm.get_csrf_token()
cfg.data["server_mode"] = rm.get_server_mode()
cfg.data["maxsize"] = rm.maxsize()
cfg.data["websocket_url"] = Config.convert_to_websocket_url(cfg.data["server_url"], WEBSOCKET_ENDPOINT)
cfg.data["hashed_password"] = CipherManager(cfg).hash_password(raw)  # E2E key
cfg.data["password"] = ""
cfg.save()
os.chmod(cfg.file_name, 0o600)
print("ok", cfg.data["websocket_url"])

Save it as ~/bin/clipcascade-login.py and make it executable. Note that DATA contains the encryption key, which is why it’s chmod 600. If the client runs on the same machine as the server, CLIPCASCADE_URL=http://127.0.0.1:5530 is fine — the traffic never leaves the host.

Starting It in the TigerVNC Session #

The client needs the X display of the VNC session (:1 in my case) and a system tray. A wrapper script waits for both, checks whether the stored session is still valid, and logs in again if it isn’t:

#!/bin/bash
# ~/bin/clipcascade-client-start.sh
APP=$HOME/apps/ClipCascade
export DISPLAY=${DISPLAY:-:1} XDG_SESSION_TYPE=x11

# wait for the X server and the LXQt panel (tray)
for i in $(seq 1 120); do
  [ -S /tmp/.X11-unix/X${DISPLAY#:} ] && pgrep -u "$USER" -x lxqt-panel >/dev/null && break
  sleep 2
done

URL=$(python3 -c "import json;print(json.load(open('$APP/DATA'))['server_url'])" 2>/dev/null)
COOKIE=$(python3 -c "import json;print(json.load(open('$APP/DATA'))['cookie']['JSESSIONID'])" 2>/dev/null)
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -b "JSESSIONID=$COOKIE" "$URL/csrf-token")
if [ "$code" != "200" ]; then
  # fetch the password from your secrets manager (pass, Vault, Infisical, ...)
  export CLIPCASCADE_PASSWORD=$(pass show clipcascade)
  CLIPCASCADE_URL="$URL" ~/bin/clipcascade-login.py || exit 1
  unset CLIPCASCADE_PASSWORD
fi

cd "$APP" && exec "$APP/.venv/bin/python" main.py

Run it as a user systemd service, so it starts at boot and gets restarted if it ever crashes:

# ~/.config/systemd/user/clipcascade-client.service
[Unit]
Description=ClipCascade client in the TigerVNC/LXQt session :1
After=network-online.target

[Service]
Type=simple
Environment=DISPLAY=:1
Environment=XDG_SESSION_TYPE=x11
Environment=XDG_CURRENT_DESKTOP=LXQt
ExecStart=%h/bin/clipcascade-client-start.sh
Restart=always
RestartSec=15

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now clipcascade-client.service
sudo loginctl enable-linger $USER   # user services run without an active login

Why systemd rather than ~/.config/autostart? LXQt autostart only runs when the desktop session starts. The systemd unit with Restart=always also covers a client crash, and with linger enabled it starts after a reboot without anyone logging in. It also works right away in an already running VNC session — no need to log out and back in.

A clipboard icon appears in the LXQt tray next to the clock. Logs are in ~/apps/ClipCascade/clipcascade_log.log — look for Websocket connected.

Adding It to the LXQt Menu #

The client allows only one running instance, so a menu entry that launches main.py again would do nothing. Instead, make it (re)start the service — handy if the tray icon disappears after a panel restart:

# ~/.local/share/applications/clipcascade.desktop
[Desktop Entry]
Type=Application
Name=ClipCascade
GenericName=Clipboard Sync
Comment=Start / restart the ClipCascade client
Exec=systemctl --user restart clipcascade-client.service
Icon=edit-paste
Terminal=false
Categories=Utility;
Keywords=clipboard;sync;
desktop-file-validate ~/.local/share/applications/clipcascade.desktop
update-desktop-database ~/.local/share/applications

The entry shows up under Accessories in the LXQt menu.

Windows and Android Clients #

On Windows, install ClipCascade.msi from the releases page and enter the server URL (https://clipcascade.example.com:5540), username and password. Leave encryption enabled on every device. The key is derived from the username and password, so all devices must use the same account with encryption on. A device with encryption off can’t read messages from the others and logs If cipher is enabled, please make sure it is enabled on all devices. The Android app works the same way.

With the client running on both sides, you can turn off clipboard sharing in the VNC viewer completely. Copy on Windows, paste in the VNC session, and the other way round — the clipboard no longer depends on the VNC connection.

Troubleshooting #

  • Client says “Login successful” but fails on the CSRF token — it’s following a 301 redirect from HTTP to HTTPS. Use the https:// URL, or switch the redirect to 308.
  • GET /clipsocket returns 302 in the nginx log — the client’s session is no longer valid (typically after a container restart without session persistence). Enable CC_SESSION_PERSISTENCE_ENABLED and restart the client.
  • Login works in the browser but the client loops back to the login page on a non-standard port — check the Location header after POST /login. If it’s missing the port or says http://, you need Host $http_host and SERVER_FORWARD_HEADERS_STRATEGY=native.
  • certbot fails with Cloudflare error 9109 — the token is IP-restricted and the request went out over IPv6. See the gai.conf note above.
  • Quick end-to-end check on Linux — DISPLAY=:1 xclip -o -selection clipboard shows what the VNC session’s clipboard currently holds after you copy something on another device.