operations

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:

  1. Symlinks .zshrc, .zprofile, .zshenv, .gitconfig, .gitignore_global, .Brewfile
  2. Prompts for name + email → writes ~/.gitconfig.local (untracked)
  3. Seeds ~/.ssh/config; generates an ed25519 key if there isn't one
  4. brew bundle --global from the shared Brewfile
  5. 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 Tailscalessh <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.

WhereAddress
Same LAN<LocalHostName>.localscutil --get LocalHostName
Anywherethe Tailscale MagicDNS name

This mini's actual values (verified 2026-08-09 — sandonjurowski's Mac mini, the shared build machine):

KindValue
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-appthe one you want — standalone GUI, can run unattended
brew install tailscaleformula: CLI + daemon only, no GUI
App Store buildsandboxed per-user; drops when nobody's logged in

Then, in order:

  1. Sign in and add the machine to the tailnet.
  2. 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.
  3. 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.
  4. Invite the second user to the tailnet (the free plan covers 3 users) and install Tailscale on their laptop too.
  5. 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.

SHARED DEV MACHINE — Docs | Graphene FM