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.

Where WireGuard stops

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:

The coordination server cannot read your traffic

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.

Network
Coordination server public keys + candidate endpoints iPhone 100.126.245.94 carrier-grade NAT no inbound port same Wi-Fi 192.168.1.99 MacBook Pro 100.117.202.65 home router NAT no port forwarding corporate firewall UDP egress blocked same Wi-Fi 192.168.1.222 no path between them yet 1 1 public keys only — never private keys DERP relay encrypted blobs · cannot decrypt 2 works instantly · ~620 ms round trip stays relayed · secure, just slower 3 simultaneous outbound probes punch both NATs 4 direct · encrypted end to end · ~48 ms direct across the LAN · ~2 ms UDP dropped — no direct path
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
Connectivity first, speed second. Tailscale does not make you wait for a direct path. It relays immediately through DERP so the connection works at once, attempts hole punching in the background, and silently upgrades when it succeeds. You may notice the first packet of a session being slow and everything after it being fast — that is this upgrade happening live. Switch the network above to see why some connections never upgrade.

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.

A relayed path is secure, just slower

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.

Your identity provider is the root of trust

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:

DeviceHow
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.
On curl … | sh, since this guide uses it

Piping 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
Approve the devices if approval is already on

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:

Watch a policy actually deny something

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.

Tailscale admin console → Access controls
{
  "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.

Grant the Mosh port range, or Mosh will never connect

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.

Tagging transfers ownership away from you

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 up with 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:self no longer matches them, which is why the ssh block above targets the tags directly. A rule written against autogroup:self will silently match nothing once you tag a machine.
Phone dials
iPhone autogroup:member ACL check per packet, in tailnet :22 sshd :6379 redis :5432 postgres :8000 dev server SYN rule matched ACCEPT tag:laptop:22 no rule matched DEFAULT DENY packet discarded here sshd sees the connection never leaves the tailnet no packet ever arrives no packet ever arrives no packet ever arrives
  1. Target: port 22Your phone is about to open an SSH session to the MacBook. Nothing has been sent yet.
  2. Target: port 6379Something on your phone — an app, a script, malware — tries to reach Redis on the MacBook.
  3. Target: port 5432Something tries to reach Postgres directly, with no tunnel and no password prompt yet.
  4. Target: port 8000A dev server you started with --host 0.0.0.0 and forgot about. No auth on it at all.
  5. 1 · Packet sentThe connection attempt enters the tailnet. It has not reached the MacBook yet — it has not reached any host yet.
  6. 2 · Rule matchedOne rule says src: autogroup:member → dst: tag:laptop:22. This packet fits it, so the verdict is accept.
  7. 2 · Nothing matchedNo accept rule mentions this port. There is no "deny" rule to write — anything unmatched is denied, and that is the whole point of default-deny.
  8. 3 · DeliveredThe packet is carried to the MacBook and sshd answers. Now the host's own auth takes over: keys, then your passphrase.
  9. 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.0 with no password.
This is why ACLs beat host firewalls. A service bound to 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.

Server-side support is limited

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:

SettingWhat it doesWhy
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
Two things to get right before you run 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.

Turn it on after every device has joined

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.

The address is a bearer capability

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...
Four services that remove the second lock

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

SituationUse
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.
An escape hatch, not a step in the build

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 status and show as online.
  • tailscale ping <device> succeeds, and you know whether you are direct or relayed.
  • MagicDNS is enabled and ping macbook resolves.
  • An ACL policy is in place that is default-deny rather than allow-all.
  • Each machine advertises its tag, and tailscale status shows the tag next to it.
  • The policy grants both :22 and :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.