A deterministic, headless Tailscale setup for macOS.
This project installs and runs:
tailscaledvia Homebrew- supervised by a system LaunchDaemon
- with explicit MagicDNS resolver integration under
/etc/resolver - with no GUI, no menu bar, and no logged-in user session required
The goal is simple:
make macOS behave like an always-on Tailscale node, reachable before login, with predictable DNS
This is not intended to teach first-time Tailscale setup from scratch.
You should already have:
- a Tailscale account
- an existing tailnet
- your tailnet domain
- MagicDNS enabled if you want the best experience
This project is about deterministic macOS node setup, not account onboarding.
- Headless
tailscaledoperation - LaunchDaemon supervision at boot
- Explicit resolver wiring for MagicDNS
- Automatic stale resolver cleanup
sudo -vupfront in all operational scripts- zsh-safe glob handling
- install, verify, repair, and uninstall workflows
- manual daemon fallback during install if
launchctl bootstrapmisbehaves
Homebrew
↓
tailscaled
↓
LaunchDaemon
↓
/etc/resolver/*
↓
macOS DNS stack
↓
MagicDNS resolution
.
├── .env.example
├── .gitignore
├── docs
│ ├── architecture.md
│ ├── dns-magicdns-notes.md
│ └── troubleshooting.md
├── fix-magicdns.sh
├── install.sh
├── LICENSE
├── README.md
├── templates
│ └── com.tailscale.tailscaled.plist.template
├── uninstall.sh
└── verify.sh
This value is critical.
The scripts wire macOS DNS using your actual tailnet domain, for example:
example-tailnet.ts.net
Find it from an existing authenticated node:
tailscale dns status --allOr check:
- Tailscale Admin Console → DNS
Do not guess this value.
git clone https://github.com/<your-user>/tailscale-headless-macos.git
cd tailscale-headless-macoscp .env.example .envEdit at least:
TS_HOSTNAME=example-mac
TAILNET_DOMAIN=example-tailnet.ts.netchmod +x install.sh verify.sh fix-magicdns.sh uninstall.sh./install.shThis will:
- install or relink Tailscale via Homebrew
- render and install the LaunchDaemon plist
- clean prior daemon state
- start
tailscaled - configure
/etc/resolver - optionally run
tailscale up
./verify.sh./fix-magicdns.sh./uninstall.shTS_HOSTNAME=example-mac
TAILNET_DOMAIN=example-tailnet.ts.netBREW_BIN=brew
TAILSCALED_SOCKET=/var/run/tailscaled.socket
TAILSCALED_WAIT_SECONDS=20
ACCEPT_ROUTES=true
ACCEPT_DNS=true
RUN_TAILSCALE_UP=true
TAILSCALE_EXTRA_FLAGS=
REMOVE_BREW_PACKAGE=falseThese normally do not need changing:
LAUNCHD_LABEL=com.tailscale.tailscaled
LAUNCHD_PLIST=/Library/LaunchDaemons/com.tailscale.tailscaled.plist
STATE_DIR=/var/lib/tailscale
STATE_FILE=/var/lib/tailscale/tailscaled.state
LOG_OUT=/var/log/tailscaled.log
LOG_ERR=/var/log/tailscaled.errinstall.sh is the main provisioning entrypoint.
It does the following:
- prompts for
sudoup front and keeps it alive - installs or relinks Homebrew Tailscale
- prepares state, log, and resolver directories
- renders the LaunchDaemon plist from the template
- cleans stale daemon state and old socket files
- tries to bootstrap the LaunchDaemon
- falls back to manual
tailscaledstart for the current session if bootstrap fails - writes the resolver files
- flushes macOS DNS cache
- optionally runs
tailscale up
If RUN_TAILSCALE_UP=true, authentication or confirmation may occur.
If you prefer to install first and authenticate later, set:
RUN_TAILSCALE_UP=falseThen run later:
sudo tailscale up --hostname=example-macverify.sh reports current state and does not attempt repair.
It checks:
tailscaleandtailscaledbinaries- daemon process presence
- LaunchDaemon visibility in
launchctl - local daemon socket presence
- Tailscale status and IP
- DNS resolver files
- stale
*.ts.netresolver leftovers - macOS DNS output
- direct DNS lookups
- recent daemon logs
It is designed to distinguish between states such as:
- package installed but daemon not running
- daemon running but resolver files missing
- resolver files present but stale files also exist
- fully working node
fix-magicdns.sh only repairs the resolver side.
It will:
- ensure
/etc/resolverexists - remove stale tailnet resolver files
- remove and rewrite the managed resolver files
- flush DNS cache
- show final resolver contents
Managed files are:
/etc/resolver/ts.net
/etc/resolver/<your-tailnet-domain>
/etc/resolver/search.tailscale
uninstall.sh removes the headless setup and runtime state.
It will:
- prompt for
sudoup front - bring Tailscale down if possible
- stop and remove the LaunchDaemon
- kill daemon processes
- remove socket, state, and log files
- remove resolver files
- flush DNS caches
It only removes the Homebrew package if this is set:
REMOVE_BREW_PACKAGE=trueIf that is left false, operational state is removed but the Homebrew package remains installed.
This project explicitly manages resolver files instead of assuming macOS will always behave the way the GUI client does.
The managed resolver set is:
/etc/resolver/ts.net
/etc/resolver/<tailnet>.ts.net
/etc/resolver/search.tailscale
This is intentional.
It helps make CLI and system resolution behaviour more deterministic on headless macOS nodes.
The scripts also clean stale files matching:
/etc/resolver/*.ts.net
except for the active domain where appropriate.
This prevents old tailnet domains from lingering and causing confusing DNS results.
All resolver cleanup is zsh-safe and handles no-match conditions properly.
Bootstrap failed: 5: Input/output error
This is a macOS/launchd nuisance, not necessarily a broken plist.
Current script behaviour:
- clean old launchd state
- attempt bootstrap
- attempt recovery
- fall back to manual
tailscaledstart for the current session if needed
That means install can still complete and networking can still work even if launchd is temporarily difficult.
Immediately after install or authentication, you may briefly see stale hostname or peer metadata.
That is normal.
Re-run:
tailscale statusafter a few seconds.
cp .env.example .env
chmod +x install.sh verify.sh fix-magicdns.sh uninstall.sh
./install.sh
./verify.sh./fix-magicdns.sh
./verify.sh./uninstall.shSet:
REMOVE_BREW_PACKAGE=trueThen run:
./uninstall.shHealthy output generally includes lines like:
[OK] tailscaled process detected
[OK] tailscaled socket present
[OK] ts.net resolver present
[OK] tailnet resolver present
[OK] search.tailscale resolver present
[OK] no stale tailnet resolver files detected
Warnings help identify partial states, for example:
- binary present but service stopped
- resolver missing
- stale tailnet resolver file detected
- socket missing
- deterministic over magical
- system-level over user-session dependence
- explicit DNS wiring
- fail-fast where appropriate
- repairable state
- clear operational separation between install, verify, repair, and uninstall
Intended for:
- macOS Intel
- macOS Apple Silicon
- Homebrew-based installs
MIT
Paul Cee / MrCee
This project turns macOS into:
a headless, deterministic, always-on Tailscale node with explicit DNS wiring
No GUI. No drift. No guesswork.