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/publickeychmod 600 /etc/wireguard/privatekeycat /etc/wireguard/publickey

Exchange the three public keys, then write the config files.

lon-01 — /etc/wireguard/wg0.conf

[Interface]Address = 10.100.0.1/24ListenPort = 51820PrivateKey = <LON_PRIVATE_KEY>[Peer]# eu-01PublicKey = <EU_PUBLIC_KEY>AllowedIPs = 10.100.0.2/32Endpoint = <EU_VPS_PUBLIC_IP>:51820PersistentKeepalive = 25[Peer]# lab-01 — no Endpoint; lab connects to usPublicKey = <LAB_PUBLIC_KEY>AllowedIPs = 10.100.0.3/32PersistentKeepalive = 25

eu-01 — /etc/wireguard/wg0.conf

[Interface]Address = 10.100.0.2/24ListenPort = 51820PrivateKey = <EU_PRIVATE_KEY>[Peer]# lon-01PublicKey = <LON_PUBLIC_KEY>AllowedIPs = 10.100.0.1/32Endpoint = <LON_VPS_PUBLIC_IP>:51820PersistentKeepalive = 25[Peer]# lab-01 — no Endpoint; lab connects to usPublicKey = <LAB_PUBLIC_KEY>AllowedIPs = 10.100.0.3/32PersistentKeepalive = 25

lab-01 — /etc/wireguard/wg0.conf

[Interface]Address = 10.100.0.3/24PrivateKey = <LAB_PRIVATE_KEY># No ListenPort — we initiate all connections outbound[Peer]# lon-01PublicKey = <LON_PUBLIC_KEY>AllowedIPs = 10.100.0.1/32Endpoint = <LON_VPS_PUBLIC_IP>:51820PersistentKeepalive = 25[Peer]# eu-01PublicKey = <EU_PUBLIC_KEY>AllowedIPs = 10.100.0.2/32Endpoint = <EU_VPS_PUBLIC_IP>:51820PersistentKeepalive = 25

Enable and start on each node:

systemctl enable --now wg-quick@wg0

Verify the mesh before continuing — all three nodes must reach each other:

# from lon-01ping -c 3 10.100.0.2 # eu-01ping -c 3 10.100.0.3 # lab-01# check peer statuswg show

Firewall: open WireGuard on VPS nodes

# lon-01 and eu-01ufw allow 51820/udp

The 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 ServerAfter=network.target wg-quick@wg0.serviceWants=wg-quick@wg0.service[Service]ExecStart=/usr/local/bin/nats-server -c /etc/nats/nats.confRestart=on-failureUser=natsStateDirectory=nats[Install]WantedBy=multi-user.target
useradd --system --no-create-home --shell /usr/sbin/nologin natsmkdir -p /var/lib/nats/jetstreamchown -R nats:nats /var/lib/natssystemctl enable --now nats

Verify NATS cluster

Install the nats CLI (https://nats.io/download/), then:

nats --server nats://10.100.0.1:4222 server listnats --server nats://10.100.0.1:4222 server report jetstream

All 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 TUIDirectory 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.gitcd 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# Verifyls -lh gslbd gslbctl

Copy to all three nodes:

# lon-01scp gslbd gslbctl root@<LON_VPS_IP>:/usr/local/bin/# eu-01scp gslbd gslbctl root@<EU_VPS_IP>:/usr/local/bin/# lab-01scp gslbd gslbctl <USER>@<LAB_IP>:/tmp/ssh <USER>@<LAB_IP> "sudo mv /tmp/gslbd /tmp/gslbctl /usr/local/bin/"

On each node, verify:

gslbd --helpgslbctl --help

Step 2 — Build and copy the Web UI

On your local machine (requires Node.js and the project's webui dependencies):

cd nexus-gslb/webuinpm installnpm 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-01rsync -av --delete webui/server/app/ root@<LON_VPS_IP>:/opt/nexus-gslb/webui/# eu-01rsync -av --delete webui/server/app/ root@<EU_VPS_IP>:/opt/nexus-gslb/webui/# lab-01rsync -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.webuiPath in config.yaml tells each gslbd instance 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" gslbdsudo mkdir -p /opt/nexus-gslb/datasudo mkdir -p /opt/nexus-gslb/webuisudo mkdir -p /etc/gslbsudo mkdir -p /var/lib/gslbdsudo chown -R gslbd:gslbd /opt/nexus-gslbsudo chown -R gslbd:gslbd /var/lib/gslbdsudo chown -R gslbd:gslbd /etc/gslb

Step 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: 9090health: 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: 53

Copy each config to its node:

# lon-01scp /path/to/lon-config.yaml root@<LON_VPS_IP>:/opt/nexus-gslb/config.yaml# eu-01scp /path/to/eu-config.yaml root@<EU_VPS_IP>:/opt/nexus-gslb/config.yaml# lab-01scp /path/to/lab-config.yaml <USER>@<LAB_IP>:/opt/nexus-gslb/config.yaml

Set permissions:

sudo chown gslbd:gslbd /opt/nexus-gslb/config.yamlsudo chmod 640 /opt/nexus-gslb/config.yaml

Step 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>EOFsudo chown root:gslbd /etc/gslb/gslbd.envsudo chmod 640 /etc/gslb/gslbd.env

Alternatively, 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 DaemonDocumentation=https://github.com/nexus-gslb/nexusAfter=network-online.target wg-quick@wg0.serviceWants=wg-quick@wg0.serviceStartLimitIntervalSec=60StartLimitBurst=5[Service]Type=simpleUser=gslbdGroup=gslbdExecStart=/usr/local/bin/gslbd -config /opt/nexus-gslb/config.yamlExecReload=/bin/kill -HUP $MAINPIDRestart=on-failureRestartSec=5sEnvironmentFile=-/etc/gslb/gslbd.envRuntimeDirectory=gslbdStateDirectory=gslbdLogsDirectory=gslbdConfigurationDirectory=gslbReadWritePaths=/var/lib/gslbdReadWritePaths=/opt/nexus-gslb/dataNoNewPrivileges=yesPrivateTmp=yesPrivateDevices=yesProtectSystem=strictProtectHome=yesProtectKernelTunables=yesProtectKernelModules=yesProtectControlGroups=yesRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIXRestrictNamespaces=yesLockPersonality=yesMemoryDenyWriteExecute=yesSystemCallFilter=@system-serviceSystemCallErrorNumber=EPERM# Required to bind to port 53 (privileged port)AmbientCapabilities=CAP_NET_BIND_SERVICECapabilityBoundingSet=CAP_NET_BIND_SERVICE[Install]WantedBy=multi-user.target

The AmbientCapabilities and CapabilityBoundingSet lines 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-reloadsudo systemctl enable --now gslbd

Check logs:

sudo journalctl -u gslbd -f

Phase 4 — Firewall RulesVPS nodes (lon-01, eu-01)

# WireGuardufw allow 51820/udp# DNS — public on port 53ufw allow 53/udpufw allow 53/tcp# API — restrict to your own IP or put behind a reverse proxyufw 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 needed

Port 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-01iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-port 5353iptables -t nat -A PREROUTING -p tcp --dport 53 -j REDIRECT --to-port 5353# Persist across reboots (Debian/Ubuntu)apt install iptables-persistentnetfilter-persistent save

The 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 — Verification1. Confirm all nodes are healthy

curl http://10.100.0.1:8080/api/v1/health # lon-01curl http://10.100.0.2:8080/api/v1/health # eu-01curl http://10.100.0.3:8080/api/v1/health # lab-01

All should return {"status":"ok"}.

2. Confirm NATS cluster state

nats --server nats://10.100.0.1:4222 server listnats --server nats://10.100.0.1:4222 server report jetstream

Three 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:8080KEY=<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-01wscat -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-01wscat -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 DROP

Within two health intervals both WebSocket connections should receive a health_update event showing 1.0.0.1 as unhealthy. This confirms:

  1. lon-01's health checker detected the failure

  2. dbSink published to NATS

  3. HealthBridge on both eu-01 and lab-01 received the event and pushed it to their WebSocket clients

Remove the block and verify recovery:

iptables -D OUTPUT -d 1.0.0.1 -j DROP

6. Test global-quorum policy

Update one node's config to use global-quorum:

state: healthPolicy: "global-quorum" quorumMinPercent: 67 # 2 of 3 nodes must agree

Restart 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 editinggslbctl -rqlite localhost:4001 -config /opt/nexus-gslb/config.yaml

Key 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.pem

Health 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 locallycd nexus-gslbgit pullCGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o gslbd ./cmd/gslbdCGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o gslbctl ./cmd/gslbctl# 2. Copy to all nodesscp 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 toorsync -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 nodesssh root@<LON_VPS_IP> "systemctl restart gslbd"ssh root@<EU_VPS_IP> "systemctl restart gslbd"

Uninstall

sudo systemctl disable --now gslbd || truesudo rm -f /etc/systemd/system/gslbd.servicesudo systemctl daemon-reloadsudo rm -f /usr/local/bin/gslbd /usr/local/bin/gslbctlsudo rm -rf /opt/nexus-gslb /etc/gslb /var/lib/gslbd


Was this article helpful?