Chapter 01 · The network plane
Tailscale & WireGuard
Everything else in this guide depends on one property: your machines are reachable by you and by nobody else. Not "protected by a strong password" — actually unreachable. This chapter explains how that is achieved, because if you understand it, the rest of the configuration stops being cargo cult.
WireGuard, honestly described
WireGuard is a VPN protocol small enough to read in an afternoon — roughly 4,000 lines against OpenVPN's hundreds of thousands. That size is a security argument in itself: less code, fewer places for bugs to hide.
Three ideas matter for our purposes.
Cryptokey routing. In WireGuard, a public key is the identity of a peer, and each peer is associated with the set of IP addresses it is allowed to use. When a packet arrives, WireGuard decrypts it, checks which key signed it, and confirms that key is permitted to send from that source address. A packet claiming to be from your MacBook but sealed with a different key is discarded before anything else looks at it. Source-address spoofing, the foundation of a lot of network attacks, simply has no purchase.
It is UDP, and it is silent. WireGuard has no connection in the TCP sense. There is no listening socket that responds to strangers. An unauthenticated packet gets no reply at all — not a rejection, not a reset, nothing. To a port scanner, a WireGuard endpoint is indistinguishable from an unused IP address. This is why "invisible" is literal rather than marketing.
Roaming is native. Because there is no connection state tied to an IP address, a peer that changes network — Wi-Fi to LTE, one café to another — just starts sending from the new address. The far end notices the authenticated packets arriving from somewhere new and updates its endpoint. Nothing is renegotiated. This is the property that makes a phone a viable workstation.
Raw WireGuard requires you to hand-place every peer's public key and endpoint in a config file, on every device. For two machines that is fine. For four machines it is twelve config entries you must keep in sync by hand, and it breaks the moment an IP changes. That key-distribution problem — not the crypto — is what Tailscale solves.
What Tailscale adds
Tailscale is WireGuard plus a coordination server plus a great deal of unglamorous NAT engineering. The critical architectural fact, and the one to check when evaluating any such service:
Private keys are generated on each device and never leave it. The coordination server exchanges public keys and policy. Data packets flow directly between your devices, encrypted end-to-end. Even relayed traffic (see DERP below) is opaque to the relay.
The address range
Tailscale assigns each device a stable address from 100.64.0.0/10, the
carrier-grade NAT range reserved by RFC 6598. It is chosen precisely because it will never
collide with your home 192.168.x.x or an office 10.x.x.x. That address
follows the device everywhere — it is the closest thing you get to a permanent name for a
machine that moves.
How two devices behind NAT find each other
Your phone is behind a carrier NAT you have zero control over. Your MacBook is behind a home
router. Neither has a reachable public port. They connect anyway, and understanding how
explains the latency numbers you will see in tailscale ping.
- Cold startNeither device has a reachable public port. Nothing on the open internet can initiate a connection to either of them — and that is the point.
- 1 · RegistrationEach device sends its public key and a list of candidate endpoints to the coordination server. Private keys were generated on-device and never move.
- 2 · Relay firstTraffic starts flowing immediately through the nearest DERP relay. The relay forwards sealed packets it has no key to open. Slow, but working — right now.
- 3 · Hole punchingBoth peers fire outbound UDP probes at each other's candidate endpoints at the same time. Each outbound packet opens a return hole in its own NAT.
- 4 · Upgraded, silentlyThe probes met. The session moves to a direct encrypted path with no interruption — you never saw a reconnect. ~620 ms became ~48 ms.
- 4 · No upgrade — and that is fineThe firewall drops outbound UDP, so the probes never meet. The connection stays on DERP: identical encryption, more latency. This is what a permanently relayed path looks like.
- 4 · Direct across the roomBoth peers are on one subnet, so the direct path is found almost instantly. Your packets never leave the building even though the tailnet spans the internet.
Seeing it yourself
tailscale ping tells you which path is in use, and it is the first thing to run when
a connection feels sluggish:
tailscale ping iphone
# pong from iphone (100.126.245.94) via DERP(dbi) in 625ms
# pong from iphone (100.126.245.94) via 192.168.1.222:41641 in 50ms
Two lines, two paths. The first went through a relay in Dubai; the second went straight across the LAN once hole punching succeeded. Persistent DERP-only paths mean something is blocking UDP — a restrictive corporate firewall, or a router with symmetric NAT.
DERP relays forward encrypted packets they cannot read. Falling back to relay costs you latency, not confidentiality. Do not disable it to "force security" — you will simply lose connectivity on restrictive networks.
Creating the tailnet
Everything above is what the network does. This is how you get one. It is step one of the build order for a reason: nothing else in this guide has an address to bind to until the tailnet exists and every device has joined it.
1 · The account
Sign up at login.tailscale.com using an identity provider you already secure properly
— Google, Microsoft, GitHub, Apple or Okta. Tailscale has no password of its own, which has a
consequence worth stating plainly: the account that owns your tailnet is exactly as strong
as the SSO account behind it. Put a hardware security key, or at minimum a TOTP app, on
that account before you enroll a single device.
Anyone who can log in as you can place a new device inside the tailnet, and a device inside the tailnet is past the strongest control in this design. SMS second factors are not adequate here. This is also the argument for device approval and tailnet lock below — they are what make stolen SSO credentials, on their own, insufficient.
2 · Install on each device
Sign in with the same account everywhere. The install differs per platform, and macOS is the one that catches people:
| Device | How |
|---|---|
| VPS · Ubuntu laptop |
curl -fsSL https://tailscale.com/install.sh | sh, then
sudo tailscale up. Installs a real system daemon, so the tailnet is up at boot
without anyone logging in. Details in chapter 08 and
chapter 09.
|
| MacBook | Three different builds exist and they are not interchangeable — which one you pick decides whether the tailnet is up before login and whether Tailscale SSH works at all. Chapter 05 covers the choice. Short version: the App Store app is the right default here, because the Mac uses OpenSSH rather than Tailscale SSH. |
| iPhone | App Store, sign in, and leave connect-on-demand enabled so the tunnel is up before you open a terminal. Details in chapter 06. |
curl … | sh, since this guide uses itPiping a downloaded script straight into a shell sits oddly in a guide about not trusting things, so it is worth being explicit rather than quiet. What you are trusting is Tailscale — their domain, their TLS certificate, their build pipeline. You are about to let that same company into your private network, so this is not a new party in your trust model. What it does skip is a signature you could verify independently of TLS, and any chance to read what you are about to run as root.
Two ways to keep the convenience and lose the blind spot:
# Read it first — the whole point is that you can
curl -fsSL https://tailscale.com/install.sh -o install.sh
less install.sh
sh install.sh
# Or skip the script and use the signed apt repository,
# which the script itself is mostly a wrapper around.
# See tailscale.com/download/linux for the current keyring steps.
Apply the same judgement to the Herdr installer in later chapters, and apply it harder: Herdr is a young project, and the trust you extend to a one-line installer should scale with how much you know about who is on the other end.
Then confirm the tailnet actually formed, from any machine:
tailscale status # every device listed, and whether each is online
tailscale ip -4 # this machine's tailnet address — chapter 05 needs the Mac's
If you enable device approval before enrolling (recommended, and covered below), each machine joins in a pending state and reaches nothing until you approve it in the admin console. A device that installed cleanly, signed in successfully and still cannot ping anything is almost always sitting unapproved.
With every device online, the remaining work is policy rather than installation: an access control list that is default-deny, and the three tailnet settings that decide who can add a device at all. Both are below.
Access control: the single most valuable setting
By default, a tailnet is permissive: every device can reach every port on every other device. For a two-device personal tailnet that is a defensible starting point. It stops being defensible the moment you have a VPS with a database on it, or you share a node with someone.
The policy file is the control. Default-deny, then grant precisely what you need:
A default-deny ACL is hard to trust on faith, because a correct one looks exactly like a
broken one until something tries to get through. The
sandbox lets you toggle these grants and fire a
connection at them: turn tag:laptop → tag:server : 22 off and the probe stops
at rung 3, in the tailnet, before the far machine hears anything about it.
{
"tagOwners": {
"tag:laptop": ["autogroup:admin"],
"tag:server": ["autogroup:admin"]
},
"acls": [
// Your devices may reach SSH and Mosh on the tagged machines. Nothing else.
{
"action": "accept",
"src": ["autogroup:member"],
"dst": [
"tag:server:22", "tag:laptop:22",
"tag:server:60000-61000", "tag:laptop:60000-61000"
]
}
],
"ssh": [
// Tailscale SSH: re-authenticate through the browser every 12h.
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["tag:server", "tag:laptop"],
"users": ["autogroup:nonroot"],
"checkPeriod": "12h"
}
]
}
Anything not matched by an accept rule is denied. With the above, a compromised
phone can reach SSH and Mosh and nothing else — not Redis on 6379, not Postgres on 5432, not an
unauthenticated dev server on 8000. This is enforced in the tailnet itself, above the host,
so it holds even if a machine is misconfigured.
60000-61000 is the range mosh-server picks a UDP port from
(chapter 03). A policy that grants only :22 lets
SSH in — so the login works, and it looks like everything is fine — and then silently
discards every Mosh datagram. You get a session that authenticates and then hangs, which sends
people debugging mosh-server for an hour. The packets are being dropped in the
tailnet, before the host is ever asked.
Rules with no proto field cover TCP, UDP and ICMP together, so one entry per range
is enough. If you never use Mosh, leave the range out deliberately rather than by accident.
Apply the tags to the machines
tagOwners above only declares that these tags may exist and who may apply them.
Nothing is tagged yet. Until each machine actually advertises its tag, it matches no rule in the
policy — and a default-deny policy means nothing works at all. This is the step people
skip, and the symptom is a tailnet where every device is online and none of them can reach each
other.
# On the VPS
sudo tailscale up --advertise-tags=tag:server
# On the Ubuntu laptop and the MacBook
sudo tailscale up --advertise-tags=tag:laptop
# Confirm — the tag should appear next to the machine
tailscale status
The phone stays untagged. It is the src in every rule above, and
autogroup:member already covers it.
A tagged device is owned by the tag, not by your user account. Three consequences worth knowing before you type the command:
- Re-running
tailscale upwith a new tag requires re-authenticating that device. - Tagged devices do not expire. Key expiry, which the tailnet-hardening section below recommends leaving on, does not apply to them — so the tailnet device list is the only place a retired tagged machine gets removed.
autogroup:selfno longer matches them, which is why thesshblock above targets the tags directly. A rule written againstautogroup:selfwill silently match nothing once you tag a machine.
- Target: port 22Your phone is about to open an SSH session to the MacBook. Nothing has been sent yet.
- Target: port 6379Something on your phone — an app, a script, malware — tries to reach Redis on the MacBook.
- Target: port 5432Something tries to reach Postgres directly, with no tunnel and no password prompt yet.
- Target: port 8000A dev server you started with
--host 0.0.0.0and forgot about. No auth on it at all. - 1 · Packet sentThe connection attempt enters the tailnet. It has not reached the MacBook yet — it has not reached any host yet.
- 2 · Rule matchedOne rule says
src: autogroup:member → dst: tag:laptop:22. This packet fits it, so the verdict is accept. - 2 · Nothing matchedNo
acceptrule mentions this port. There is no "deny" rule to write — anything unmatched is denied, and that is the whole point of default-deny. - 3 · DeliveredThe packet is carried to the MacBook and
sshdanswers. Now the host's own auth takes over: keys, then your passphrase. - 3 · Discarded in the networkThe packet is dropped before any host sees it. The service is not asked to refuse the connection — it is never given the chance. That holds even if the service is bound to
0.0.0.0with no password.
0.0.0.0 by
accident is still unreachable if the tailnet refuses to carry the packet. The host
firewall is your second line, not your first. Note the failure mode on the blocked ports:
a timeout, not a refusal — the same silence a port scanner gets from a WireGuard endpoint.
Tailscale SSH, and when you can use it
Tailscale can terminate SSH itself, authenticating with tailnet identity instead of SSH keys.
No authorized_keys to distribute, no key rotation, and the check
action can force a browser re-auth every few hours. It is excellent — where it is available.
Tailscale SSH can act as a server on Linux and on macOS only when you run the
open-source tailscale/tailscaled CLI build. The standard macOS app —
the one from the App Store or the packaged download — cannot be a Tailscale
SSH server. So: use it on your VPS and Ubuntu laptop; use OpenSSH on the Mac.
# On the VPS / Ubuntu laptop (not the Mac app)
sudo tailscale up --ssh
# Verify what the daemon thinks it is doing
tailscale debug prefs | grep -i runssh
Hardening the tailnet itself
Three settings in the admin console, all worth the two minutes:
| Setting | What it does | Why |
|---|---|---|
| Key expiry | Node keys expire (default 180 days) and the device must re-authenticate. | A device you lose access to eventually falls off the tailnet by itself. Disable it only for unattended servers, and know that you have done so. |
| Device approval | New devices join in a pending state until you approve them. | Stolen account credentials alone are not enough to place a new machine inside your network. |
| Tailnet lock | Devices sign each other's keys; the coordination server can no longer insert a node on its own. | Removes the control plane from your trust model entirely. This is the strongest available setting and the correct answer to "what if Tailscale itself is compromised?" |
Enabling tailnet lock
Key expiry and device approval are toggles in the admin console. Tailnet lock is not — it is a set of signing keys held by your own devices, so it has to be set up from the command line, and the order matters.
The idea: you nominate some devices as trusted signers. From then on, a new node cannot join the tailnet until one of them signs it. The coordination server can still distribute keys, but it can no longer invent a node of its own — which is exactly the property the threat model in chapter 10 wants.
# 1. On EACH device you want as a signer, read its tailnet lock key
tailscale lock status
# Note the tlpub:... key it prints for this node.
# 2. On any one of them, initialise with the FULL list of signers
sudo tailscale lock init tlpub:aaa... tlpub:bbb...
# 3. Read the output carefully — the disablement secret is shown once.
# 4. From now on, a new device waits for a signature
tailscale lock status # shows nodes awaiting signing
sudo tailscale lock sign <node-key>
# 5. Audit what has been signed, and by whom
tailscale lock log
lock init
Nominate at least two signers. Signing needs a device that can run the
tailscale CLI, so your MacBook, VPS and Ubuntu laptop are candidates and the phone
may not be — check what tailscale lock status reports on each before deciding. If
your single signer is lost or wiped, you cannot add another device to your own tailnet, and the
only way out is disabling lock entirely.
Store the disablement secret like a recovery key. It is printed once, at init, and it is the escape hatch for exactly the situation above. It belongs in the same place as your FileVault and LUKS recovery keys — a password manager you can reach from a device that is not part of the tailnet.
Enrolling four machines is easier before signing is required than after. Do the whole build, confirm every device is online, then initialise lock — and treat the signing step as part of the onboarding runbook in chapter 11 from then on.
MagicDNS
With MagicDNS on, devices are reachable by name — macbook, or the fully qualified
macbook.tailnet-name.ts.net. Use names in your SSH config rather than
100.x addresses: names survive a device being removed and re-added, addresses do
not always.
Tailcat: the same data plane, without the control plane
Everything above is the control plane earning its keep — an account, a coordination server, device identities, ACLs, expiry. Tailcat is the other half on its own: WireGuard, DERP and the same NAT engineering described earlier in this chapter, with all of that stripped out. Its own README puts it as "like netcat, but over Tailscale's data plane, without Tailscale's control plane".
No account, no admin console, no tailnet, no root, no daemon. A server prints an address; whoever holds that address can connect. That is the whole model, and it is also the whole problem.
It carries a pre-shared key inside it, so anyone you send it to can connect. There is no
identity behind the connection, nothing in an audit log, and nothing to revoke. The README
is blunt about what a saved key costs you: "anyone you've ever shared that address with
can connect to any future server using that key". Ephemeral is the default and is the
right choice — the key lives in memory, and when the process exits the address is dead
forever. Reach for --key=NAME only with a reason, and understand that you have
just written an authorized_keys entry that never expires.
The one shape worth using
Serving a raw port hands the machine to the address. Serving SSH does not: the address gets a stranger as far as the door, and the key policy from chapter 02 decides whether it opens. Two locks, and the second one is the one you already trust.
# Install — one static binary, no daemon, no root
brew install tailcat # macOS
go install github.com/tailscale/tailcat/cmd/tailcat@latest
# On the machine being reached: ephemeral key, real SSH auth
tailcat serve ssh --ssh-authorized-keys=~/.ssh/authorized_keys
# Tighter — pin it to one client's public key
tailcat genkey --client --key=client-default # on the client, once
tailcat serve ssh --ssh-authorized-keys=~/.ssh/authorized_keys \
--allow=nodekey:cfb6bf...ddfd16
# From the client
tailcat ssh tc...
serve no-auth-ssh drops SSH authentication altogether — the address alone
becomes a shell. serve all exposes every port on the machine, and
serve exit-node exposes the network behind it, which is the blast radius
chapter 10 asks you to keep small. Separately,
--psk=false shortens the address by dropping the pre-shared key, and with it
post-quantum protection and the guarantee that a public DERP operator cannot join the
tunnel. None of the four belong on a machine you care about.
When to reach for it, and when not
| Situation | Use |
|---|---|
| Your own machines, every day, for years | The tailnet. Names, ACLs, expiry, device approval, offboarding — everything above this section. |
| Handing one port to one person for one afternoon | Tailcat. They join nothing and install nothing permanent, and the address dies with the process. |
| A box where you cannot install a daemon or get root | Tailcat. A single binary that needs neither. |
| Reaching a machine that has fallen off the tailnet | Tailcat, as the proven second way in that chapter 12 keeps insisting on — but only if you arranged it before you needed it. |
This is deliberately absent from the build order in
chapter 11, and it should stay absent. The tailnet is the
thing you operate; this is the hatch you keep beside it. Tailcat is also young and says so
— the CLI flags, the Go API and the wire format may all change — so check
tailcat serve --help on your installed version rather than trusting the lines
above.
Verify before moving on
- The identity provider behind the Tailscale account has a hardware key or TOTP on it — not SMS.
- All devices appear in
tailscale statusand show as online. tailscale ping <device>succeeds, and you know whether you are direct or relayed.- MagicDNS is enabled and
ping macbookresolves. - An ACL policy is in place that is default-deny rather than allow-all.
- Each machine advertises its tag, and
tailscale statusshows the tag next to it. - The policy grants both
:22and:60000-61000, or you have decided not to use Mosh. - Key expiry is on for laptops and phones; deliberately considered for servers, and you know it does not apply to tagged devices.
- Device approval is enabled.
- Tailnet lock is enabled with at least two signing devices, and the disablement secret is stored off-device.
The commands in this guide change firewall and login settings, and can lock you out of a machine. Practise on something disposable first. Everything here is provided as is, with no warranty — you accept the risk of running it. Read the disclaimer.