---
title: "My Dev Environment Is Two Machines and a Tunnel"
date: 2026-11-03
slug: two-machines
description: "A MacBook that sleeps and a Linux box somewhere in Europe that never does. How an always-on devbox, one SSH ControlMaster, and a 2-second port poll let coding agents run all day and still open my browser, screenshot my screen, and paste to my clipboard."
---

My laptop is a 2020 M1 MacBook Pro. It gets hot, it sleeps, and it is the only thing in my setup with a screen. Everything else runs on a rented Linux box in a datacenter I have never seen, and it has not been off since I provisioned it.

That split sounds like a compromise. It turned out to be the best decision I made about how I work with coding agents. This post is the shape of it, the one daemon that makes it feel like one machine, and the things that break.

## The shape

The **Mac** is where I look at things: a terminal ([Ghostty](https://ghostty.org) running [herdr](https://herdr.dev)), a browser, a tiling window manager, and the native apps that only exist on macOS. It goes to sleep in a bag.

The **devbox** is a mid-range dedicated server (a few cores, plenty of RAM, mirrored NVMe) reachable only over a private mesh VPN. SSH needs a key plus a second factor and is not exposed to the public internet at all. It runs the agents, the dev servers, a database, a cache, a headless browser, a reverse proxy, cron jobs, and a handful of systemd user services. It has no display and does not need one.

The rule that follows from the shape: **execution happens on devbox, and the Mac is a peripheral.** Sessions do not run on the laptop, so closing the lid does not kill them. Three agents can be rebasing three worktrees at 3 a.m. while the Mac is asleep, and the results are there in the morning.

Click through the flows that make this feel like one machine:

<iframe class="frame" src="topology.html" height="440" loading="lazy" title="Mac and devbox topology, with each cross-machine flow animated"></iframe>

## One daemon: `devbox tunnel`

Every one of those flows rides one thing: a single SSH ControlMaster from devbox to the Mac (over the VPN), owned by a small daemon that polls for listening ports every two seconds.

```text
1. open ControlMaster  devbox -> mac (over the VPN)
2. every 2s: list ports listening on devbox
3. new port?     ssh -S tunnel.sock -O forward -R <port>:localhost:<port> mac
   port gone?    ssh -S tunnel.sock -O cancel  -R <port>:localhost:<port> mac
4. reconcile: anything listening but not forwarded gets retried
```

That is the whole idea. Start `bun dev` on devbox and about two seconds later the same port on the Mac is that server. No config file, no per-project setup, no remembering to forward anything. Infrastructure ports (SSH, the databases, the reverse proxy) are excluded; everything else just appears.

The part that took real work is not the forwarding, it is surviving the Mac going to sleep. The daemon runs as a systemd user unit and health-checks the master every poll. `ServerAliveInterval=15` times two means a dead link is noticed in about thirty seconds. On loss it tears down the half-open master, kills the orphaned `sshd-session` processes on the Mac that still hold the forwarded ports (otherwise re-forwarding fails with "port already in use"), reconnects, and re-establishes every forward. A reconcile pass each tick re-adds any forward that failed, so a transient error never permanently drops a port.

```ts
// tunnel-daemon.ts (excerpt): re-establish forwards after a reconnect
for (const [port, info] of forwards) {
  await cancelForward(port)              // idempotent: cancel then add
  const ok = await addForward(port)
  if (!ok) forwards.delete(port)         // stays listening on devbox;
}                                        // reconcile picks it up next tick
```

The earlier version had `start` spawn a detached process, which raced systemd's `Restart=always` and accumulated orphan daemons. Now `start`, `stop` and `restart` delegate to `systemctl --user`, and there is exactly one way the daemon comes into existence.

## The reverse direction: `devbox local`

The master socket is bidirectional, so devbox can also run commands *on the Mac* with zero connection latency:

```sh
devbox local "open http://localhost:3000/"                        # Mac browser
devbox local "say 'deploy finished'"                              # Mac speech
echo "text" | devbox local pbcopy --stdin                         # Mac clipboard
devbox local "peekaboo image --app Ghostty --path /tmp/shot.png"  # Mac screenshot
```

On the headless devbox, `pbcopy`, `wl-copy` and `xsel` are shims that pipe to `devbox local pbcopy --stdin`, falling back to OSC 52 when the tunnel is down. CLI tools that try to copy text on the Linux box land it in the Mac clipboard without knowing. `$BROWSER` is `devbox-open`, which does the same for URLs.

One footgun worth documenting: the stdin channel is not binary-safe. A 34 KB mp3 arrived on the Mac as 61 KB of garbage. Base64 on devbox, decode on the Mac.

## Things that fall out for free

**OAuth on a box with no browser.** A CLI on devbox starts its callback server on `localhost:<port>`, `$BROWSER` opens the login URL on the Mac, I authenticate there, the callback to `localhost:<port>` hits the Mac, and the tunnel forwards it back to devbox. `gcloud auth login`, `wrangler login`, most things: they just work.

```mermaid
sequenceDiagram
  participant CLI as CLI on devbox
  participant T as devbox tunnel
  participant B as Mac browser
  participant P as Provider
  CLI->>CLI: listen on localhost:8085 for the callback
  T-->>B: :8085 now forwarded to the Mac
  CLI->>B: $BROWSER opens the auth URL
  B->>P: sign in
  P-->>B: redirect to localhost:8085/callback?code=…
  B->>T: hits Mac localhost:8085
  T->>CLI: forwarded back to devbox
  CLI->>CLI: done, token stored on devbox
```

**Agents that verify their own work.** The forward also runs Mac to devbox for one port: the Mac's browser is launched with remote debugging enabled, and that port is forwarded the other way so devbox sees it as localhost. So an agent on devbox can drive my real browser over the Chrome DevTools Protocol: load the page, evaluate JavaScript, capture a screenshot, and look at it. This blog's `check:overflow` script is exactly that. It serves `dist/` on devbox, opens each page in the Mac's browser at 320 and 390 pixels wide, and fails the build if anything scrolls horizontally. The agent that wrote the last post used the same path to screenshot its own diagrams before telling me they rendered.

```ts
// scripts/check-overflow.ts (excerpt): a build check that uses the Mac's browser from devbox
const list = await (await fetch(`http://localhost:${CDP_PORT}/json`)).json()
const tab = list.find((t) => t.type === "page")
const ws = new WebSocket(`ws://localhost:${CDP_PORT}/devtools/page/${tab.id}`)
// ... Emulation.setDeviceMetricsOverride({ width: 320 }), Page.navigate, Runtime.evaluate(scrollWidth > clientWidth)
```

**Agent status on the menu bar.** A watcher on devbox reads every herdr session's socket and pushes the worst agent state to the Mac's SketchyBar via `devbox local`, on change plus a 20-second heartbeat. Each workspace pill gets a dot: red blocked, a spinner working, teal done. The bar expires anything older than 45 seconds, so a dead watcher or a sleeping Mac fades the dots out instead of leaving a stale all-clear.

**The phone.** Since nothing lives on the Mac, `mosh devbox` from an iPhone with [Moshi](https://moshi.dev) lands in the same herdr workspace, mid-session. Reading a worker's output from the sofa is fine. Anything that needs the Mac waits until it wakes, which is the honest answer anyway.

**This blog's deploys.** GitHub Actions runners register on devbox as systemd user units with repo-scoped labels. A `runs-on: [self-hosted, …]` job builds and pushes to Cloudflare from a machine that already has Bun, the cache, and my `gh` auth. No GitHub-hosted minutes.

## When you actually need a real URL

`tunnel` is Mac-only by design. For webhooks and client demos there is `expose`, which adds a route to the reverse proxy and gets a certificate on demand:

```sh
devbox expose add 3000 --name demo      # https://demo.<my-dev-domain>   (basic auth)
devbox expose add 4000 --public         # https://<random>.<my-dev-domain> (no auth)
devbox expose add 3000 --private        # VPN-only
```

| | Tunnel | Expose |
|---|---|---|
| Who can reach it | My Mac, as localhost | Anyone with the URL |
| Auth | SSH, nothing to configure | Basic auth, public, or VPN-only |
| Setup | Automatic | One command per port |
| For | Dev tools, localhost UIs, OAuth | Webhooks, demos, sharing |

## What breaks

Being honest about this setup means listing the failure modes, because every one of them has cost me an afternoon.

- **The Mac is asleep and nothing says so.** Agents on devbox that try to open a browser, reach the debugging port, or complete an OAuth flow fail silently. The rule for agents is now written into their instructions: before any cross-machine action, run `devbox tunnel status` and start it if it is down. The SketchyBar dots fading out is the human-side hint.
- **Stale processes pile up.** Detached dev servers from agents that were killed mid-task keep ports. `devbox doctor` lists them; `devbox clean --processes` kills the ones matching known ad-hoc patterns and never the infrastructure. The dev server for this very blog was an orphan from August until an agent noticed last week.
- **Two machines, two OSes, one muscle memory.** Homebrew and `launchctl` on one side, `apt` and `systemd` on the other. Editing a file under `infra/devbox/` on the Mac changes nothing on devbox until it is synced. The agents got this wrong so often that "detect which machine you are on first" is now step zero of every task, in bold, in the global rules.
- **Clipboard bytes.** See above. Base64.

## Why bother

I could run everything on a bigger laptop. I priced one this summer. But the property I actually wanted was not speed, it was that **my work does not stop when I do.** Agents run overnight. A worker pane started at lunch is still there at dinner from the phone. The laptop can overheat, sleep, or get closed and nothing is lost, because nothing was ever on it.

The cost is one daemon, a few shims, and a set of rules about which side does what. The tunnel is around 300 lines of TypeScript. Everything else is `ssh -O forward` and a systemd unit doing what systemd units do.

The setup is provisioned with Ansible and driven by a small CLI. I have left the specifics (hosts, ports, domains) out of this post on purpose.
