Pulsed is a resilient cluster htop daemon. Run it on a few machines, open any node in a browser, and watch the whole cluster from a server-rendered dashboard. If the node serving your browser gets busy, it can send the next refresh to a quieter peer.
The result is intentionally simple: every node can serve the UI, every node shares fresh load metrics with its peers, and the browser can be hot-potatoed around the cluster without a central coordinator.
Deeper docs live in docs/; this README stays focused on quick start and common
deployment paths.
- Cluster htop view: CPU, memory, load, freshness, and offline status for every known node.
- Hot-potato refresh: a busy node can bake a lower-load peer into the next browser refresh.
- Peer rebasing: node links point at that node's own HTTP address, so the browser moves to the selected peer.
- Zero-config LAN discovery: nodes discover peers with mDNS.
- Seed support: provide explicit peers with
PULSED_SEEDSwhen discovery is not enough. - Local-first resilience: each node keeps enough recent state to keep rendering during peer churn.
go install github.com/OffPeakEngineer/pulsed@latest
pulsedIf you prefer a shorter command name, you can alias it locally as pls:
alias pls=pulsed
plsOr build from a checkout:
go build ./
./pulsedOpen:
http://localhost:8080
Run the same binary on additional LAN machines and they should discover each other automatically. If the advertised browser URL needs to differ from the listen address, set PULSED_ADVERTISE_HTTP.
If you start pulsed again while an instance is already running on the machine, the second process does not open another writer on the same store or publish a duplicate node. When the existing PULSED_DB is locked, or the configured gossip port is already bound, it joins as a terminal-only mirror with a temporary database and renders the cluster view in your terminal.
export PULSED_HTTP=":9000" # HTTP listen address, default :8080
export PULSED_ADVERTISE_HTTP="http://10.0.1.25:9000" # browser-reachable URL for this node
export PULSED_GOSSIP=":7947" # peer sync listen address, default :7946
export PULSED_SEEDS="10.0.1.20:7946,10.0.1.21:7946" # explicit peer sync addresses
export PULSED_DB="./data" # local state directory
export PULSED_WEB="true" # set false for sync-only nodes
export PULSED_NODE_NAME="rack-a-01" # optional stable node identity override
export PULSED_NODE_TTL="15s" # how long this node's heartbeat stays online
./pulsedBy default, Pulsed uses the OS hostname as the node identity. Set
PULSED_NODE_NAME for cloned hosts, containers, or multiple test instances that
would otherwise publish the same hostname. The override must be non-empty and
must not contain whitespace.
Each node publishes its own heartbeat TTL with PULSED_NODE_TTL. Shorter values
make stale/offline indication react faster; longer values are better for slow or
lossy networks. The default is 15s, and values must be at least 2s.
Each heartbeat includes the binary version. During a rolling upgrade, live nodes
from another version can remain visible so operators can watch the cluster
converge. The dashboard and terminal view label those live mismatches as
version <peer> (local <this-node>) without treating them as failures.
Offline records from older versions are removed on startup or when a mismatched peer leaves. This keeps stale data from previous releases from lingering after the upgraded node can no longer confirm it.
| Environment | How nodes find each other |
|---|---|
| LAN / bare metal | mDNS service _pulsed._tcp |
| Static hosts | PULSED_SEEDS |
| Mixed setup | mDNS discoveries and explicit seeds are merged |
| Single node | Renders solo until peers appear |
Templates live in deploy/ for common ways to keep Pulsed running:
| Target | Template |
|---|---|
| Linux systemd | deploy/systemd/pulsed.service and deploy/systemd/pulsed.env |
| macOS launchd | deploy/launchd/com.offpeakengineer.pulsed.plist |
| Windows service | deploy/windows/install-service.ps1 using NSSM |
| Kubernetes | deploy/kubernetes/pulsed.yaml |
| Helm | helm/pulsed |
| Ansible local binary | deploy/ansible/install-pulsed.yml |
| Ansible release binary | deploy/ansible/install-release-pulsed.yml |
| Traefik to bare metal | deploy/traefik/bare-metal-node.yaml |
| Traefik single hostname | deploy/traefik/single-host-query.yaml |
Linux systemd quick install from a built binary:
go build -o pulsed ./
sudo sh deploy/systemd/install.sh
sudoedit /etc/pulsed/pulsed.env
sudo systemctl restart pulsedFor clusters, build or download the binary once, then distribute that binary with systemd or Ansible. Avoid go install on every host unless you intentionally manage Go toolchains there.
macOS launchd:
sudo install -m 0755 pulsed /usr/local/bin/pulsed
sudo mkdir -p /usr/local/var/pulsed /usr/local/var/log
sudo cp deploy/launchd/com.offpeakengineer.pulsed.plist /Library/LaunchDaemons/
sudo launchctl bootstrap system /Library/LaunchDaemons/com.offpeakengineer.pulsed.plistWindows service:
.\deploy\windows\install-service.ps1 -BinaryPath "C:\Program Files\pulsed\pulsed.exe" -AdvertiseHttp "http://10.0.1.25:8080"Kubernetes:
kubectl apply -f deploy/kubernetes/pulsed.yamlHot-potato refreshes and peer links require node identity to survive the browser round trip. If a single URL like https://pulsed.example.com is backed by a normal load balancer, the next request may land on any node, so the browser has not really rebased to the lower-load peer.
Use one browser-routable URL per node instead:
https://pulsed-node-a.example.com -> 10.0.1.25:8080
https://pulsed-node-b.example.com -> 10.0.1.26:8080
https://pulsed-node-c.example.com -> 10.0.1.27:8080
Then set each node's advertised URL to its proxied hostname:
PULSED_HTTP=:8080
PULSED_ADVERTISE_HTTP=https://pulsed-node-a.example.com
PULSED_GOSSIP=:7946
PULSED_SEEDS=10.0.1.26:7946,10.0.1.27:7946With Traefik in Kubernetes proxying bare-metal nodes, create one Service, Endpoints, and IngressRoute per node. Start from deploy/traefik/bare-metal-node.yaml.
If you prefer one hostname, route by query parameter instead:
https://pulsed.example.com/?pulsed_node=node-a -> 10.0.1.25:8080
https://pulsed.example.com/?pulsed_node=node-b -> 10.0.1.26:8080
Then advertise the routed URL from each node:
PULSED_ADVERTISE_HTTP=https://pulsed.example.com/?pulsed_node=node-aTraefik supports query-param matchers in router rules, so this keeps link clicks and hot-potato refreshes on a single DNS name while still selecting a specific backend. Start from deploy/traefik/single-host-query.yaml.
That example also includes a low-priority host-only fallback route. If Traefik sees no matching pulsed_node value, it sends the request to a shared pulsed-any service instead of pinning fallback traffic to one node. Keep the pulsed-any Endpoints list limited to nodes that should receive unrouted fallback traffic.
pulsed uses peer-to-peer state sharing and a small local store internally, but those are implementation details for the dashboard. It is not intended to be a general-purpose distributed database or key-value API.
- Go 1.25+ if building from source with
go installorgo build.
For fleet installs, prefer release binaries or a binary built once in CI over compiling on every host. This avoids distro Go version drift and keeps bare-metal installs simple.
CI runs on GitHub-hosted Linux, Windows, and macOS runners for both x64 and arm64 where standard hosted runners are available. Releases publish matching Linux, macOS, and Windows binaries from deploy/release/build-all.sh.