Skip to content
NETWORKING

Tailscale subnet routers and ACLs in anger

The subnet-router pattern I used to replace pfSense, the ACL policy I actually run, and the three weekends of yak-shaving I skipped.

published
author
read
4 min (~894 words)
From below of fiber optic switch with sockets and connected rubber cables on blurred background
0%

I ran pfSense as my home router from 2018 to 2024. OpenVPN server on top of it. It worked. It was also a weekend-every-few-months of pkg updates, certificate rotations, and "why is IPv6 broken this week" that I had silently started dreading. In the summer of 2024 I replaced my VPN with Tailscale 1.70 in a single afternoon. My services went from "VPN-tunnel-then-bookmark-an-IP" to "MagicDNS name that just resolves." I have not touched the VPN layer since.

This essay is the subnet-router pattern I use, the ACL JSON policy file I actually run, and the three weekends of yak-shaving that I did not need to do. For the full stack this slots into, see the rig.

The problem Tailscale solves

Home services live on a private LAN. To reach them from outside the house, the classical options are: (a) port-forward each one through the ISP router and hope nothing gets popped, (b) run a VPN server on the edge, or (c) run something like Cloudflare Tunnel and put every service behind Cloudflare's auth. None of those are bad in isolation. All of them are work I had run out of appetite for.

Tailscale uses WireGuard as the cryptographic layer but adds three things that make the home-operator story clean:

  • No port forwards. The ISP box doesn't know my services exist. Tailscale punches through NAT using DERP relays as a fallback when direct holes fail.
  • MagicDNS. Every node on the tailnet gets a DNS name of the form {hostname}.tail-XXXX.ts.net. Tailscale's coordination server resolves those names from every signed-in client. I never type an IP anymore.
  • ACLs in a JSON file. I'll show the real one below.

The subnet-router pattern

Installing the Tailscale client on every device works for phones and laptops. It does not work for smart plugs, printers, the UPS web UI, my partner's Windows gaming PC, or the TV. For those I run a single "subnet router" node inside the tailnet that announces my LAN as a route.

On my Proxmox host, a 256 MB LXC container does the job:

# Inside the LXC
curl -fsSL https://tailscale.com/install.sh | sh
echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf
echo 'net.ipv6.conf.all.forwarding = 1' >> /etc/sysctl.conf
sysctl -p
tailscale up \
  --advertise-routes=192.168.10.0/24,192.168.20.0/24 \
  --hostname=rack-router \
  --accept-dns=true \
  --advertise-exit-node

Two subnets: a "trust" LAN for the rack and my own devices, and an IoT VLAN for the smart-home Zigbee/Z-Wave/cheap-WiFi devices. The router LXC advertises both. On the Tailscale admin console I click "Approve" once per route, and every client on the tailnet can now reach every LAN device without installing anything on those devices.

The --advertise-exit-node flag is the bonus: any tailnet client can now route all of its traffic through my home IP. Useful on cafe Wi-Fi, occasionally useful for region-locked streaming.

MagicDNS + a tiny trick with CNAMEs

Out of the box, services behind the subnet router still get reached by IP (http://192.168.10.42:8096 for Jellyfin). That's not what I wanted. I wanted jellyfin to resolve from every device.

Two-line fix in the Tailscale admin console under DNS: add "search domains" home, and add a split-DNS nameserver for the home suffix pointing at a tiny AdGuard Home LXC I run locally. AdGuard Home has its own DNS records: jellyfin.home resolves to 192.168.10.42, and so on. Now from my laptop anywhere in the world: http://jellyfin.home/ just works. From my phone: same. From my partner's Mac: same.

The ACL policy file I actually run

This is the real one, with identifiers replaced. It lives in the Tailscale admin console and gets versioned via their Git integration.

{
  "groups": {
    "group:operators": ["[email protected]"],
    "group:household":  ["[email protected]", "[email protected]"],
    "group:guests":     ["[email protected]"]
  },
  "tagOwners": {
    "tag:rack":     ["group:operators"],
    "tag:iot":      ["group:operators"],
    "tag:services": ["group:operators"]
  },
  "acls": [
    { "action": "accept",
      "src":    ["group:operators"],
      "dst":    ["*:*"] },
    { "action": "accept",
      "src":    ["group:household"],
      "dst":    ["tag:services:443", "tag:services:8096"] },
    { "action": "accept",
      "src":    ["group:guests"],
      "dst":    ["tag:services:8096"] }
  ],
  "ssh": [
    { "action": "accept",
      "src":    ["group:operators"],
      "dst":    ["tag:rack"],
      "users":  ["theo", "root"] }
  ]
}

Decoded: I have full access. My partner's devices can reach any service on HTTPS or Jellyfin's port. My in-law gets Jellyfin only. SSH into rack nodes is gated to my user account and requires Tailscale device auth. No 0.0.0.0/0 allow * rules; every rule is specific.

What Tailscale is not

Honest scope. Tailscale is not a replacement for a firewall between your LAN and the internet. That's still your ISP router's job (or a proper edge device if you want one). Tailscale is not a good fit for enterprises running hundreds of thousands of nodes — the free tier caps at 100 devices, and the pricing past that gets expensive fast. For that scale look at Headscale, the open-source coordination server.

For a household tailnet with <20 devices and <5 users: Tailscale's free tier is complete.

What I stopped doing

  • Renewing OpenVPN certs twice a year.
  • Port-forwarding 1194/UDP and worrying about my IP leaking into Shodan.
  • Carrying three different VPN profiles between work, home, and travel.
  • Writing 192.168.10.42 into my phone's Safari bar in a moving car.

All of that disappeared on one afternoon. That's the trade: fifteen years of self-hosted VPN habit for an afternoon of Tailscale config. The math only goes one way.

Next: Docker Compose to Podman quadlets without downtime — the container-runtime swap I'm doing one service at a time. Full stack context: the rig.

# issues (0)

$ no issues filed yet. be the first — the form is below.

# add an issue

Comments are moderated. Links are capped. Be kind, be specific.