Remote dev — phone preview over Tailscale
Linear: AUT-153 (the ≤5-click ticket).
The goal: edit UI on the laptop, see the change on a phone over
the open internet, without exposing a public URL. just dev-remote
wraps just dev and pipes it through
Tailscale Serve
so the loop reaches whatever device is signed into your tailnet.
One-time setup (5 clicks)
-
Laptop: install Tailscale.
- macOS:
brew install --cask tailscale(the cask installs the system-extension build that supportstailscale serve; the Mac App Store build does too but takes a couple of trust-prompts). - Linux:
curl -fsSL https://tailscale.com/install.sh | sh.
- macOS:
-
Laptop: sign in. Run
tailscale up. A browser opens to a Tailscale OAuth flow (Google / GitHub / Microsoft / email). One click in the browser. -
Phone: install Tailscale from the App Store / Play Store. Open it, tap your account provider, authenticate.
-
Phone: sign in to the same account as the laptop. The tailnet sees both devices.
-
Run + tap. On the laptop:
just dev-remote. It prints a URL likehttps://laptop-name.tailnet-id.ts.net. On the phone, paste or tap that URL.
That's the five clicks. Optional sixth (recommended once): on the
phone, Safari → Share → Add to Home Screen (or Chrome →
three-dot menu → Add to Home screen). The URL becomes a
launchable icon — daily flow afterwards is two interactions:
just dev-remote on laptop, tap home-screen icon on phone.
Daily flow
just dev-remote
Wait ~5 seconds for the dev-server to boot and Tailscale Serve to finish provisioning. Tap the home-screen icon on the phone.
When you're done:
just dev-remote-stop
This tears down Tailscale Serve and kills the background
dev-server process. Idempotent — safe to run even if nothing is
running.
Verify the tailnet (one-time)
If just dev-remote prints a URL but the phone can't reach it:
tailscale status # phone's name should appear
tailscale ping <phone-name> # should report round-trip times
If tailscale ping fails, check the Tailscale admin console:
- DNS → MagicDNS is enabled.
- ACLs default policy allows the phone → laptop.
Privacy
tailscale serve is private to your tailnet. Only devices
signed into the same Tailscale account can reach the URL. Stop the
exposure with just dev-remote-stop (or tailscale serve --https=443 off).
tailscale funnel is the public sibling — it exposes the same
URL to the open internet. just dev-remote deliberately uses
tailscale serve instead. Do not flip to funnel for this loop.
Troubleshooting
tailscale servepermission denied. macOS may prompt to authorise the system extension once afterbrew install --cask; approve it in System Settings → Privacy & Security.- HTTPS cert says "issuing" for a minute. Tailscale provisions
a real Let's Encrypt cert on first Serve. Re-run
tailscale serve statusin 30–60 s. - Phone reloads but content is stale. Force-refresh (long-press the reload button in Safari, or pull-to-refresh in Chrome). The live-reload script reconnects after a server restart, but a cold cache may need a manual nudge.
- iOS Home Screen icon looks wrong. Add a
<link rel="apple-touch-icon">to the index page later; not blocking.
Composition
sequenceDiagram
autonumber
participant Edit as "Laptop editor"
participant Watcher as "dev-server watcher"
participant Server as "dev-server (127.0.0.1:3000)"
participant TS as "Tailscale Serve"
participant Phone as "Phone browser"
Edit->>Watcher: save crates/ui-storybook/src/foo.rs
Watcher->>Watcher: debounce 250 ms
Watcher->>Server: cargo run -p ui-storybook --bin ui-export-stories
Note over Watcher,Server: exit 0 → broadcast "reload"
Server->>Phone: WebSocket message "reload"
Phone->>TS: GET / (HTTPS over tailnet)
TS->>Server: GET / (loopback)
Server-->>Phone: index.html + injected client
The books (mdBook live-reload)
The same Tailscale machinery serves the two mdBooks, with mdbook serve providing the live-reload (no dev-server involved — mdbook
has built-in filesystem watch + websocket reload).
Two books, two ports so you can run both at once:
# Terminal 1 — screen project book
just dev-book # http://127.0.0.1:3001/
# Terminal 2 — wisp library book
just dev-wisp-book # http://127.0.0.1:3002/
# Terminal 3 (once) — expose both over Tailscale
just dev-remote-book
dev-remote-book registers two Tailscale Serve path proxies:
| Phone URL | Routes to |
|---|---|
https://<MAC>.<TAILNET>.ts.net/ | http://127.0.0.1:3001/ |
https://<MAC>.<TAILNET>.ts.net/wisp/ | http://127.0.0.1:3002/ |
Both books pass through mdbook-preprocessor-cross on every
rebuild, so \{\{shared X\}\} and \{\{wisp-link Y\}\} tags get
re-resolved live as you edit. The cross-book links work because
the production base path (/Screen/wisp/) doesn't match the
local path (/wisp/) — but mdbook's site-url is configured for
production, so on local you'll see the cross-links pointing at
/Screen/wisp/... which won't resolve. For local cross-book
nav, use the book's own TOC; for production-shape verification,
deploy preview or just site + open target/book/.
Stop with just dev-remote-book-stop (tears down the Tailscale
routes; leave mdbook serve running in their terminals and Ctrl-C
when you're done).
mdbook serve rebuilds + reloads on changes under the book's
src/ tree AND book.toml. Changes to _docs/shared/ files
also trigger a rebuild — both books' src/ tree includes a
{{shared}} tag that pulls in those files, and mdbook's watch
covers them transitively. Changes to the preprocessor source
(tools/mdbook-preprocessor-cross/src/lib.rs) do NOT — you have
to Ctrl-C and re-run just dev-book so preprocessor-build
recompiles.