Skip to content

MrCee/tailscale-headless-macos

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tailscale-headless-macos

Platform Arch Runtime Supervisor Mode DNS Goal License


What this is

A deterministic, headless Tailscale setup for macOS.

This project installs and runs:

  • tailscaled via 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


What this is not

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.


Features

  • Headless tailscaled operation
  • LaunchDaemon supervision at boot
  • Explicit resolver wiring for MagicDNS
  • Automatic stale resolver cleanup
  • sudo -v upfront in all operational scripts
  • zsh-safe glob handling
  • install, verify, repair, and uninstall workflows
  • manual daemon fallback during install if launchctl bootstrap misbehaves

Architecture

Homebrew
   ↓
tailscaled
   ↓
LaunchDaemon
   ↓
/etc/resolver/*
   ↓
macOS DNS stack
   ↓
MagicDNS resolution

Project structure

.
├── .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

Required: TAILNET_DOMAIN

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 --all

Or check:

  • Tailscale Admin Console → DNS

Do not guess this value.


Quick start

1. Clone the repo

git clone https://github.com/<your-user>/tailscale-headless-macos.git
cd tailscale-headless-macos

2. Create .env

cp .env.example .env

Edit at least:

TS_HOSTNAME=example-mac
TAILNET_DOMAIN=example-tailnet.ts.net

3. Make scripts executable

chmod +x install.sh verify.sh fix-magicdns.sh uninstall.sh

4. Install

./install.sh

This 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

5. Verify

./verify.sh

6. Repair MagicDNS if needed

./fix-magicdns.sh

7. Uninstall

./uninstall.sh

Environment variables

Required

TS_HOSTNAME=example-mac
TAILNET_DOMAIN=example-tailnet.ts.net

Common optional values

BREW_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=false

Internal defaults

These 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.err

Script behaviour

install.sh

install.sh is the main provisioning entrypoint.

It does the following:

  • prompts for sudo up 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 tailscaled start 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=false

Then run later:

sudo tailscale up --hostname=example-mac

verify.sh

verify.sh reports current state and does not attempt repair.

It checks:

  • tailscale and tailscaled binaries
  • daemon process presence
  • LaunchDaemon visibility in launchctl
  • local daemon socket presence
  • Tailscale status and IP
  • DNS resolver files
  • stale *.ts.net resolver 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

fix-magicdns.sh only repairs the resolver side.

It will:

  • ensure /etc/resolver exists
  • 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

uninstall.sh removes the headless setup and runtime state.

It will:

  • prompt for sudo up 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=true

If that is left false, operational state is removed but the Homebrew package remains installed.


MagicDNS model

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.

Stale resolver cleanup

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.


Known behaviour

launchctl bootstrap may fail with:

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 tailscaled start for the current session if needed

That means install can still complete and networking can still work even if launchd is temporarily difficult.

DNS convergence can take a few seconds

Immediately after install or authentication, you may briefly see stale hostname or peer metadata.

That is normal.

Re-run:

tailscale status

after a few seconds.


Typical workflow

Fresh install

cp .env.example .env
chmod +x install.sh verify.sh fix-magicdns.sh uninstall.sh
./install.sh
./verify.sh

Repair DNS only

./fix-magicdns.sh
./verify.sh

Full removal

./uninstall.sh

Full removal including Homebrew package

Set:

REMOVE_BREW_PACKAGE=true

Then run:

./uninstall.sh

Example verify outcomes

Healthy 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

Design principles

  • 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

Tested target

Intended for:

  • macOS Intel
  • macOS Apple Silicon
  • Homebrew-based installs

License

MIT


Author

Paul Cee / MrCee


Summary

This project turns macOS into:

a headless, deterministic, always-on Tailscale node with explicit DNS wiring

No GUI. No drift. No guesswork.

About

Deterministic headless Tailscale for macOS using Homebrew + tailscaled + LaunchDaemon, with pre-login startup and working MagicDNS.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages