A copy/paste walkthrough for turning one always-on machine into the secure 24/7 hub the rest of your fleet relies on. The hub runs Redis (the coordination substrate) and the darkmux daemon (the live viewer + flow endpoints). This page assumes a clean machine and a tailnet: every command after brew install should work as written. Setting up a worker peer? Do this page first, then the peer setup guide.
You have one machine that's going to stay on (a Mac mini, a Mac Studio, a Linux box in a closet) and you want other machines on your tailnet to coordinate through it. This page hardens THAT machine: the one everything else depends on.
If you only have one machine and you're staying single-machine, you don't need a hub at all. Getting started + the daemon + viewer is the whole story; flow records land on disk locally and the viewer reads them. Come back here when you add a second machine.
This guide sets up password-less Redis, and that is a deliberate, safe choice because of how the listener is bound. The logic:
127.0.0.1 and the hub's 100.x.y.z Tailscale address). It does NOT listen on your physical LAN, your WiFi, or any public interface. The only machines that can open a socket to it are devices already on your tailnet.requirepass path at the end), but the bind is the primary control, and on a single-operator tailnet it's sufficient.bind 0.0.0.0. That makes Redis listen on every interface, including whatever LAN or coffee-shop WiFi the machine is on. The old advice of "0.0.0.0 + a password" trades a structural guarantee for a credential you have to manage and protect. Binding the specific loopback + tailnet addresses makes the port structurally unreachable from anywhere but the tailnet: no password to leak, rotate, or fat-finger.
One caveat to know: Tailscale assigns each device a stable 100.x.y.z address that persists across reboots. It only changes if you remove and re-add the device in the admin console. If you ever do that, update the bind line (and peers' redis.host) and restart. In normal operation the address is stable.
If your hub already has its OS basics hardened (see harden the hub below if not) and Tailscale running, this is the whole setup. Run it on the hub:
# 1. Install darkmux.
brew tap kstrat2001/darkmux
brew install darkmux
# 2. Install Redis (the coordination substrate).
brew install redis
# 3. Bind Redis to loopback + your tailnet interface only, password-less.
# TS_IP is this hub's Tailscale address.
TS_IP=$(tailscale ip -4)
brew services start redis
redis-cli CONFIG SET bind "127.0.0.1 $TS_IP"
redis-cli CONFIG SET protected-mode no
redis-cli CONFIG SET appendonly yes # crash-durable (see Phase 1)
redis-cli CONFIG REWRITE # persist to redis.conf
brew services restart redis
# 4. Tell darkmux this machine is the hub, using the local Redis.
darkmux config set fleet.mode hub
darkmux config set redis.enabled true
darkmux config set redis.host 127.0.0.1
darkmux config set machine_id studio # operator-named; your call
# 5. Start the daemon (live viewer + flow endpoints) under brew services.
brew services start darkmux
# 6. Verify.
darkmux doctor
darkmux doctor ends with a one-line verdict banner (● ok / needs attention / broken). Green on daemon reachable, flow sink health (should read a Redis-backed sink), and machine_id means the hub is live. The peer machines now point their redis.host at this hub's $TS_IP; that's the peer setup guide.
config.redis (the enabled + host you just set) and only layers in a Keychain password if one exists. With none stored, it connects password-less, exactly matching the password-less bind. The brew services daemon follows the same resolution. Nothing to store, nothing to leak.
The rest of this page is the deeper operational reference: what each piece does, how to run the daemon under explicit launchd control, the audit substrate, log rotation, daily integrity checks, the cross-tailnet viewer, and the optional requirepass defense-in-depth layer. The quick path above is enough to be operational; read on to harden for weeks of unattended operation.
fleet.mode hub, connected to the local RedisThe hub is one machine, but the discipline matters because other machines depend on it. A flaky hub means a flaky fleet, no matter how stable the peers are.
The most common multi-machine failure mode is the operator-default-macOS one: the hub sleeps overnight, drops Tailscale, your other machines' dispatches start timing out, and you don't notice until you try to use it. Walk through this once on the hub:
# Stop sleeping. Mac defaults assume "lid closed = sleep"; wrong for a 24/7 hub.
sudo pmset -a sleep 0 disksleep 0 autorestart 1
pmset -g # verify the settings landed
# Tailscale: System Settings → Tailscale → "Run at login" + "Always on"
# Auto-login: System Settings → Users & Groups → Login Options → Automatic login
# Updates: set major-version auto-install OFF; schedule them manually.
autorestart 1 recovers from a brief power blip. Tailscale "Run at login" + an auto-login user means: after any reboot the machine boots straight into a session where Tailscale + your services start automatically, instead of sitting at the login screen with Tailscale down and no remote way in.
You also need Homebrew (to install darkmux and Redis) and a private mesh network reaching the hub. This guide assumes Tailscale; WireGuard / ZeroTier / plain LAN work the same. And run darkmux on a version that ships darkmux machine + darkmux config (check with darkmux config --help; if it errors, brew upgrade darkmux).
redis.conf path (/etc/redis/redis.conf). The decisions and verification steps are the same; only the file formats change.
The quick path already set the bind, protected-mode, and AOF. This section explains each line so you can verify and tune it.
TS_IP=$(tailscale ip -4) # this hub's 100.x.y.z address
redis-cli CONFIG SET bind "127.0.0.1 $TS_IP"
redis-cli CONFIG SET protected-mode no
redis-cli CONFIG REWRITE
brew services restart redis
Two interfaces, deliberately: 127.0.0.1 so the hub's own daemon connects over loopback, and the tailnet IP so peers can reach it across the tailnet. Nothing else. protected-mode no is required for the password-less posture: with protected-mode yes (the default) and no requirepass, Redis refuses every non-loopback connection, so peers couldn't connect. Turning it off is safe here precisely because the bind already restricts the listener to the tailnet interface.
Confirm:
redis-cli CONFIG GET bind # "127.0.0.1 100.x.y.z"
redis-cli CONFIG GET protected-mode # "no"
The default Redis install does periodic RDB snapshots; a crash between snapshots loses minutes of flow records. AOF (append-only file) with appendfsync everysec caps loss at ≤1 second on crash, at negligible cost for hub-class write volume.
Back up redis.conf first (always), then apply at runtime + persist:
cp /opt/homebrew/etc/redis.conf \
/opt/homebrew/etc/redis.conf.bak-pre-hub-$(date +%Y%m%d-%H%M%S)
redis-cli CONFIG SET appendonly yes
redis-cli CONFIG SET appendfsync everysec
redis-cli CONFIG SET maxmemory 4gb
redis-cli CONFIG SET maxmemory-policy noeviction
redis-cli CONFIG REWRITE
redis-cli BGREWRITEAOF
The maxmemory 4gb + noeviction pair is deliberate: the flow stream is already XADD MAXLEN-bounded by darkmux (default 10,000 entries via DARKMUX_REDIS_MAXLEN), so Redis never needs to evict. You do NOT want it to, either, because eviction policies that touch streams can drop records silently. noeviction means "if you'd hit the cap, reject the write and surface an error," the right posture for a record-keeping substrate.
Verify:
redis-cli CONFIG GET appendonly # "yes"
redis-cli CONFIG GET appendfsync # "everysec"
ls /opt/homebrew/var/db/redis/appendonlydir/ # appendonly.aof.* files appear
From a peer machine on the same tailnet, prove the hub's Redis is reachable, and that it's reachable only over the tailnet:
# On a peer:
nc -zvw1 <hub-tailnet-addr> 6379 # expect "succeeded"
redis-cli -h <hub-tailnet-addr> PING # expect PONG (no -a flag; password-less)
redis-cli -h <hub-tailnet-addr> XLEN darkmux:flow
With one operator and two devices, the default "allow everything within the tailnet" ACL is fine. The moment you add a third device you don't fully trust (a CI runner, a friend's laptop, a tagged ephemeral), tighten with a tag so only fleet devices can reach the hub's ports:
{
"tagOwners": {
"tag:darkmux-fleet": ["your-email@example.com"]
},
"acls": [{
"action": "accept",
"src": ["tag:darkmux-fleet"],
"dst": ["tag:darkmux-fleet:6379", "tag:darkmux-fleet:8765"]
}]
}
Tag each fleet device in the Tailscale admin console; untagged devices then can't reach the hub's Redis (6379) or darkmux daemon (8765) ports. This is also the point at which adding requirepass (see defense-in-depth below) earns its keep.
# Restore the backup; turn AOF off; restart.
cp /opt/homebrew/etc/redis.conf.bak-pre-hub-YYYYMMDD-HHMMSS /opt/homebrew/etc/redis.conf
redis-cli CONFIG SET appendonly no
brew services restart redis
Linux substitution: swap brew install redis → apt install redis-server or dnf install redis; /opt/homebrew/etc/redis.conf → /etc/redis/redis.conf; tailscale ip -4 is the same on Linux.
darkmux serve is the local HTTP daemon: flow records, mission/phase state, machine status, the bundled live viewer. For a 24/7 hub you want it under a service manager so it auto-starts at boot and respawns on crash.
If you installed via Homebrew, the formula already ships a launchd-managed service. Because your Redis is password-less, there's nothing to wire up: the daemon resolves its Redis connection from the config.redis you set in the quick path:
brew services start darkmux
sleep 3
curl -s http://127.0.0.1:8765/health # {"darkmux_version":"…","flow_schema_version":"…"}
darkmux doctor 2>&1 | grep -i "daemon reachable" # expect ✓ + the viewer URL
The daemon binds 127.0.0.1:8765 by default. That's correct: the live viewer loads on the hub itself, and for cross-tailnet browser access you proxy through Tailscale Serve (see the viewer section) rather than exposing the daemon's port directly. To set the operator-named machine id the service reports, export DARKMUX_MACHINE_ID in your shell rc before brew services start (the service picks it up at load), or rely on the config set machine_id you already ran.
darkmux-redis only if it exists. On the password-less posture there's no such item, so the wrapper does nothing and the daemon connects from config.redis password-less. If you later add requirepass (below), store the password in that Keychain item and the wrapper picks it up automatically, no plist edit.
If you'd rather control the service definition directly (or you built from source), here's the equivalent plist. File: ~/Library/LaunchAgents/com.darkmux.serve.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.darkmux.serve</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/darkmux</string>
<string>serve</string>
<string>--bind</string><string>127.0.0.1</string>
<string>--port</string><string>8765</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key><string>/Users/<your-user></string>
<key>PATH</key><string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>DARKMUX_MACHINE_ID</key><string><your-machine-id></string>
<key>DARKMUX_ORCHESTRATOR</key><string>claude-code</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>StandardOutPath</key><string>/Users/<your-user>/Library/Logs/darkmux/serve.out</string>
<key>StandardErrorPath</key><string>/Users/<your-user>/Library/Logs/darkmux/serve.err</string>
<key>WorkingDirectory</key><string>/Users/<your-user></string>
<key>ProcessType</key><string>Background</string>
</dict>
</plist>
No Redis URL in the plist: the daemon resolves Redis from config.redis (the ~/.darkmux/config.json the quick path wrote). The password-less posture means there's no secret to inject at process-start, so the /bin/zsh -lc Keychain-reading wrapper the old guide used is unnecessary. (If you add requirepass later, the simplest path is to switch to brew services, whose wrapper does the Keychain read for you.)
mkdir -p ~/Library/Logs/darkmux
plutil -lint ~/Library/LaunchAgents/com.darkmux.serve.plist
launchctl load -w ~/Library/LaunchAgents/com.darkmux.serve.plist
sleep 3
launchctl list | grep com.darkmux.serve # expect: <pid> 0 com.darkmux.serve
curl -s http://127.0.0.1:8765/health
Prove auto-recovery works by killing the daemon and confirming a new PID:
PID=$(launchctl list | awk '/com.darkmux.serve/ {print $1}')
kill -TERM "$PID"
sleep 14 # ThrottleInterval=10s + margin
launchctl list | grep com.darkmux.serve # expect: a NEW PID
darkmux machine add <your-machine-id> --address 127.0.0.1:8765
darkmux machine list
# brew path:
brew services stop darkmux
# explicit-plist path:
launchctl unload -w ~/Library/LaunchAgents/com.darkmux.serve.plist
rm ~/Library/LaunchAgents/com.darkmux.serve.plist
Linux substitution: a systemd user unit at ~/.config/systemd/user/darkmux-serve.service (Restart=always, RestartSec=10, env vars in [Service]), enabled with systemctl --user enable --now darkmux-serve; journalctl --user -u darkmux-serve -f for logs.
The casual per-day JSONL under ~/.darkmux/flows/ is enough for personal record-keeping. The audit sink adds BLAKE3 hash-chained records; a daily darkmux flow integrity-check (Phase 4) walks the chain and surfaces edits. It's a strong substrate to build a compliance posture on (ISO 27001, AI Act, HIPAA-as-covered-entity) and a useful tripwire even for personal use. The chain is un-anchored, so it detects edits (absent a full re-chain) rather than preventing them: tamper-detecting, not tamper-proof. For a stronger guarantee, pair it with OS append-only flags on the audit files or an off-box co-signature.
/darkmux-enable-audit Claude Code skill (shipped with darkmux). Invoke it in a Claude Code session on the hub; it walks you through the use-case framing, the dir choice, the env-var setup, the first-write verification, and the doctor check. This section captures only the hub-specific decisions.
~/.darkmux/audit/ by default: same filesystem as the casual sink, simplest backup posture. Put it on a different volume if your backup policy needs that.DARKMUX_AUDIT_DIR in the daemon's service environment (so the daemon's own writes are audited) AND in your interactive ~/.zshrc (so darkmux flow note, dispatch, etc. from the hub's shell are too). Missing either yields a partial chain.brew services restart darkmux or reload the plist); it reads the env at process-start.# In ~/.zshrc:
export DARKMUX_AUDIT_DIR="$HOME/.darkmux/audit"
source ~/.zshrc
darkmux flow note --text "audit substrate enable smoke"
darkmux flow integrity-check # expect: ✓ valid … (exit 0)
darkmux doctor 2>&1 | grep -iE "audit integrity|flow sink"
brew services restart darkmux # pick up the audit dir
Linux substitution: the audit substrate is POSIX-only by design (uses flock(2)). Same env var, same skill, same integrity check + exit codes; only the path conventions differ.
Two habits the hub needs that the daemon doesn't ship: log rotation so serve.err doesn't grow forever, and (if you enabled the audit substrate) a daily integrity check that fires a notification if the chain ever breaks.
macOS's native rotation system is newsyslog. Drop-in configs live under /etc/newsyslog.d/. File: /etc/newsyslog.d/darkmux.conf
# darkmux daemon logs: rotate weekly Saturday 00:00, keep 8, compress
# fields: logfilename owner:group mode count size when flags
/Users/<your-user>/Library/Logs/darkmux/serve.out <you>:staff 644 8 * $W6D0 ZN
/Users/<your-user>/Library/Logs/darkmux/serve.err <you>:staff 644 8 * $W6D0 ZN
/Users/<your-user>/Library/Logs/darkmux/integrity.log <you>:staff 644 8 * $W6D0 ZN
sudo install -m 644 -o root -g wheel /tmp/darkmux-newsyslog.conf /etc/newsyslog.d/darkmux.conf
sudo newsyslog -nv | grep -i darkmux # "will trim at Sat … 00:00:00" ×3
redis-server doesn't reopen its log fd on SIGHUP; rotating its file mid-process silently orphans the fd and you stop seeing Redis logs with no error. Hub-scale Redis log volume is tiny; a manual brew services restart redis a couple times a year is the practical workaround.
File: ~/Library/LaunchAgents/com.darkmux.flow-integrity-check.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.darkmux.flow-integrity-check</string>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>export DARKMUX_AUDIT_DIR="$HOME/.darkmux/audit"; /opt/homebrew/bin/darkmux flow integrity-check; ec=$?; if [ $ec -eq 2 ]; then /usr/bin/osascript -e 'display notification "darkmux audit chain BROKEN: see ~/Library/Logs/darkmux/integrity.log" with title "darkmux integrity-check"'; fi; exit $ec</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key><string>/Users/<your-user></string>
<key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>15</integer></dict>
<key>StandardOutPath</key><string>/Users/<your-user>/Library/Logs/darkmux/integrity.log</string>
<key>StandardErrorPath</key><string>/Users/<your-user>/Library/Logs/darkmux/integrity.log</string>
<key>RunAtLoad</key><false/>
</dict>
</plist>
darkmux flow integrity-check exits 2 only on chain break; that case fires a desktop notification you can't miss. Exit 0 (the happy path) appends a ✓ valid line silently. Load + smoke-test:
plutil -lint ~/Library/LaunchAgents/com.darkmux.flow-integrity-check.plist
launchctl load -w ~/Library/LaunchAgents/com.darkmux.flow-integrity-check.plist
launchctl start com.darkmux.flow-integrity-check
sleep 3
cat ~/Library/Logs/darkmux/integrity.log # expect: ✓ valid …
Linux substitution: /etc/logrotate.d/darkmux (weekly rotate 8 compress missingok notifempty); a systemd timer (OnCalendar=*-*-* 04:15:00) for the integrity check; notify-send for the notification.
The hub is operational. Bringing the next machine online (pointing its redis.host at this hub's tailnet address, declaring it fleet.mode peer, and verifying it reaches the hub) is its own walkthrough: the peer setup guide. It's a clean-machine, brew-only, copy/paste recipe, and it opens by linking back here in case you landed there without a hub yet.
The one fact peers need from this page: the hub's tailnet address (tailscale ip -4 on the hub). That's the value each peer sets as its redis.host. No password, because the bind is the perimeter.
The daemon binds 127.0.0.1:8765 and serves the live viewer there. To open it from another device's browser (your phone, a peer), proxy through Tailscale Serve. The daemon stays on loopback; Tailscale handles the cross-tailnet hop and TLS:
# On the hub. --bg persists across reboots via tailscaled.
tailscale serve --bg --https=443 http://127.0.0.1:8765
tailscale serve status # shows the assigned https://<hub>.<tailnet>.ts.net/ URL
Now https://<hub-magic-dns-name>.ts.net/ loads the live viewer on any device in your tailnet, with real HTTPS, and the daemon never leaves loopback.
--bind: binding the daemon to the tailnet IP directly would trip darkmux's remote-auth gate. A non-loopback bind requires a bearer token, and a browser page-navigation can't send an Authorization header, so the viewer would 401. Tailscale Serve keeps the daemon on 127.0.0.1, so the daemon sees a loopback connection and serves the viewer freely while Tailscale enforces the tailnet boundary. It's the same reason the bind-to-loopback + serve pattern is the recommended phone-dashboard setup.
HTTP (--http=80 instead of --https=443) also works and is fine on a tailnet (the WireGuard tunnel is already encrypted); HTTPS needs the cert toggle enabled once in the Tailscale admin console. Tailscale Serve is tailnet-only: never enable Funnel, which would expose the viewer publicly.
Password-less + tailnet-bound is sufficient on a single-operator tailnet. Add requirepass if you want a second layer: say you run untrusted tagged devices on the tailnet, or your compliance posture asks for authentication independent of the network boundary. The password lives in the macOS Keychain, never in a file:
# Generate a URL-safe password and store it in the Keychain.
PASS=$(openssl rand -base64 36 | tr -d '/+=' | head -c 48)
security add-generic-password -a "$USER" -s darkmux-redis -w "$PASS"
# Set it on Redis.
redis-cli CONFIG SET requirepass "$PASS"
redis-cli -a "$PASS" --no-auth-warning CONFIG REWRITE
unset PASS
brew services restart redis
With the password in the darkmux-redis Keychain item, darkmux picks it up automatically on both sides:
darkmux-redis Keychain item; its config.redis connection then carries the password. (The peer guide notes this branch.).~_- only). A password containing @ : / # ? & or whitespace breaks the Redis URL parser; the brew service wrapper validates this and exits with a clear message rather than silently failing to connect. The protected-mode setting can stay no (the bind still restricts the listener) or go back to yes (belt-and-suspenders) once a password is set.
After setup, the load-bearing test is reboot the hub and confirm everything comes back unaided. KeepAlive and "starts on boot" are different guarantees; only a real reboot exercises both.
sudo shutdown -r now
# Log back in once the machine reboots, then:
# 1) Both services came up at boot
launchctl list | grep -E 'redis|darkmux'
# expect: homebrew.mxcl.redis, com.darkmux.serve (+ flow-integrity-check if enabled)
# 2) Doctor green on the load-bearing checks
darkmux doctor
# expect ✓: daemon reachable, flow sink health (Redis-backed), machine_id
# ⚠ on anything left out of scope (no profile loaded yet, etc.) is fine
# 3) Cross-network write from a peer (on the peer):
darkmux flow note --text "post-reboot peer smoke $(date +%s)"
# 4) Cross-network read via the hub's daemon (on the peer or your phone):
# https://<hub>.<tailnet>.ts.net/ (the note appears in the live viewer)
# 5) KeepAlive smoke: kill serve, expect a new PID
PID=$(launchctl list | awk '/com.darkmux.serve/ {print $1}')
kill -TERM "$PID"; sleep 14
launchctl list | grep com.darkmux.serve
# 6) Redis stream includes the peer-smoke records (no -a flag; password-less)
redis-cli XLEN darkmux:flow # higher than your pre-reboot baseline
All pass → the hub is operational. Now point your peers at it via the peer setup guide.
~/.darkmux/flows/ sink survives independently, so per-machine history is preserved everywhere. The hub is the coordination point, not the sole source of truth: back it up like any machine you rely on.~/.darkmux/fleet.json is its own, so adding a machine means registering it on the machines that need to see it (the machine add commands above).