When `*.ts.net` won't resolve: brew tailscaled and macOS split-DNS
I migrated my self-hosted Gitea to a tailnet-served hostname (git.sable-chinstrap.ts.net) and tried to update repo origins. SSH refused to start:
ssh: Could not resolve hostname git.sable-chinstrap.ts.net:
nodename nor servname provided, or not known
Same machine, same Tailscale account, peers visible in tailscale status. But no name resolution for anything under the tailnet suffix. The fix turned out to be a single missing nameserver line — but the diagnostic path is worth recording because most of it is misleading.
The first dead end: dig
The instinct is to ask DNS directly:
$ dig git.sable-chinstrap.ts.net
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN
;; SERVER: 1.1.1.1#53(1.1.1.1)
NXDOMAIN from 1.1.1.1. Reasonable on its face — .ts.net is a real public domain (Tailscale’s), and child names under it are private to each tailnet, so an upstream resolver legitimately doesn’t know them. The expectation is that your machine routes those queries to Tailscale’s local resolver at 100.100.100.100 instead of the upstream.
Quick check that the local resolver itself works:
$ dig @100.100.100.100 git.sable-chinstrap.ts.net +short
100.94.154.74
It does. So Tailscale is fine. macOS just isn’t sending those queries there.
Important distinction: dig and nslookup read /etc/resolv.conf directly and bypass macOS’s full resolver chain. They tell you what the upstream resolver returns, not what getaddrinfo() (used by SSH, browsers, every normal app) would do. For diagnosing macOS DNS, dig is the wrong tool unless you already know what server to ask. Use dscacheutil instead:
$ dscacheutil -q host -a name git.sable-chinstrap.ts.net
(empty)
That’s the real signal — the macOS resolver chain itself can’t resolve the name.
The actual diagnostic: scutil --dns
macOS layers its DNS configuration: a default resolver for everything, plus scoped resolvers for specific suffixes. scutil --dns shows the whole stack.
$ scutil --dns | grep -A 3 ts.net
search domain[0] : sable-chinstrap.ts.net
flags : Request A records, Request AAAA records
reach : 0x00000000 (Not Reachable)
A scoped entry exists for the tailnet suffix, but with reach: 0x00000000. macOS has been told there’s a resolver for *.ts.net, but it considers that resolver unreachable and falls back to the default — which is 1.1.1.1, which doesn’t know the tailnet.
Note that the entry has no nameserver listed — only a search domain. That detail mattered later, but I missed it on first read.
Things that didn’t help
Standard “kick the resolver” moves, in order:
tailscale set --accept-dns=false
tailscale set --accept-dns=true
sudo launchctl kickstart -k system/homebrew.mxcl.tailscale
sudo killall -HUP mDNSResponder
sudo dscacheutil -flushcache
None changed scutil’s output. The scoped entry stayed exactly as before — search domain set, no nameserver, Not Reachable.
That stability is itself the clue. If the entry were being torn down and rebuilt by a daemon restart, you’d expect something to change. It didn’t, because nothing had ever set the nameserver field in the first place. Whatever wrote that entry only ever wrote half of it.
What’s actually in /etc/resolver/
macOS has a second mechanism for per-suffix DNS: drop a file named after the suffix into /etc/resolver/, with nameserver lines pointing at the resolver to use for that suffix. It’s the standard way to make the resolver chain split-route by domain.
Check what tailscaled installed:
$ ls /etc/resolver/
search.tailscale
$ cat /etc/resolver/search.tailscale
# Added by tailscaled
search sable-chinstrap.ts.net
A search directive — append this suffix to bare hostnames during lookup — and nothing else. No nameserver line. So macOS knows that git should expand to git.sable-chinstrap.ts.net, but has no instruction about where to send queries for that suffix. They go to the default resolver, which says NXDOMAIN.
That’s the bug. The brew-installed tailscaled writes the search domain but not the resolver mapping.
Why it differs from the official app
There are two ways to run Tailscale on macOS:
- The Mac App Store / official
Tailscale.app, which uses Apple’s Network Extension framework. It registers a scoped resolver viaSystemConfiguration— that’s whatscutil --dnswould show with a real nameserver and a workingreach:flag. - The Homebrew CLI build, which runs
tailscaledas aLaunchDaemonand doesn’t ship the Network Extension. It can’t use the same private API. As a fallback, it writes to/etc/resolver/, but only the search-domain half — there’s no logic in the brew build to set up the nameserver routing.
Neither approach is wrong on its face; they’re just different integration points. But the brew build leaves the user one config line short of working split-DNS, and there’s no warning. Hostname lookups simply NXDOMAIN.
The fix
Add the missing nameserver mapping for *.ts.net:
sudo tee /etc/resolver/ts.net <<'EOF'
nameserver 100.100.100.100
EOF
Filename matters: /etc/resolver/<suffix> — macOS routes queries for *.<suffix> to the listed nameservers. No daemon restart needed; macOS picks it up on the next lookup.
$ dscacheutil -q host -a name git.sable-chinstrap.ts.net
name: git.sable-chinstrap.ts.net
ip_address: 100.94.154.74
$ ssh -T git@git.sable-chinstrap.ts.net
Hi there, dharmapurikar! You've successfully authenticated...
The file persists across reboots. New tailnet hosts resolve automatically because 100.100.100.100 knows about all of them — no per-host updates needed.
Takeaways
-
digdoes not test the macOS resolver chain. It reads/etc/resolv.confand queries that server directly, ignoringscutil-managed scoped resolvers and/etc/resolver/. For a real “would my apps resolve this?” answer, usedscacheutil -q host -a name <name>. -
scutil --dnsis the source of truth for macOS DNS state. Look for scoped resolvers — entries that mention adomainorsearch domainmatching your suffix. A scoped entry without a nameserver, or withreach:flagged Not Reachable, means the routing is broken even if the suffix is recognized. -
/etc/resolver/<suffix>is the documented macOS split-DNS mechanism. Filename is the suffix, contents arenameserver <ip>lines. No restart required. This is independent of whatever a daemon may have installed viaSystemConfiguration— and a useful escape hatch when a tool’s automatic setup is incomplete. -
Brew vs. App Store Tailscale on macOS are not interchangeable for DNS. The brew CLI build writes a search-domain-only entry to
/etc/resolver/and leaves the nameserver mapping unset. If you’re using brew tailscaled and tailnet hostnames don’t resolve, expect to add/etc/resolver/ts.netyourself. -
A failing resolver toggle that changes nothing in
scutilis a useful negative result. Iftailscale set --accept-dns=false/trueand a daemon restart leave the configuration byte-identical, the daemon isn’t writing what you expected. Look at what’s actually on disk (/etc/resolver/,scutil --dns) rather than re-running the toggle.