Magic Tools
Claude GuidesBy CooconAugust 29, 202624 views12 min read

Turn a Home Mac mini Into an Always-On Claude Code Workstation: claudecodeui + SSH Reverse Tunnel, Take Over Sessions From Any Browser

The Mac mini at home stays on around the clock: Claude Code manages the workspaces of five or six projects on it, and long tasks routinely run for tens of minutes. The problem is that I'm not always home — when I'm out, how do I check where a task is, approve a permission request, or hand it a new job?

The goal is specific: from any device with a browser, open a URL, log in, and see and operate the Claude Code sessions on the Mac mini. No client install, no VPN — it has to work on an office computer and on a phone.

This setup has been live for a week and is in daily use. The article follows the real decision order: two selection rounds first (which web UI, which transport), then the full working configuration, then actual screenshots and operating numbers, and finally the pitfalls already hit.

Selection Round One: Which Web UI

There are four ways to "operate Claude Code in a browser." I ruled them out one by one until the last:

The official web version (claude.ai/code): eliminated first. It runs in Anthropic's cloud sandbox and operates on a copy of the code inside that sandbox — whereas what I need is to operate the local workspaces on the Mac mini: local repos, local .env files, local CLIs that are already logged in. They are simply not the same thing.

ttyd + tmux (terminal-in-a-browser): architecturally entirely feasible — hang a tmux session on a web page via ttyd and you have a remote terminal. But five minutes of using it on a phone tells you where the problem is: typing into a terminal on a touchscreen is torture — arrow keys, Ctrl combos, scrolling back through output, every one of them is awkward. What it gives you is "a remote screen," not "an interface designed for mobile."

code-server (VS Code in the browser): works, but roundabout. It is fundamentally an editor, and Claude Code still has to run inside its integrated terminal — so you're wrapping a heavy shell (memory measured in GB) around a worse terminal experience, and the mobile layout is basically unusable.

claudecodeui (open source, 13.5k stars): the final choice. The decisive point is its data model —

claudecodeui does not maintain its own session state — it reads the session JSONL files under ~/.claude/projects/ directly. That means a Claude Code session you start in the terminal is the same one you see on the web; conversely, a session started from the web is right there when you get back to the computer and run claude --resume. The web UI is just another view of the same data — there is no separate ledger of "web sessions" versus "terminal sessions."

That one property hits my core scenario dead on: what I take over while out is the very session I opened in the terminal before leaving, not a fresh one started from scratch. The other points in its favor:

  • Mobile-first UI: a project-list drawer, a session stream, a bottom input bar — designed for a phone screen, not a desktop layout squeezed down
  • Auth built in: JWT login + bcrypt passwords, and the login endpoint is a natural fit for an extra rate-limit layer at nginx (config below)
  • More than chat: a built-in file browser (with editing), a git panel, and a real terminal (WebSocket) — a fallback for whatever chat can't solve
  • Open source and forkable: this paid off within the week — on day two I hit an upstream bug, forked it, and fixed it myself (see "Pitfalls")

Its measured footprint is light: a single Node process with RSS stable at around 170MB — imperceptible on a Mac mini that still has real work to run.

Selection Round Two: Why an SSH Reverse Tunnel, Not Tailscale

Home broadband has no public IP — that's the starting condition. The textbook answer for "reach a machine at home from outside" is Tailscale, but for this specific need — a web entry point reachable from any browser — an SSH reverse tunnel wins on four dimensions:

1. Zero install on the accessing side (decisive). Tailscale's model is "devices join the network": every accessing device installs a client and logs into an account. A phone can do that; an office computer often isn't allowed to, and a borrowed device certainly won't. The output of SSH tunnel + nginx is an ordinary HTTPS URL, usable by anything with a browser. "Zero install" isn't a nicety — it determines in how many real-world situations this entry point is actually available.

2. A network path you control. Tailscale relies on NAT hole punching for direct connections and falls back to DERP relays when punching fails — with carrier-grade NAT common on domestic ISPs plus restrictions on the far end, the punch-through rate is not encouraging, and once it falls back, traffic detours through Tailscale's overseas nodes with latency and stability outside your control. The SSH tunnel's path is fixed and chosen by you: Mac mini → the VPS you picked → you. You know whether the route is good before buying the VPS; when something breaks, ssh -v shows you exactly what — no hole punching, no relays, no third-party control plane, none of the "why is it slow again today" mystery layers.

3. The exposure surface is one port, not a whole machine. Once a machine joins a Tailscale network, the entire machine is reachable within it (every listening port); the trust boundary is "account security + ACLs written correctly." A reverse tunnel exposes exactly one service on exactly one port, and that port listens only on loopback on the VPS — the only thing the public internet can touch is that one URL on nginx. The audit is "are these three doors locked," not "does the ACL of the whole network have a hole."

4. It reuses assets I already have. My VPS already has nginx, automatic certificate renewal, and a logging setup; this plan adds one vhost and one tunnel. Tailscale would introduce a new account system + a resident agent on every device + an ACL configuration language of its own.

Conversely, when Tailscale is the right choice: you only access from your own devices and every one of them can run the client; you want whole-machine capability (remote desktop, SMB, direct SSH); you have no VPS and don't want to maintain one. And one thing that must be said honestly — this plan's entry point is publicly reachable, so security rests entirely on those authentication doors; Tailscale's services are visible only inside the private network, which eliminates an entire class of risk by construction. If your web UI has no reliable authentication, do not run this plan naked.

A word on Cloudflare Tunnel: same idea as this plan (dial out from inside, terminate at an edge) and free — the best alternative if you have no VPS. With a VPS, your own nginx gives you more control over timeout policy, rate limiting, and logs.

Architecture

[Mac mini, home]                    [VPS, public]                    [any browser]
claudecodeui              SSH reverse tunnel      nginx (TLS + rate limit)
127.0.0.1:18300   ────────►  127.0.0.1:18300  ────────►  https://code.example.com
(loopback only)     ssh -R     (loopback only)      reverse proxy

Three key points: the web UI listens only on loopback (even the LAN can't reach it); the tunnel is dialed out by the Mac mini (so home needs no public IP and no router changes); the tunnel's landing point on the VPS also listens only on loopback — the sole public entry is nginx.

Domains, IPs, and usernames in the configs below are placeholders.

Setup: the Mac mini Side

Installing and Supervising claudecodeui

git clone https://github.com/siteboon/claudecodeui.git ~/tools/claudecodeui
cd ~/tools/claudecodeui
npm install && npm run build

Three lines of .env — the key one binds to loopback:

SERVER_PORT=18300
HOST=127.0.0.1
NODE_ENV=production

Use launchd to make it a start-on-boot resident service, ~/Library/LaunchAgents/com.example.claudecodeui.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.example.claudecodeui</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/node</string>
    <string>dist-server/server/index.js</string>
  </array>
  <key>WorkingDirectory</key><string>/Users/YOUR_USER/tools/claudecodeui</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    <key>HOME</key><string>/Users/YOUR_USER</string>
  </dict>
  <key>KeepAlive</key><true/>
  <key>RunAtLoad</key><true/>
  <key>StandardOutPath</key><string>/Users/YOUR_USER/tools/logs/claudecodeui/server.log</string>
  <key>StandardErrorPath</key><string>/Users/YOUR_USER/tools/logs/claudecodeui/server.err.log</string>
</dict>
</plist>

An easy trap: the launchd environment does not have your shell configurationPATH and HOME must be written explicitly, or node won't be found and neither will the claude CLI.

The Reverse Tunnel, Also Under launchd

On macOS you don't need autossh — launchd's KeepAlive is the best supervisor there is. ~/Library/LaunchAgents/com.example.ccui-tunnel.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.example.ccui-tunnel</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/bin/ssh</string>
    <string>-N</string>
    <string>-o</string><string>ExitOnForwardFailure=yes</string>
    <string>-o</string><string>ServerAliveInterval=30</string>
    <string>-o</string><string>ServerAliveCountMax=3</string>
    <string>-R</string><string>127.0.0.1:18300:127.0.0.1:18300</string>
    <string>tunnel@203.0.113.10</string>
  </array>
  <key>KeepAlive</key><true/>
  <key>RunAtLoad</key><true/>
  <key>ThrottleInterval</key><integer>15</integer>
</dict>
</plist>

Why the parameters are set this way:

  • -R 127.0.0.1:18300:127.0.0.1:18300: using the same port on both ends keeps things simple; write the 127.0.0.1: prefix explicitly so the landing point on the VPS never listens publicly
  • ExitOnForwardFailure=yes: if the forward fails to establish, make ssh exit cleanly instead of holding a dead connection while pretending to be alive — only after it exits does launchd know to restart it. Without this, a dropped tunnel plays dead-alive while you fume from outside
  • ServerAliveInterval=30 + CountMax=3: a dead link is detected and exited within 90 seconds — broadband blips and modem re-dials self-heal through this
  • ThrottleInterval=15: don't retry frantically while the network is down

A note on keys: under launchd, ssh looks for ~/.ssh/id_ed25519 at the default path, and it must have no passphrase (or be pre-loaded into the Keychain), otherwise unattended connection fails.

Don't Let the Mac mini Fall Asleep

sudo pmset -a sleep 0 disksleep 0   # machine never sleeps (display can)
sudo pmset -a autorestart 1          # power back on automatically after an outage

Setup: the VPS Side

nginx: TLS + WebSocket + Login Rate Limiting

This is the actual config running in production (domain redacted). The two easiest things to miss are both commented — the WebSocket upgrade headers and the long-connection timeouts:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ""      close;
}

# Brute-force protection on the login endpoint: 10 requests per IP per minute
limit_req_zone $remote_addr zone=ccui_auth:1m rate=10r/m;

server {
    listen 443 ssl;
    server_name code.example.com;

    ssl_certificate     /etc/letsencrypt/live/code.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/code.example.com/privkey.pem;

    # Separate rate limit on the login endpoint — the entry is on the public internet, this is mandatory
    location /api/auth/login {
        limit_req zone=ccui_auth burst=5 nodelay;
        proxy_pass http://127.0.0.1:18300;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        proxy_pass http://127.0.0.1:18300;
        proxy_http_version 1.1;
        # The chat stream and the built-in terminal are both WebSocket — these two lines are non-negotiable
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        # Don't let long sessions get cut by the 60s default
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        client_max_body_size 50m;
    }
}

Certificates issue normally with certbot. If your 443 is already taken by another service (say, SNI-based routing), hang the vhost on an internal port and let the routing layer forward to it — the architecture is unchanged.

Tightening the Tunnel Account

I took the shortcut of reusing an existing ops account. If you create a dedicated one (recommended), pin its privileges down with a Match block in /etc/ssh/sshd_config:

Match User tunnel
    AllowTcpForwarding remote
    PermitListen 127.0.0.1:18300
    PermitTTY no
    X11Forwarding no
    AllowAgentForwarding no

Combined with a nologin shell and key-only login, even a leaked key only grants "the ability to hang a forward on port 18300 of your VPS's loopback" — nothing more.

Verification

# On the VPS: the tunnel landing point must listen on loopback only
ss -tlnp | grep 18300     # must be 127.0.0.1:18300, never 0.0.0.0

# Direct public access to the port must fail
curl -m 5 http://203.0.113.10:18300/   # should time out / be refused

What It Looks Like in Practice

Open the domain in a phone browser — the login page (JWT auth built in, with nginx rate limiting on top):

claudecodeui mobile login page: username and password form

After login, the left drawer is the project list — these are the real projects under ~/.claude/projects: any directory where you've used Claude Code in the terminal shows up automatically, with session counts and stars:

Mobile project list drawer: multiple projects with session counts, stars, and a search box

Open a project into a session: the message stream, tool calls (Read/Bash shown collapsed), the bottom input bar and model selector — fully operable on a phone. Checking status, approving permissions, and sending instructions all happen right here:

Mobile session view: user messages, Claude's Read and Bash tool calls, bottom input bar

A desktop browser gets the full two-column layout, with the file browser, git panel, and built-in terminal switchable at the top right:

Desktop view: project list with paths and session counts on the left, workspace on the right

One week of operating numbers (real readings, not estimates):

Metric Measured
Tunnel stability A single ssh process online for 5 straight days, 0 lines in the error log, zero manual intervention
claudecodeui memory RSS ≈ 170MB (read after 3.5 days of continuous running)
Drop recovery 90s heartbeat detection + instant launchd restart — perceived as "back within two minutes on its own"
What the phone is actually used for Checking task progress, approving permission requests, sending follow-up instructions; heavy work goes back to the computer

Pitfalls Hit

The <synthetic> placeholder displayed as a model name. Hit on day two: after an API error in one session, claudecodeui's session list showed the model name as <synthetic>. It turned out to be Claude Code's behavior — it writes locally synthesized placeholder lines into the session JSONL (API-error placeholders and the like), and those lines carry "<synthetic>" in the model field, which claudecodeui's model scan trusted without discrimination. Forked and fixed (any value wrapped in angle brackets is treated as a placeholder and skipped) — three commits, done. This is exactly why "open source and forkable" earned a point in the selection round: with a closed tool, a bug this annoying just sits there until someone else fixes it.

launchd's environment is a clean slate. Both plists tripped on this: without PATH, node and claude can't be found; with a passphrase on the ssh key, unattended startup fails. The rule is one line: anything running under launchd must declare every dependency explicitly — never count on your shell configuration.

Missing WebSocket headers produce a deeply confusing symptom. The page opens, login works, the project list is there — but chat won't send and the terminal won't connect, because all the HTTP requests are fine and only the WebSocket upgrade is being swallowed by nginx. If you see "the UI looks normal but everything real-time is dead," check the Upgrade/Connection headers first.

Security Checklist

Walk through before going live:

  • The web UI on the Mac mini listens only on 127.0.0.1
  • The tunnel landing point on the VPS listens only on 127.0.0.1 (verify with ss -tlnp)
  • Direct public access to the tunnel port fails; only 443 via nginx is reachable
  • nginx rate-limits the login endpoint (the entry is public — brute-force protection is not optional)
  • The tunnel account has minimum privileges (nologin + Match restrictions + key-only)
  • ExitOnForwardFailure=yes is configured
  • The claudecodeui password is strong and not reused

FAQ

My home broadband has no public IP — does this work?

Yes; that's exactly what the reverse tunnel is for. The connection is dialed out from the Mac mini to the VPS — the home side only needs to be online. No public IP, no port mapping, no bridge mode on the modem.

Is the session I see on the web the same one as in the terminal?

Yes. claudecodeui reads the session files under ~/.claude/projects/ directly and keeps no independent state. A session opened in the terminal can be picked up on the web, and a session started on the web is there when you run claude --resume back at the computer.

Does a dropped tunnel need manual intervention?

No. The ServerAliveInterval heartbeat detects a dead link within 90 seconds, ExitOnForwardFailure guarantees ssh exits cleanly, and launchd's KeepAlive restarts it immediately. Over five days of testing with several broadband blips, every one self-healed — zero lines in the error log.

Why not just run Claude Code on the VPS?

The workspaces live on the Mac mini: local repos, local credentials, CLIs that are already logged in. Besides, the mini's compute and memory are paid for once; a VPS of the same spec costs money every month. In this architecture the VPS is only the "street address" — the cheapest machine is plenty.

How does it compare to Cloudflare Tunnel?

Same idea (dial out from inside + edge entry) and free — the best alternative when you have no VPS. The differences: traffic passes through Cloudflare's edge (one more third party, with unstable speeds in some regions), and long-lived WebSocket connections are subject to its timeout policy. With your own nginx you have more control.

Can you really get work done on a phone?

Depends on the work. Checking progress, approving permissions, sending instructions, reading diffs: entirely adequate — that's nine-tenths of the away-from-desk scenarios. Writing large amounts of code: unrealistic, the screen and keyboard are what they are. The actual pattern is "supervise and dispatch from mobile, heavy work back at the computer."

Related Articles

You Set ANTHROPIC_BASE_URL. Claude Code Ignored It.

I exported ANTHROPIC_BASE_URL in .zshrc to point at a self-hosted API gateway, and Claude Code kept talking to Google Vertex anyway. On the same machine, a launchd-managed web UI insisted it wasn't authenticated at all. Neither bug was in the gateway — both were in the gap between 'I set the env var' and 'the process actually has it.'

claude-codebug-postmortem+2
pitfallsAug 24, 20264 min
92

Qwen3.8-27B + DFlash 2 on a 24GB Mac mini: a Measured 1.8x, and Why You Won't Get the Official 3x

One week after DFlash 2 shipped, I got Qwen3.8-27B with speculative decoding fully working on a 24GB Mac mini M4: 6.5 tok/s to 11.7–12.2 tok/s at 4-bit, a stable 1.8–1.9x. This post covers the exact deployment commands, three controlled benchmark rounds, the GB-by-GB memory budget, and the three concrete reasons the official 2.7–3.4x number shrinks on consumer Apple Silicon.

qwendflash+6
ai-tutorialsAug 23, 20266 min
194

Claude Didn't Write That Printer Driver: 14KB of Glue, a Linux Container, and HP's Own Binary

A tweet claiming Claude wrote a macOS driver for a Windows-only HP printer pulled 2.62 million views. Pull the repo and you find 6,497 bytes of shell, 7,638 of Python, 550 of Dockerfile — not one line of C. The actual encoding is done by rastertospl, a binary from HP's official Linux driver, running in a Linux container on the Mac. Hacker News caught this. But debunking isn't the point: the genuinely valuable parts of that four-hour session (reading the printer's own error pages, escaping the CUPS sandbox, writing USB directly) and the fact that the decisive turn came from the human, not from Claude, are a precise measurement of where AI's grunt-work ability currently ends.

claude-codeopen-source+6
claudeAug 19, 202611 min
143

Fable 5 Has a 1M Context Window, So Why Does the Status Line Say 200k? Capture the Data Before You Swap the Tool

Claude Fable 5 officially ships with a 1M-token context window, yet the Claude Code status line kept showing 200k as the denominator. The first instinct — 'let's switch to a better statusline' — was wrong. This postmortem walks through the full debugging process: one line of tee to capture the statusline's stdin, hard evidence that the official field misreports 200000 for new models, and a model-table fix. Plus a general lesson: swapping tools never fixes a broken data source.

llmclaude-code+3
pitfallsAug 14, 20264 min
222

Published by Magic Tools