A browser-based TUI for managing tmux sessions across many
servers. Pick a session from the sidebar, drop into a full interactive terminal in your browser.
It looks and behaves like lazygit / k9s / btop (monospace, keyboard-first, zero chrome), not
a SaaS dashboard.
Servers and their tmux sessions in a keyboard-first sidebar.
Attach and you're in a full interactive terminal, Nerd Font glyphs included.
Sessions stay alive on the server, so long-running work survives reconnects.
Hearth is two apps. The split keeps the terminal stream off Vercel (serverless can't hold long-lived WebSockets) and keeps every server credential on a machine you control.
flowchart LR
B["Browser"]
W["apps/web<br/>Next.js on Vercel"]
CF["Cloudflare Tunnel<br/>hearth.your-domain"]
H["hearthd<br/>your VPS · 127.0.0.1:8080"]
R[("remote server<br/>tmux")]
L[("VPS-local<br/>tmux")]
B -->|"1 · load UI (HTTPS)"| W
B -->|"2 · REST via /api/*"| W
W -->|"3 · REST + Bearer HEARTH_TOKEN"| CF
B -->|"4 · terminal stream (WSS + JWT)"| CF
CF --> H
H -->|"ssh2 PTY"| R
H -->|"node-pty"| L
apps/web(@hearth/web): Next.js 15 + Tailwind v4 + xterm.js. Renders the UI, proxies REST tohearthd(injecting the service token server-side), and mints short-lived tokens for the browser's direct terminal WebSocket. Deploys to Vercel. Never carries terminal traffic.apps/hub(@hearth/hub, binaryhearthd): Fastify + ssh2 + node-pty. Holds all server credentials. Bridges a WebSocket totmux new -A -s <name>(attach-or-create) over SSH (remote) or node-pty (the VPS itself). Because tmux keeps sessions alive, a dropped connection simply re-attaches.
| Where | Needs |
|---|---|
| Your dev machine | Bun 1.3+ · tmux (only to test a local session) |
The VPS running hearthd |
Bun 1.3+ (install/build) · Node 20+ (runs the built daemon) · tmux if you host sessions on the VPS itself |
| Each target server | tmux installed · reachable over SSH |
| Vercel | nothing to install (managed) |
Install Bun: curl -fsSL https://bun.sh/install | bash
node-pty(used only for a VPS-local session) is an optional native addon. Bun skips its native build by default, which is fine for the SSH path. To use alocalserver, install a C++ toolchain (Xcode CLT on macOS;build-essential+python3on Linux) and runbun pm trust node-pty.
This gets the full app running on your machine with a terminal into your own Mac/Linux box (no remote server required).
1. Install and build
git clone <this-repo> hearth && cd hearth
bun install2. Generate the two shared secrets (you'll paste the same values into both apps)
openssl rand -hex 32 # use as HEARTH_TOKEN
openssl rand -hex 32 # use as JWT_SECRET3. Configure the hub (apps/hub)
cp apps/hub/.env.example apps/hub/.envEdit apps/hub/.env and set HEARTH_TOKEN and JWT_SECRET to the values from step 2. Then create
the server list:
# A local session on this machine, no SSH (needs tmux + a built node-pty):
echo '[{ "id": "local", "name": "this machine", "local": true }]' > apps/hub/servers.json(Prefer a remote box? Use apps/hub/servers.json.example as a template instead, see
Server configuration.)
4. Configure the web app (apps/web)
cp apps/web/.env.example apps/web/.env.localEdit apps/web/.env.local:
HEARTH_HTTP_URL=http://localhost:8080
HEARTH_WS_URL=ws://localhost:8080
HEARTH_TOKEN=<same value as the hub>
JWT_SECRET=<same value as the hub>
DASHBOARD_PASSWORD=<pick a password>5. Run both (two terminals, or use bun run dev from the root to run both at once)
bun run dev:hub # → http://127.0.0.1:8080
bun run dev:web # → http://localhost:30006. Open http://localhost:3000, log in with DASHBOARD_PASSWORD, select a session, press Enter.
HEARTH_TOKEN and JWT_SECRET must be identical in both apps. Everything else is per-app.
apps/hub/.env (the daemon, keeps secrets):
| Variable | Default | Purpose |
|---|---|---|
HOST |
127.0.0.1 |
Bind address. Keep on loopback in production (the tunnel reaches it). |
PORT |
8080 |
Listen port. |
HEARTH_TOKEN |
(required) | Static token the web app presents on REST calls. Shared. |
JWT_SECRET |
(required) | HMAC secret for verifying terminal-WebSocket tokens. Shared. |
SERVERS_FILE |
./servers.json |
Path to the server inventory. |
WS_PING_MS |
30000 |
Server keepalive ping interval (keeps idle terminals alive). |
ALLOWED_ORIGIN |
(empty) | Comma-separated origins allowed to open the terminal WebSocket. Set to your Vercel URL in production. Empty allows any (fine for local dev). |
LOG_LEVEL |
info |
fatal | error | warn | info | debug | trace | silent |
apps/web/.env.local (or Vercel env vars):
| Variable | Example | Purpose |
|---|---|---|
HEARTH_HTTP_URL |
https://hearth.example.com |
Where the Next server reaches hearthd's REST API. |
HEARTH_WS_URL |
wss://hearth.example.com |
Where the browser opens the terminal WebSocket. |
HEARTH_TOKEN |
(required) | Must match the hub. Shared. |
JWT_SECRET |
(required) | Must match the hub. Shared. |
DASHBOARD_PASSWORD |
(required) | The single login password. |
apps/hub/servers.json is a JSON array. Credentials live only here, on the hub.
[
{
"id": "vps",
"name": "vps · frankfurt",
"host": "203.0.113.10",
"port": 22,
"user": "deploy",
"identityFile": "~/.ssh/id_ed25519"
},
{ "id": "this-box", "name": "this machine", "local": true }
]| Field | Required | Notes |
|---|---|---|
id |
yes | Unique, [A-Za-z0-9_-]. |
name |
yes | Display label. |
host |
remote only | Hostname or IP. |
port |
no | SSH port, default 22. |
user |
remote only | SSH user. |
identityFile |
no | Path to a private key on the hub (~ is expanded). |
password |
no | SSH password (use a key instead when possible). |
local |
no | true runs tmux on the hub itself via node-pty (no SSH). |
cwd |
no | Absolute start directory for new tmux sessions. Omit to use the default (local: the hub user's home; remote: the SSH login dir). |
New sessions start in ~ by default. To pin a server elsewhere, set an absolute cwd (e.g.
"/srv/projects"). cwd only applies when a session is created — reattaching to an existing
session keeps its original directory, so kill and recreate it to pick up a change.
You can also add and remove servers from the UI (a / x); changes are written back to this file.
git clone <this-repo> /opt/hearth && cd /opt/hearth
bun install
bun run --filter @hearth/hub build # builds apps/hub/dist/index.js
cp apps/hub/.env.example apps/hub/.env # fill in, then: chmod 600 apps/hub/.env
cp apps/hub/servers.json.example apps/hub/servers.json # edit your servers
# install as a service (edit User / paths in the unit first)
sudo cp apps/hub/deploy/hearthd.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now hearthd
journalctl -u hearthd -f # check it startedThis gives hearthd a public https:// + wss:// address without opening any inbound port or
exposing the VPS IP. Run on the VPS:
cloudflared tunnel login
cloudflared tunnel create hearth
cloudflared tunnel route dns hearth hearth.example.comCopy apps/hub/deploy/cloudflared-config.yml to ~/.cloudflared/config.yml, fill in the tunnel
UUID and your hostname, then:
cloudflared tunnel run hearth # or: sudo cloudflared service installWebSockets are proxied automatically. (Cloudflare drops idle connections after ~100s; hearthd's
WS_PING_MS keeps terminals alive.)
- Import the repo. Set the project Root Directory to
apps/web(Vercel detects Bun from thepackageManagerfield). - Add the
apps/webenvironment variables from the table above. PointHEARTH_HTTP_URL/HEARTH_WS_URLathttps:///wss://hearth.example.com, and use the sameHEARTH_TOKEN/JWT_SECRETas the hub. - After the first deploy, set the hub's
ALLOWED_ORIGINto your Vercel URL and restarthearthd.
| Context | Keys |
|---|---|
| Sidebar | j/k or ↑/↓ move · Enter attach or expand · n new session · d kill session · a add server · x remove server · r refresh · / or ⌘K jump · 1–9 switch tab |
| Terminal | ⌘B back to sidebar · ⌘1–9 tab · ⌘←/⌘→ adjacent tab · ⌘W close tab · ⌘K jump |
⌘ is Ctrl on non-Mac. While a terminal is focused, plain keystrokes go to the terminal; only
the ⌘-combos are intercepted, so vim/tmux/etc. keep working.
-
Select to copy. Selecting text in a terminal copies it straight to your local clipboard. (The browser Clipboard API needs a secure context, which the
https://production deploy provides.) -
OSC 52. Apps that set the clipboard via OSC 52 (vim with
clipboard=unnamedplus, tmux copy-mode, …) reach your local clipboard too. Inside tmux this only works if tmux forwards the sequence — add to~/.tmux.confon the target host:set -g set-clipboard on set -g allow-passthrough on
-
Paste an image. Paste an image from your clipboard into a terminal and Hearth uploads it to the target host under
~/.hearth/uploads/, then types the absolute path into the prompt — so CLIs like Claude Code (which can't read your local clipboard over SSH) can load it by path. Uploads are capped at 10 MB and pruned after 7 days.
REST endpoints require Authorization: Bearer <HEARTH_TOKEN> (the web app adds this for you):
| Method | Path | Description |
|---|---|---|
GET |
/healthz |
Liveness (no auth). |
GET |
/servers |
Server list, credentials stripped. |
POST |
/servers |
Add a server (body: a server object). |
DELETE |
/servers/:id |
Remove a server. |
GET |
/servers/:id/sessions |
{ name, windows, attached }[]. |
POST |
/servers/:id/sessions |
Create a session (body: { "name": "..." }). |
DELETE |
/servers/:id/sessions/:name |
Kill a session. |
GET |
/servers/:id/sessions/:name/preview |
tmux capture-pane snapshot. |
POST |
/servers/:id/upload |
Store a pasted image on the host (body: { "filename", "mime", "dataBase64" }); returns { "path" }. |
Terminal WebSocket (token in the query string, short-lived):
GET /attach?server=<id>&session=<name>&cols=<c>&rows=<r>&token=<jwt>
Binary frames carry terminal bytes (both directions); text frames carry control JSON, e.g.
{ "type": "resize", "cols": 120, "rows": 40 }.
- Login.
/api/logincompares the password toDASHBOARD_PASSWORDin constant time, then sets anhttpOnly,Secure,SameSite=Laxsession cookie (a 12h JWT). - REST is proxied. The browser only ever talks to same-origin
/api/*. The Next server attachesBearer HEARTH_TOKENand forwards tohearthd.HEARTH_TOKENandJWT_SECRETnever reach the browser. - The terminal WebSocket is direct and short-lived. The browser fetches a ~60s token from
/api/token(cookie-gated) and connects straight tohearthd, which verifies the token and the requestOriginbefore opening the socket. - No shell injection. Session names are allowlisted (
^[A-Za-z0-9_.-]{1,64}$) before any use; the local path passes arguments as an array (no shell at all). Credentials stay in the hub'sservers.jsonand.env.
hearth/
├─ apps/
│ ├─ web/ @hearth/web Next.js UI + auth proxy (deploys to Vercel)
│ └─ hub/ @hearth/hub hearthd daemon (runs on your VPS)
│ └─ deploy/ systemd unit + cloudflared config
├─ package.json Bun workspace + scripts
└─ tsconfig.base.json
Root scripts: bun run dev (both), bun run build, bun run typecheck, bun run dev:web,
bun run dev:hub.
MIT