Three-Node Cluster (Bare Metal)
Three-Node Cluster — Bare Metal Installation
This guide walks through deploying a Nexus GSLB cluster across three geographically distributed nodes using WireGuard for overlay networking and a three-peer NATS JetStream cluster for state synchronisation. It is written for the specific topology of two public VPS nodes and one home-lab node behind NAT, but the pattern applies to any mix of public and private nodes.
Unlike the Docker-based guide, this variant installs the
gslbd daemon and gslbctl TUI as native
binaries with systemd, runs on port 53 for DNS, and serves the Web UI
directly from the local filesystem.
Topology
| Node | Location | WireGuard IP | DNS public? |
|---|---|---|---|
lon-01 |
London VPS | 10.100.0.1 | Yes |
eu-01 |
EU VPS | 10.100.0.2 | Yes |
lab-01 |
Home lab VM | 10.100.0.3 | No (internal) |
Each node runs:
gslbd(daemon binary) with its own SQLite database- A NATS server (cluster peer)
gslbctl(TUI/CLI, run on-demand from the binary)
NATS traffic stays inside the WireGuard overlay. The home lab node connects outbound through WireGuard so NAT traversal is transparent.
Why three full NATS peers and not a leaf node? Three peers give JetStream a 2-of-3 quorum, which means the cluster survives a single node failure while still persisting health events and membership state. A leaf node only relays messages — it does not participate in consensus.
Phase 1 — WireGuard Mesh
Install WireGuard on all three nodes.
Generate a key pair on each node:
wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey
chmod 600 /etc/wireguard/privatekey
cat /etc/wireguard/publickeyExchange the three public keys, then write the config files.
lon-01 —
/etc/wireguard/wg0.conf
[Interface]
Address = 10.100.0.1/24
ListenPort = 51820
PrivateKey = <LON_PRIVATE_KEY>
[Peer]
# eu-01
PublicKey = <EU_PUBLIC_KEY>
AllowedIPs = 10.100.0.2/32
Endpoint = <EU_VPS_PUBLIC_IP>:51820
PersistentKeepalive = 25
[Peer]
# lab-01 — no Endpoint; lab connects to us
PublicKey = <LAB_PUBLIC_KEY>
AllowedIPs = 10.100.0.3/32
PersistentKeepalive = 25eu-01 —
/etc/wireguard/wg0.conf
[Interface]
Address = 10.100.0.2/24
ListenPort = 51820
PrivateKey = <EU_PRIVATE_KEY>
[Peer]
# lon-01
PublicKey = <LON_PUBLIC_KEY>
AllowedIPs = 10.100.0.1/32
Endpoint = <LON_VPS_PUBLIC_IP>:51820
PersistentKeepalive = 25
[Peer]
# lab-01 — no Endpoint; lab connects to us
PublicKey = <LAB_PUBLIC_KEY>
AllowedIPs = 10.100.0.3/32
PersistentKeepalive = 25lab-01 —
/etc/wireguard/wg0.conf
[Interface]
Address = 10.100.0.3/24
PrivateKey = <LAB_PRIVATE_KEY>
# No ListenPort — we initiate all connections outbound
[Peer]
# lon-01
PublicKey = <LON_PUBLIC_KEY>
AllowedIPs = 10.100.0.1/32
Endpoint = <LON_VPS_PUBLIC_IP>:51820
PersistentKeepalive = 25
[Peer]
# eu-01
PublicKey = <EU_PUBLIC_KEY>
AllowedIPs = 10.100.0.2/32
Endpoint = <EU_VPS_PUBLIC_IP>:51820
PersistentKeepalive = 25Enable and start on each node:
systemctl enable --now wg-quick@wg0Verify the mesh before continuing — all three nodes must reach each other:
# from lon-01
ping -c 3 10.100.0.2 # eu-01
ping -c 3 10.100.0.3 # lab-01
# check peer status
wg showFirewall: open WireGuard on VPS nodes
# lon-01 and eu-01
ufw allow 51820/udpThe home lab node opens no inbound ports — it initiates all WireGuard handshakes outbound.
Phase 2 — NATS Cluster
Download the nats-server binary on each node from https://nats.io/download/.
Install to /usr/local/bin/nats-server.
Each server listens on its WireGuard IP. The cluster routing port (6222) is never exposed publicly — it is only reachable over the overlay.
lon-01 —
/etc/nats/nats.conf
server_name: lon-01
listen: 10.100.0.1:4222
http: 10.100.0.1:8222
cluster {
name: nexus-gslb
listen: 10.100.0.1:6222
routes: [
nats-route://10.100.0.2:6222
nats-route://10.100.0.3:6222
]
}
jetstream {
store_dir: /var/lib/nats/jetstream
max_memory_store: 256MB
max_file_store: 1GB
}
eu-01 —
/etc/nats/nats.conf
server_name: eu-01
listen: 10.100.0.2:4222
http: 10.100.0.2:8222
cluster {
name: nexus-gslb
listen: 10.100.0.2:6222
routes: [
nats-route://10.100.0.1:6222
nats-route://10.100.0.3:6222
]
}
jetstream {
store_dir: /var/lib/nats/jetstream
max_memory_store: 256MB
max_file_store: 1GB
}
lab-01 —
/etc/nats/nats.conf
server_name: lab-01
listen: 10.100.0.3:4222
http: 10.100.0.3:8222
cluster {
name: nexus-gslb
listen: 10.100.0.3:6222
routes: [
nats-route://10.100.0.1:6222
nats-route://10.100.0.2:6222
]
}
jetstream {
store_dir: /var/lib/nats/jetstream
max_memory_store: 256MB
max_file_store: 1GB
}
systemd unit — all nodes
# /etc/systemd/system/nats.service
[Unit]
Description=NATS Server
After=network.target wg-quick@wg0.service
Wants=wg-quick@wg0.service
[Service]
ExecStart=/usr/local/bin/nats-server -c /etc/nats/nats.conf
Restart=on-failure
User=nats
StateDirectory=nats
[Install]
WantedBy=multi-user.targetuseradd --system --no-create-home --shell /usr/sbin/nologin nats
mkdir -p /var/lib/nats/jetstream
chown -R nats:nats /var/lib/nats
systemctl enable --now natsVerify NATS cluster
Install the nats CLI (https://nats.io/download/),
then:
nats --server nats://10.100.0.1:4222 server list
nats --server nats://10.100.0.1:4222 server report jetstreamAll three servers (lon-01, eu-01,
lab-01) should appear. JetStream should show a single meta
leader with two followers.
Phase 3 — gslbd Binary, Web UI, and TUI
Directory layout (per node)
/opt/nexus-gslb/
├── gslbd # daemon binary
├── gslbctl # TUI binary
├── config.yaml # per-node config
├── webui/ # Next.js build (Web UI)
│ └── server/app/ # built static files
└── data/ # SQLite DB (created on first run)
Step 1 — Build the binaries locally
Run these commands on your local workstation (not on the VPS nodes):
git clone https://your.git/nexus-gslb.git
cd nexus-gslb
# Build gslbd (daemon)
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o gslbd ./cmd/gslbd
# Build gslbctl (TUI/CLI)
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o gslbctl ./cmd/gslbctl
# Verify
ls -lh gslbd gslbctlCopy to all three nodes:
# lon-01
scp gslbd gslbctl root@<LON_VPS_IP>:/usr/local/bin/
# eu-01
scp gslbd gslbctl root@<EU_VPS_IP>:/usr/local/bin/
# lab-01
scp gslbd gslbctl <USER>@<LAB_IP>:/tmp/
ssh <USER>@<LAB_IP> "sudo mv /tmp/gslbd /tmp/gslbctl /usr/local/bin/"On each node, verify:
gslbd --help
gslbctl --helpStep 2 — Build and copy the Web UI
On your local machine (requires Node.js and the
project's webui dependencies):
cd nexus-gslb/webui
npm install
npm run build # produces output in webui/server/app/The build output is a static Next.js application that
gslbd serves directly from the configured
webuiPath.
Copy the built Web UI to all three nodes:
# lon-01
rsync -av --delete webui/server/app/ root@<LON_VPS_IP>:/opt/nexus-gslb/webui/
# eu-01
rsync -av --delete webui/server/app/ root@<EU_VPS_IP>:/opt/nexus-gslb/webui/
# lab-01
rsync -av --delete webui/server/app/ <USER>@<LAB_IP>:/opt/nexus-gslb/webui/Each node serves its own local copy of the Web UI. There is no shared/centralized Web UI server. The
api.webuiPathin config.yaml tells eachgslbdinstance where to find its local copy.
Step 3 — Create system user and directories (all nodes)
sudo useradd --system --no-create-home --shell /sbin/nologin --comment "Nexus GSLB Daemon" gslbd
sudo mkdir -p /opt/nexus-gslb/data
sudo mkdir -p /opt/nexus-gslb/webui
sudo mkdir -p /etc/gslb
sudo mkdir -p /var/lib/gslbd
sudo chown -R gslbd:gslbd /opt/nexus-gslb
sudo chown -R gslbd:gslbd /var/lib/gslbd
sudo chown -R gslbd:gslbd /etc/gslbStep 4 — Write per-node config (all nodes)
lon-01 —
/opt/nexus-gslb/config.yaml:
dns:
listenAddr: "0.0.0.0"
port: 53
domain: "gslb.example.com"
cluster:
id: "nexus-test"
node:
id: "lon-01"
rqlite:
httpAddr: "localhost:4001"
api:
enabled: true
listenAddr: "0.0.0.0"
port: 8080
webuiPath: "/opt/nexus-gslb/webui/server/app"
metrics:
enablePrometheus: true
listenAddr: "0.0.0.0"
port: 9090
health:
enabled: true
type: "tcp"
port: 80
checkInterval: "10s"
timeout: "2s"
state:
nats:
servers:
- "nats://10.100.0.1:4222"
- "nats://10.100.0.2:4222"
- "nats://10.100.0.3:4222"
healthPolicy: "prefer-local"
heartbeatInterval: "10s"
heartbeatTTL: "30s"eu-01 —
/opt/nexus-gslb/config.yaml:
Same as lon-01 except:
node:
id: "eu-01"lab-01 —
/opt/nexus-gslb/config.yaml:
Same as lon-01 except:
node:
id: "lab-01"
dns:
listenAddr: "127.0.0.1" # not publicly exposed
port: 53Copy each config to its node:
# lon-01
scp /path/to/lon-config.yaml root@<LON_VPS_IP>:/opt/nexus-gslb/config.yaml
# eu-01
scp /path/to/eu-config.yaml root@<EU_VPS_IP>:/opt/nexus-gslb/config.yaml
# lab-01
scp /path/to/lab-config.yaml <USER>@<LAB_IP>:/opt/nexus-gslb/config.yamlSet permissions:
sudo chown gslbd:gslbd /opt/nexus-gslb/config.yaml
sudo chmod 640 /opt/nexus-gslb/config.yamlStep 5 — Optional license credentials (all nodes)
Create /etc/gslb/gslbd.env to store license credentials
without editing config.yaml. See Licensing for
token format details. Licenses are issued via the Nexus website.
sudo tee /etc/gslb/gslbd.env > /dev/null <<'EOF'
# GSLB_LICENSE_PUBLIC_KEY=<ed25519-public-key-from-nexus-website>
# GSLB_LICENSE_KEY=<token-from-nexus-website>
EOF
sudo chown root:gslbd /etc/gslb/gslbd.env
sudo chmod 640 /etc/gslb/gslbd.envAlternatively, apply a license after the cluster is running via the
WebUI (Admin → License) or CLI
(gslbctl license apply --server ...). Tokens applied this
way are persisted in the database and survive restarts without requiring
changes to the env file.
For a three-node cluster, use a floating license (no install ID binding). One token covers all nodes.
Step 6 — Write the systemd unit (all nodes)
Copy this to /etc/systemd/system/gslbd.service on each
node:
[Unit]
Description=Nexus GSLB Daemon
Documentation=https://github.com/nexus-gslb/nexus
After=network-online.target wg-quick@wg0.service
Wants=wg-quick@wg0.service
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=gslbd
Group=gslbd
ExecStart=/usr/local/bin/gslbd -config /opt/nexus-gslb/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
EnvironmentFile=-/etc/gslb/gslbd.env
RuntimeDirectory=gslbd
StateDirectory=gslbd
LogsDirectory=gslbd
ConfigurationDirectory=gslb
ReadWritePaths=/var/lib/gslbd
ReadWritePaths=/opt/nexus-gslb/data
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Required to bind to port 53 (privileged port)
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.targetThe
AmbientCapabilitiesandCapabilityBoundingSetlines are already uncommented in this file because port 53 is the target. If you later change to port 5353 you can comment these two lines out.
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now gslbdCheck logs:
sudo journalctl -u gslbd -fPhase 4 — Firewall Rules
VPS nodes (lon-01, eu-01)
# WireGuard
ufw allow 51820/udp
# DNS — public on port 53
ufw allow 53/udp
ufw allow 53/tcp
# API — restrict to your own IP or put behind a reverse proxy
ufw allow from <YOUR_IP> to any port 8080
# Metrics — WireGuard overlay only (Prometheus scraper should be within the mesh)
ufw allow from 10.100.0.0/24 to any port 9090
# NATS — WireGuard overlay only, no public rule neededPort 53 iptables redirect (VPS nodes only)
The daemon binds to port 53 but some VPS providers block privileged ports. Add a redirect so external port 53 traffic reaches the WireGuard overlay:
# lon-01 and eu-01
iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-port 5353
iptables -t nat -A PREROUTING -p tcp --dport 53 -j REDIRECT --to-port 5353
# Persist across reboots (Debian/Ubuntu)
apt install iptables-persistent
netfilter-persistent saveThe lab-01 node does not need this redirect since it does not expose port 53 publicly.
Alternatively, run the DNS service on port 5353 and use the redirect to translate port 53 → 5353 for all incoming queries.
Phase 5 — Verification
1. Confirm all nodes are healthy
curl http://10.100.0.1:8080/api/v1/health # lon-01
curl http://10.100.0.2:8080/api/v1/health # eu-01
curl http://10.100.0.3:8080/api/v1/health # lab-01All should return {"status":"ok"}.
2. Confirm NATS cluster state
nats --server nats://10.100.0.1:4222 server list
nats --server nats://10.100.0.1:4222 server report jetstreamThree peers, one meta leader, JetStream enabled on all.
3. Test the Web UI
Open http://<NODE_IP>:8080 in a browser. The Web
UI is served directly by gslbd from the
webuiPath directory. No separate web server is needed.
4. Create a test pool and members
API=http://10.100.0.1:8080
KEY=<your-api-key>
POOL=$(curl -sX POST $API/api/v1/pools \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"name":"test-pool"}' | jq -r .id)
curl -sX POST $API/api/v1/pools/$POOL/members \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"name":"host-a","ipAddress":"1.1.1.1","port":80,"weight":1}'
curl -sX POST $API/api/v1/pools/$POOL/members \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"name":"host-b","ipAddress":"1.0.0.1","port":80,"weight":1}'
curl -sX PUT $API/api/v1/pools/$POOL/healthcheck \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"type":"tcp","port":80,"intervalMs":5000,"timeoutMs":2000}'5. Test health propagation across nodes
Open a WebSocket listener on two different nodes simultaneously:
# Terminal 1 — watch lon-01
wscat -c "ws://10.100.0.1:8080/api/v1/ws?token=$(
curl -s http://10.100.0.1:8080/api/v1/ws/token \
-H "Authorization: Bearer $KEY" | jq -r .token)"
# Terminal 2 — watch eu-01
wscat -c "ws://10.100.0.2:8080/api/v1/ws?token=$(
curl -s http://10.100.0.2:8080/api/v1/ws/token \
-H "Authorization: Bearer $KEY" | jq -r .token)"Block 1.0.0.1 on lon-01:
iptables -A OUTPUT -d 1.0.0.1 -j DROPWithin two health intervals both WebSocket connections should receive
a health_update event showing 1.0.0.1 as
unhealthy. This confirms:
lon-01's health checker detected the failuredbSinkpublished to NATSHealthBridgeon botheu-01andlab-01received the event and pushed it to their WebSocket clients
Remove the block and verify recovery:
iptables -D OUTPUT -d 1.0.0.1 -j DROP6. Test global-quorum
policy
Update one node's config to use global-quorum:
state:
healthPolicy: "global-quorum"
quorumMinPercent: 67 # 2 of 3 nodes must agreeRestart gslbd on that node:
systemctl restart gslbd.
With a block on lon-01 only, 1.0.0.1 is
unhealthy from lon-01's view but healthy from eu-01 and lab-01. With 67%
quorum (2 of 3), the member stays healthy. Block it on two nodes —
quorum fails and DNS responses on all nodes stop including that
member.
gslbctl TUI Reference
gslbctl is a terminal UI for managing pools, members,
services, health checks, and geo rules. It connects to the local
rqlited datastore over its HTTP API — run it on any node
that can reach rqlited.
# TUI — connects to the local rqlited (default localhost:4001)
gslbctl -rqlite localhost:4001
# TUI with cluster/NATS config for editing
gslbctl -rqlite localhost:4001 -config /opt/nexus-gslb/config.yamlKey bindings:
| Key | Action |
|---|---|
n |
New item |
e |
Edit selected |
d |
Delete selected |
enter |
Drill into pool (show members) |
H |
Health check screen |
G |
Geo rules screen |
M |
Members screen |
C |
Cluster/NATS config screen |
← / → |
Switch tabs (Pools / Services) |
r |
Trigger daemon restart (if pending) |
esc |
Back |
q |
Quit |
DNSSEC DS record export:
gslbctl dnssec ds --zone example.com. --ksk-file /etc/gslb/ksk.pemHealth Policy Reference
| Policy | Behaviour |
|---|---|
prefer-local |
Uses local checker result; global is informational only |
local-only |
Ignores NATS entirely; each node decides independently |
global-any-healthy |
Healthy if local or any active node reports healthy |
global-quorum |
Healthy if ≥ quorumMinPercent of active nodes report
healthy |
prefer-local is the right default for a split-horizon
setup where each node checks its nearest backends. Use
global-quorum when you want the cluster to agree before
withdrawing an endpoint from DNS.
What Is Not Covered Here
| Feature | Where to look |
|---|---|
| Config sync (pool/member replication across nodes) | docs/reference/StateSync.md → Configuration Sync
section |
| Geo-IP routing | docs/reference/Configuration.md →
algorithm: geo-ip |
| DNSSEC | docs/user/SecurityGuide.md |
| DNS delegation (pointing a real subdomain at both VPS IPs) | Your registrar + two NS/A records |
| Kubernetes deployment for the lab node | docs/user/Kubernetes.md |
| Prometheus + Grafana setup | docs/user/MetricsObservability.md |
| Upgrading the binary | See below |
Upgrading
When a new version is available:
# 1. Build the new binary locally
cd nexus-gslb
git pull
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o gslbd ./cmd/gslbd
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o gslbctl ./cmd/gslbctl
# 2. Copy to all nodes
scp gslbd gslbctl root@<LON_VPS_IP>:/usr/local/bin/
scp gslbd gslbctl root@<EU_VPS_IP>:/usr/local/bin/
# 3. If the Web UI changed, copy it too
rsync -av webui/server/app/ root@<LON_VPS_IP>:/opt/nexus-gslb/webui/
rsync -av webui/server/app/ root@<EU_VPS_IP>:/opt/nexus-gslb/webui/
# 4. Restart on all nodes
ssh root@<LON_VPS_IP> "systemctl restart gslbd"
ssh root@<EU_VPS_IP> "systemctl restart gslbd"Uninstall
sudo systemctl disable --now gslbd || true
sudo rm -f /etc/systemd/system/gslbd.service
sudo systemctl daemon-reload
sudo rm -f /usr/local/bin/gslbd /usr/local/bin/gslbctl
sudo rm -rf /opt/nexus-gslb /etc/gslb /var/lib/gslbd