Multi-network clusters via Tailscale
By default, Skulk discovers cluster peers using mDNS, which only works on the same local network segment. If you want cluster nodes in different locations (a Mac at home, a Linux box at a colo, a cloud VM), mDNS won't reach them.
Tailscale solves this by giving every node a stable 100.x.x.x address that works across any network. You configure Skulk to use those addresses as bootstrap peers, and the cluster forms over the Tailscale overlay.
Unlike the remote access scenario (where only the node you want to reach needs Tailscale), multi-network clustering requires every node to have Tailscale installed and running. This is because Skulk dials each peer directly by its 100.x.x.x address; there's no gateway or proxy. If a node doesn't have a Tailscale IP, the other nodes have no address to dial it on.
If all your nodes are on the same local network, you don't need Tailscale at all: mDNS handles discovery automatically. Tailscale is only needed when nodes are on physically separate networks.
Prerequisites
On every node that will join the cluster:
- Install Tailscale and log in: see Remote access via Tailscale for install instructions
- Confirm each machine has a
100.x.x.xaddress:tailscale ip -4 - All nodes must be on the same tailnet (same Tailscale account or Headscale server)
Setup
1. Enable Tailscale connectivity on every node
Add this to skulk.yaml on every node, the same three lines everywhere, no per-node IPs to manage:
connectivity:
tailscale:
enabled: true
With this set, each node queries its local tailnet and auto-discovers every
other node's 100.x address as a bootstrap peer: there is no list to
maintain. Nodes are dialed on Skulk's libp2p port (default 52416); peers that
aren't running Skulk simply fail Skulk's private-network handshake and are
ignored. When a node's Tailscale IP changes, discovery picks up the new one on
the next restart with no config edit.
The Tailscale overlay (a utun interface) is exempt from macOS Local Network
Privacy, so (unlike a plain LAN/Thunderbolt cluster) you do not need to
grant Local Network access for a Tailscale cluster to form. See
Thunderbolt clustering for the local-network case.
2. (Optional) Pin specific peers
Auto-discovery is enough for most setups. If you want to pin specific bootstrap addresses anyway (for example to dial a node on a non-default port, or to seed peers from outside the tailnet), list them explicitly; they are merged with the auto-discovered set:
connectivity:
tailscale:
enabled: true
bootstrap_peers:
- /ip4/100.101.102.103/tcp/52416 # pinned peer
Port 52416 is Skulk's default libp2p port. If you changed it with --libp2p-port, use that port instead.
3. Restart Skulk on every node
# Running manually:
uv run skulk
# Running as a service (macOS):
launchctl kickstart -k gui/$(id -u)/foundation.foxlight.skulk
# Running as a service (Linux):
systemctl --user restart skulk
Skulk reads the config, logs the Tailscale status, and dials the bootstrap peers over the overlay.
Verify the cluster formed
Check startup logs on each node, looking for the Tailscale line:
INFO Tailscale: running | IP 100.101.102.101 | my-node.tailnet-abc.ts.net
Check the cluster view: open the dashboard on any node (http://100.x.x.x:52415). Once libp2p has dialed the bootstrap peers and gossipsub has propagated state, all nodes should appear. Allow 10 to 15 seconds after the last node restarts.
Check via the API:
curl http://localhost:52415/v1/state | python3 -m json.tool
Look for all expected nodes in the nodes map.
How peer discovery works
Skulk's cluster uses gossipsub for state propagation. You only need to list some of the other nodes in bootstrap_peers, not all of them. Once Node A connects to Node B, and Node B already knows about Node C, Node A will learn about Node C indirectly within a few seconds. A single well-connected bootstrap node is enough to bring a new node into the cluster.
Tailscale IPs are stable; they don't change unless you reinstall Tailscale. You set bootstrap_peers once and leave it.
Troubleshooting
Nodes can't reach each other
ping 100.101.102.102
If ping fails between nodes, check:
- Both nodes are on the same tailnet (same Tailscale account or Headscale server)
tailscale statuson each node shows the other as a peer- Your tailnet ACL policy allows TCP on port 52416 between nodes (the default "allow all" policy works; a custom ACL might block it)
Only some nodes are visible in the dashboard
Gossipsub fans out from bootstrap peers. If Node A only lists Node B, and Node B hasn't connected to Node C yet, Node A won't see Node C immediately. Give it 10 to 15 seconds after all nodes have restarted. If it doesn't resolve, check that every node has at least one valid bootstrap peer in its config.
Wrong IP in the multiaddr
Run tailscale ip -4 on the relevant machine and update skulk.yaml. Tailscale IPs are stable but worth verifying if something looks off.
Tailscale connectivity configured but tailscaled is not running
tailscaled is not running on that node. Fix:
# macOS:
sudo tailscaled &
tailscale up
# Linux:
sudo systemctl start tailscaled
tailscale up
Using Headscale
Headscale is a self-hosted Tailscale control server. Skulk works with it identically: tailscale status --json returns the same structure regardless of whether the control plane is Tailscale's or Headscale's. No config changes needed.
tailscale up --login-server https://your-headscale-server.example.com
Join every node to the same Headscale server, then follow the setup steps above.