Hosting your own rendezvous
The rendezvous is an introducer: it helps a client and an agent find each other, then gets out of the way. When the network allows a direct peer-to-peer tunnel, it carries nothing at all; when it does not, it relays bytes it cannot read. One static Go binary, no database.
What it can and cannot see
Worth understanding before you run one, because it is what makes the design safe:
- It cannot read your files or terminal. The iPad and the agent run their own mutually-authenticated TLS 1.3 session inside the relay, with keys pinned at pairing. The relay sees ciphertext.
- It cannot grant anyone access to a host. Authorisation is the agent checking the device key it recorded when you paired. The introducer is never consulted about access.
- It stores nothing. All state is in memory with a TTL. Restarting it loses everything; the consequence is one reconnect.
- It does see IP addresses, connection timing and traffic volume. Agents register under a blinded handle that rotates hourly, so it does not learn a durable identity — but it is not a Tor relay, and nobody should claim otherwise.
Requirements
- A host with a public IP address — a 1–2 GB VM is plenty.
- A DNS
Arecord (andAAAAif you have IPv6) pointing at it. - 443/tcp and 443/udp reachable from the internet. Check your cloud provider's security group as well as the host firewall.
- Nothing else already bound to 443:
sudo ss -lntp | grep :443
Install on Ubuntu Server
1 · Create an unprivileged user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin spade-rv
2 · Install the binary
# unpack the tarball from the download page
tar xzf spade-rendezvous-linux-amd64.tar.gz
sudo install -o root -g root -m 0755 \
spade-rendezvous /usr/local/bin/spade-rendezvous
spade-rendezvous --version
3 · Create directories
sudo mkdir -p /var/lib/spade-rendezvous/acme /etc/spade-rendezvous
sudo chown -R spade-rv:spade-rv /var/lib/spade-rendezvous
sudo chmod 700 /var/lib/spade-rendezvous/acme
4 · Write the environment file
sudo tee /etc/spade-rendezvous/env >/dev/null <<'EOF'
SPADE_RV_DOMAIN=rendezvous.example.com
SPADE_RV_ACME_EMAIL=ops@example.com
SPADE_RV_LISTEN=:443
SPADE_RV_UDP_LISTEN=:443
SPADE_RV_METRICS_LISTEN=127.0.0.1:9090
SPADE_RV_SESSION_MAX_BYTES=536870912
SPADE_RV_SESSION_MAX_SECONDS=28800
SPADE_RV_AGENT_TTL=300s
SPADE_RV_MAX_AGENTS_PER_IP=20
SPADE_RV_RATE_LOOKUPS_PER_MIN=60
SPADE_RV_NOTICE_FILE=/etc/spade-rendezvous/notice.json
EOF
sudo chmod 640 /etc/spade-rendezvous/env
sudo chown root:spade-rv /etc/spade-rendezvous/env
The tarball install.sh also writes
/etc/spade-rendezvous/notice.json (silent until you enable it) — see
operator notice.
5 · Install the systemd unit
Save as /etc/systemd/system/spade-rendezvous.service. The hardening
directives are requirements, not decoration — this process is exposed to the whole
internet.
[Unit]
Description=SpadeIDE Rendezvous (introducer + blind relay)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=spade-rv
Group=spade-rv
EnvironmentFile=/etc/spade-rendezvous/env
ExecStart=/usr/local/bin/spade-rendezvous serve
Restart=on-failure
RestartSec=3
# Bind 443 without being root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Only the ACME cache is writable
StateDirectory=spade-rendezvous
ReadWritePaths=/var/lib/spade-rendezvous
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
LimitNOFILE is not optional. Every online agent holds one
open connection, so the default of 1024 caps you at roughly a thousand agents.
6 · Start it
sudo systemctl daemon-reload
sudo systemctl enable --now spade-rendezvous
sudo systemctl status spade-rendezvous
7 · Open the firewall
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status
8 · Verify
# Certificate issued, TLS 1.3 negotiated
openssl s_client -connect rendezvous.example.com:443 -tls1_3 </dev/null 2>/dev/null \
| grep -E "Protocol|Verify return code"
# Health endpoint
curl -fsS https://rendezvous.example.com/healthz && echo OK
# Metrics must NOT answer from outside — this command must FAIL
curl -m 5 http://rendezvous.example.com:9090/metrics && echo "LEAK — fix SPADE_RV_METRICS_LISTEN"
# Logs must contain no handles, agent ids or client addresses
sudo journalctl -u spade-rendezvous -n 100 --no-pager
First-start certificate issuance takes a few seconds. If it fails, the cause is DNS or a blocked port 443 nine times out of ten.
9 · Point your agent and iPad at it
# on the host
spadeide remote on --server wss://rendezvous.example.com
spadeide remote status
On the iPad: Settings → Rendezvous URL, enter the same
wss:// address. Leave it empty to go back to the hosted default.
Configuration reference
Everything is read from the environment. No secrets are written to disk.
| Variable | Default | Meaning |
|---|---|---|
SPADE_RV_DOMAIN | required | Public hostname, and the subject of the ACME certificate |
SPADE_RV_LISTEN | :443 |
TCP listener — control connections and the relay |
SPADE_RV_UDP_LISTEN | :443 |
UDP listener — reflexive-address echo for NAT traversal |
SPADE_RV_ACME_CACHE | /var/lib/spade-rendezvous/acme |
Where the TLS certificate is cached. Keep it 0700 |
SPADE_RV_ACME_EMAIL | empty | Optional contact address for Let's Encrypt |
SPADE_RV_METRICS_LISTEN | 127.0.0.1:9090 |
Aggregate counters. Must be loopback — the server refuses to start otherwise |
SPADE_RV_AGENT_TTL | 300s |
How long a registration survives without a heartbeat |
SPADE_RV_SESSION_MAX_BYTES | 536870912 (512 MiB) |
Per-relay-session byte cap. 0 means unlimited — not advisable on
a public host |
SPADE_RV_SESSION_MAX_SECONDS | 28800 (8 h) |
Per-relay-session lifetime cap |
SPADE_RV_MAX_AGENTS_PER_IP | 20 |
Registration cap per source address |
SPADE_RV_RATE_LOOKUPS_PER_MIN | 60 |
Client lookup rate per source address |
SPADE_RV_LOG_LEVEL | info |
Verbosity. Even at debug, no handles or addresses are logged |
SPADE_RV_NOTICE_FILE | /etc/spade-rendezvous/notice.json |
Operator notice file (always installed). Set "enabled": true
inside to broadcast; hot-reloaded. Also GET /v1/notice |
A malformed value is a startup error, not a silent fallback — a typo in a limit will stop the service rather than quietly running with a different number.
Operator notice
Broadcast a short message to iPads that use this rendezvous for remote access — security fixes, required updates, or anything you need everyone to see. The file is always on disk; whether it is shown is controlled inside the JSON, not by deleting the file.
Where it lives
install.sh / reinstall.sh create
/etc/spade-rendezvous/notice.json if it is missing (default
"enabled": false). An existing file is never overwritten on upgrade.
Path override: SPADE_RV_NOTICE_FILE in the env file.
How clients get it
The notice is piggybacked on the next remote lookup
(WSS /v1/client — when the iPad cannot reach the host on the LAN and
talks to rendezvous). It is cached on the tablet and shown as a dismissible banner
on the home screen. Editing the JSON is hot-reloaded (mtime) — no
systemctl restart.
Turn it on
sudo nano /etc/spade-rendezvous/notice.json
# set "enabled": true, fill title/body, save
curl -sS https://rendezvous.example.com/v1/notice
# 200 + JSON while enabled; 204 while disabled
File format
{
"enabled": true,
"id": "sec-2026-04",
"title": "Please update SpadeIDE",
"body": "A security fix is out. Update the iPad app and the host agent.",
"severity": "warning",
"belowAppVersion": "1.2.0",
"belowAgentVersion": "0.2.0",
"agentUrl": "https://spadeide.com/download",
"appUrl": "https://apps.apple.com/app/spadeide"
}
| Field | Meaning |
|---|---|
enabled |
false (install default) = silent; true = attach to lookups |
id |
Stable label for this announcement. The iPad dismisses once per
id — change it to show again after dismiss |
title / body |
Banner text. When enabled you need id and at least one of these |
severity |
info (default), warning, or critical — tint on the banner |
belowAppVersion |
Show only if the iPad app version is strictly below this. Empty string = ignore |
belowAgentVersion |
Show only if any paired host’s last-known agent version is below this. Empty = ignore |
agentUrl |
Optional — “Download agent” on the banner (host agent download page) |
appUrl |
Optional — “Get the app” on the banner (App Store / product page) |
If both version fields are empty, every client that looks up sees the notice.
If either is set, the banner appears when the app gate or the agent gate
matches (strictly below). Agent versions are remembered from the last successful
hello on each paired host.
Ports
| Port | Exposure | Purpose |
|---|---|---|
443/tcp | Public | Agent registration, client lookup, relay |
443/udp | Public | Reflexive echo for direct connections |
9090/tcp | Loopback only | Metrics. Scrape it over an SSH tunnel |
Port 80 is never needed: certificates are issued over TLS-ALPN on 443 itself, so there is no HTTP listener and no redirect to get wrong.
SPADE_RV_LISTEN if your situation calls for it.
Operating it
Upgrades
tar xzf spade-rendezvous-linux-amd64.tar.gz
cd spade-rendezvous-linux-amd64
sudo ./reinstall.sh
reinstall.sh stops the unit, replaces the binary, and starts it again.
/etc/spade-rendezvous/env, the ACME cache, and
notice.json stay put (a missing notice file is created with
enabled: false). Agents reconnect on their own within their retry window.
Relayed sessions see a brief reconnect. Session state does not need a
backup — that is the in-memory design. Keep copies of
env and notice.json if you care about operator config.
Sizing
The workload is idle connections, so it is memory-bound rather than CPU-bound: budget a few tens of kilobytes per online agent. A 1–2 GB VM holds thousands. Relay bandwidth, not CPU, is the thing that costs money — and only for sessions that could not go direct.
Scaling past one node
Out of scope by design. State is in memory, so an agent and its client must reach the same instance. When you outgrow one, the answer is session affinity by handle — consistent hashing at a TCP load balancer, or regional hostnames — not a shared database, which would break the "stores nothing" property.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Service restarts in a loop at boot | SPADE_RV_DOMAIN unset or invalid — check journalctl -u spade-rendezvous |
| TLS handshake fails, no certificate | DNS not pointing here yet, or 443/tcp blocked upstream — the cloud security group, not ufw |
| Agents register but clients get "not found" | Clock skew between host and tablet — handles rotate hourly. Check timedatectl |
| "too many open files" under load | LimitNOFILE not applied — systemctl show -p LimitNOFILE spade-rendezvous |
| Everything works but always relays, never direct | 443/udp is closed. TCP alone degrades every session to relay, silently |