John KuehJohn Kueh
All articles

Article· Updated May 2026

Coding on the go is solved cover

Claude Code on mobile connects to your Mac over SSH. You can delegate real code changes from your phone — file edits, test runs, git commits. But you can't see the result running. Your dev server is on localhost:3000, and your phone is on cellular. The code ships; the feedback loop doesn't.

For months I treated this as a known limitation. I'd push a commit from my phone, then wait until I was back at my desk to open the browser and check. That's not coding on the go. That's coding blind and verifying later.

The missing piece was a tunnel — a way to reach localhost from outside my network. I tried ngrok. It worked, but it was slow (500–2000ms RTT), expensive ($10+/month for custom domains), and needed one session per project. I run four or five dev servers at once across worktrees. ngrok didn't scale.

So I replaced it with a self-hosted stack: a free Cloudflare Tunnel, a 107-line Node proxy, and a metro-takeover script. Now I ship production code from my phone and see it running on the same phone, in the same session.

The three-layer stack

The architecture looks like this:

phone / external network
  → *.tunnel-domain.dev           (Cloudflare wildcard CNAME)
  → cloudflared tunnel run dev    (persistent daemon via launchd)
      ├─ wildcard rule → pubproxy :1354
      │     reads portless routes.json
      │     forwards WITHOUT rewriting Host
      │     → 127.0.0.1:<dev-port>
      └─ Expo entries → Metro :8081

Each layer exists for a reason.

Cloudflare Tunnel terminates TLS at Cloudflare's edge. No ports open on my Mac. The global Cloudflare network handles the routing — typical RTT is 100–300ms, compared to ngrok's 500–2000ms for the same request. It runs as a launchd LaunchAgent, starts on boot, stays up indefinitely. Free.

portless (from Vercel Labs) auto-allocates a free port for every pnpm dev and dispatches by Host header. I don't have to remember which port each project uses. It also auto-prefixes git branch names as subdomains in worktrees — feature-auth.subs-rip.tunnel-domain.dev just works, no config, no restart. This is load-bearing when I have multiple Claude Code agents running in parallel worktrees.

pubproxy is my 107-line Node script. It sits on :1354, reads portless's ~/.portless/routes.json to look up the dev port for each incoming Host header, and forwards the request directly. The critical detail: it does not rewrite the Host header. This is the whole reason it exists.

Why Caddy broke

The original stack used Caddy instead of pubproxy. Caddy rewrote incoming Host headers from project.tunnel-domain.dev to project.localhost so portless could dispatch. That rewrite broke everything downstream — Next.js redirects, OG tags, and most painfully, Clerk Dev's auth handshake. Fresh devices visiting from a phone got bounced to project.localhost:1355, which on a phone means ERR_CONNECTION_REFUSED.

One 107-line Node script replaced Caddy. pubproxy preserves the Host header end-to-end — no dependencies, no config language, just http.createServer with a port lookup from portless's routes file. Auth works, redirects work, OG tags work.

metro-takeover: switching Expo Metro between worktrees

Only one Metro bundler can run per port. If I have two Claude Code agents working on the same Expo app in different worktrees, they can't both serve Metro on :8081 at the same time.

metro-takeover.sh solves this. It kills the current Metro, starts it from the new worktree, polls until ready, and emits a clickable dev-client deeplink. The switch takes about five seconds. Agent A finishes coding, takes over Metro, tests. Agent B takes over when it's ready. Serial, but fast enough that it doesn't matter.

Serving builds to phone

Sometimes I need a production build on a device, not just the dev client. deliver.sh takes the output of eas build --local, starts a small install-page server over the tunnel, and serves OTA install links for iOS and direct APK downloads for Android. Rebuild overwrites in place — the page always shows the latest.

Keeping it running

A doctor.sh script runs nine checks in sequence — cloudflared binary, LaunchAgent status, config integrity, portless, pubproxy, DNS resolution, end-to-end HTTP, Expo ingress, and a secret scanner for env vars that would leak into client bundles. I run it after macOS updates or whenever something returns a 502.

Security is two free Cloudflare settings: Access (login wall for *.tunnel-domain.dev, allowlisted emails only) and Bot Fight Mode (blocks known-bad agents). The wildcard subdomain is discoverable via Certificate Transparency logs, so treat it as public and gate it.

The result

The whole stack is four things: cloudflared (brew install), portless (npm), pubproxy.js (107 lines), and two launchd plists. Free, 100–300ms RTT (ngrok is 500–2000ms), wildcard support so new worktrees are auto-reachable with zero config.

I open Claude Code on my phone, ask it to make a change, and check the result in the browser on the same phone. The feedback loop is closed.