Shared dev machine — accounts, dotfiles, remote access
Internal ops doc for the Mac mini (M4, macOS 26, Apple silicon) that Sandon and Silver both work on. Not contributor-facing — external contributors set up their own machines via setup-guides/LOCAL_DEV_SETUP.md.
Two accounts, both admins with FileVault secure tokens: sandonjurowski and
silverzhao. Same tools, same shell, separate homes and separate clones.
Dotfiles: /Users/Shared/dotfiles
A git repo, group admin + g+w + setgid so either account can commit to it.
It lives in /Users/Shared rather than a home directory because /Users/<name>
is drwxr-x--- — no other account can read it.
Each account symlinks into the repo, so a change made once takes effect for both.
New account, one command
/Users/Shared/dotfiles/bootstrap.sh
Idempotent. Anything it would overwrite is moved to
~/.dotfiles-backup-<timestamp>/ first, never deleted. It refuses to run as
root. What it does:
- Symlinks
.zshrc,.zprofile,.zshenv,.gitconfig,.gitignore_global,.Brewfile - Prompts for name + email → writes
~/.gitconfig.local(untracked) - Seeds
~/.ssh/config; generates an ed25519 key if there isn't one brew bundle --globalfrom the shared Brewfile- Installs Node LTS via fnm
Then, per account, by hand: gh auth login; add the new SSH key at
github.com/settings/keys; VS Code → Settings Sync (own account, pulls
extensions + settings); sign into Claude Code with your own Anthropic account;
sudo xcodebuild -license accept before any iOS build.
Shared vs. per-user
| Shared (symlinked into the repo) | Per-user (never shared) |
|---|---|
.zshrc, .zprofile, .zshenv | .gitconfig.local — name + email |
.gitconfig — aliases, defaults | .zshrc.local — one-off exports |
.gitignore_global | .ssh/ — keys are personal, always |
.Brewfile | .zsh_history |
~/.claude/, ~/.claude.json — auth | |
gh token, Dashlane / Slack / VPN | |
fnm Node versions (~/.local/share/fnm) | |
Android AVDs (~/.android) | |
| Xcode simulator runtimes | |
.env / .env.local in every repo |
The rule for anything added to the repo: no file may hardcode a username or a
home directory. Use $HOME, and guard every path with a -d / -x test so
the config stays a no-op where a tool isn't installed. Both original bugs the
repo was created to fix were exactly this — a hardcoded
/Users/sandonjurowski/.local/bin, and EDITOR="code --wait" pointing at a
code shim that was never on PATH.
Repos are cloned per-account
Each account gets its own clones under its own ~/dev. Don't share a working
tree — two users on one git index, plus node_modules built under different
ownership, is a steady drip of permission errors. Disk is cheaper than the
debugging.
Each account also gets its own free Supabase sandbox — never the production
project — and its own .env files, filled in per
setup-guides/LOCAL_DEV_SETUP.md. Nobody
copies an .env from the other account; that's the whole point of
SECRETS_HANDLING.md.
Give the second account a port offset
Both apps default to fixed localhost ports — backend PORT=3001, frontend
next dev on 3000. If both accounts are logged in and both run dev servers,
they collide, and the failure mode is much worse than a clean error: the
backend dies with EADDRINUSE, but Next quietly auto-increments to the next
free port, which then no longer matches its own NEXT_PUBLIC_SITE_URL. Auth
redirects and cookies break in ways that look like an application bug.
Every port is env-driven, so pin one account to a different block. Using
+100 for the second account:
# apps/backend/.env
PORT=3101
FRONTEND_URL=http://localhost:3100
NEXT_PUBLIC_SITE_URL=http://localhost:3100
NEXT_PUBLIC_API_URL=http://localhost:3101
# apps/frontend/.env.local
NEXT_PUBLIC_API_URL=http://localhost:3101
NEXT_PUBLIC_SITE_URL=http://localhost:3100
Start the frontend with the matching flag — the port is resolved before
.env.local is read, so setting PORT there does nothing:
cd apps/frontend && npm run dev -- -p 3100
Then set Site URL to http://localhost:3100 in that account's own Supabase
sandbox (Authentication → URL Configuration). Miss this and email
confirmations and OAuth bounce back to port 3000 — i.e. into the other
account's dev server, if it happens to be running.
Homebrew on a shared machine
Homebrew is single-user by design. /opt/homebrew is owned by whoever
installed it, and roughly fifteen directories under it aren't group-writable,
so a second account's brew install fails partway through. Fix, once, from
either admin account:
sudo chgrp -R admin /opt/homebrew && sudo chmod -R g+w /opt/homebrew
A major brew update can occasionally recreate a directory with tighter
permissions. If brew install starts complaining that something isn't
writable, re-run that command — it's the same fix every time.
The Android SDK ($HOMEBREW_PREFIX/share/android-commandlinetools) is shared
out of the brew prefix and needs this too, since sdkmanager writes into it.
AVDs stay per-user under ~/.android.
Remote access
Two layers. Don't think of it as "a remote desktop app."
Enabling the services
Both live in System Settings → General → Sharing: turn on Remote Login and Screen Sharing, and give both users access on each.
Do this in the GUI. The CLI equivalent looks tempting but isn't worth it —
sudo systemsetup -setremotelogin on fails with "requires Full Disk Access
privileges" unless you grant FDA to the terminal app itself, which is a broad
grant to hand a terminal (and, in the VS Code integrated terminal, to hand all
of VS Code). Screen Sharing has no reliable CLI toggle on macOS 26 anyway, so
the GUI is one trip instead of two.
Both accounts are already in com.apple.access_ssh and
com.apple.access_screensharing, so no group work is needed.
Confirm it took:
netstat -an | grep LISTEN | grep -E '\.(22|5900)\s' # sshd, screen sharing
Verify over the LAN before adding Tailscale — ssh <user>@<host>.local
and vnc://<host>.local (Finder → Go → Connect to Server). If it works on the
LAN and not over Tailscale, you know which layer broke.
Leave the legacy VNC password OFF
Screen Sharing → Computer Settings has a "VNC viewers may control screen with password" checkbox. It looks like the obvious way to let someone in. Don't use it — it defeats the entire point of two accounts.
A client authenticating with that shared password gets the console session: literally whatever is on the physical display, including whoever is already logged in there. Per-user virtual sessions only happen when the client authenticates as a macOS user account. The two paths are mutually exclusive, and this is the wrong one. (It's also weaker on its own terms — a single shared secret, stored obfuscated rather than hashed.)
It's on if this file exists:
ls -l /Library/Preferences/com.apple.VNCSettings.txt # present ⇒ turn it off
Addressing and credentials
Credentials are just the macOS account — your own short username and password. There is no separate remote-access credential to issue, and nothing to copy between accounts.
Address by name, never by IP. The LAN address (10.0.0.x) is unreachable
from outside the network and moves on a DHCP lease.
| Where | Address |
|---|---|
| Same LAN | <LocalHostName>.local — scutil --get LocalHostName |
| Anywhere | the Tailscale MagicDNS name |
This mini's actual values (verified 2026-08-09 — sandonjurowski's Mac mini, the shared build machine):
| Kind | Value |
|---|---|
| MagicDNS name (anywhere) | sandons-mac-mini.tail7f5992.ts.net |
| Tailscale IP (anywhere, no-DNS) | 100.80.153.125 |
| Bonjour name (same LAN) | Sandons-Mac-mini.local |
The Tailscale IP is worth having as a fallback even though the rule above says "by name": unlike the LAN 10.0.0.x lease, a Tailscale 100.x address is stable per machine, so it keeps working when a laptop has Tailscale up but "Use Tailscale DNS settings" (MagicDNS) is off — the one case where the name won't resolve but the tunnel is fine. Ready-made per-user SSH aliases (each person adds this to their own ~/.ssh/config, with their own macOS username + key — the mini has both sandonjurowski and silverzhao):
Host mini
HostName sandons-mac-mini.tail7f5992.ts.net
User <your-mac-username>
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Host mini-ip # no-DNS fallback (MagicDNS off)
HostName 100.80.153.125
User <your-mac-username>
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Host mini-lan # Tailscale down, same network
HostName Sandons-Mac-mini.local
User <your-mac-username>
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
Then ssh mini (or mini-ip / mini-lan), and VS Code → Remote-SSH: Connect to Host → mini. The precondition every time: Tailscale must be connected on your laptop (the app merely being open is not enough — an open-but-disconnected app reports "Not Running" and falls back to the system DNS resolver, so nothing resolves), and the mini must be unlocked (see the FileVault note below).
Handing over a new account's password: not over chat. Use a password
manager share or say it out loud, and have them change it at first login via
System Settings → Users & Groups — not passwd — so FileVault and the secure
token stay in sync.
Better, for SSH: skip the password. Have them send their laptop's public key (public keys are safe to paste anywhere) and install it here:
sudo -u <account> sh -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys' <<< 'ssh-ed25519 AAAA… them@their-laptop'
sudo -u <account> chmod 600 /Users/<account>/.ssh/authorized_keys
This is the opposite direction from the key bootstrap.sh generates — that one
is this machine → GitHub. This one is their laptop → this machine. They'll
still need the account password for GUI login and sudo.
First connection is a chicken-and-egg — plan for it
Tailscale has to be installed on this machine by someone already on it, before a remote user can reach it from anywhere but the LAN. Nobody can remote in to set up the thing that lets them remote in.
So the order is: an existing account installs Tailscale here with Run
Unattended (it's system-level, so it covers every account — the new user
never installs Tailscale on the mini, only on their own laptop) → invite them
to the tailnet → they connect by MagicDNS name as themselves. If they happen to
be on the same LAN, they can start immediately with .local and skip ahead.
Network layer — Tailscale
No port forwarding, no dynamic DNS, stable hostname, works from anywhere. Everything below rides on it.
brew install --cask tailscale-app
Get the package right — this is the trap. There are three Tailscales for macOS and only one is correct here:
--cask tailscale-app | the one you want — standalone GUI, can run unattended |
brew install tailscale | formula: CLI + daemon only, no GUI |
| App Store build | sandboxed per-user; drops when nobody's logged in |
Then, in order:
- Sign in and add the machine to the tailnet.
- Tailscale menu → Settings → "Run Unattended." Without it the tunnel only exists while someone is logged in — so it's down at the login window and after a reboot, which is exactly when it's needed.
- Admin console → disable key expiry for this machine. Node keys expire after ~180 days by default. This is how a remote-access box dies silently six months later with no obvious cause.
- Invite the second user to the tailnet (the free plan covers 3 users) and install Tailscale on their laptop too.
- Use the MagicDNS name (
<host>.tailXXXX.ts.net) as the stable address for everything below.
Path 1 — VS Code Remote-SSH, where the work happens
Open this machine as a Remote-SSH host from your own laptop and you get your
own window, your own terminal, your own Claude Code session, with no video
latency because no pixels are being streamed. Editing, npm run check, git,
backend work: all better here than over screen sharing. Pair it with VS Code
Settings Sync and the editor looks identical local and remote.
Path 2 — Apple Screen Sharing, for GUI-only work
Xcode, iOS Simulator, Android emulator, browser testing.
Connect with your own credentials, not the other person's. macOS then gives you your own login session on a virtual display — it does not take over whoever is sitting at the machine, and both sessions run at the same time. On Apple silicon the Screen Sharing app's high-performance mode gives hardware H.264 and is genuinely good.
If Screen Sharing over WAN feels sluggish, Jump Desktop (Fluid protocol) is the upgrade worth paying for. Skip Chrome Remote Desktop — on macOS it only mirrors the console session and the permission grants are painful.
Two things that will bite you
FileVault is on, and it beats every remote-access tool. After a reboot or power loss the disk stays locked and no network service starts — SSH and Screen Sharing are both dead until someone unlocks at the physical login screen. For planned restarts:
sudo fdesetup authrestart # pre-authorizes the next boot
For unplanned ones, someone has to be physically present. Plan remote work around it.
A sleeping Mac is an unreachable Mac.
sudo pmset -a sleep 0 disablesleep 1 womp 1
Never put on the other account
Private SSH keys, ~/.claude.json and ~/.claude/ (Claude auth), gh tokens,
.env files, password-manager or VPN sessions. Each account authenticates as
itself — that's the whole point of two accounts. Same principle as
setup-guides/SECRETS_HANDLING.md.