Mastering Tailscale ACLs and Subnet Routing

Introduction

I recently decided to burn down my traditional VPN setup. OpenVPN was clunky, WireGuard was fast but hard to manage at scale, and I wanted something that felt more like “magic.”

Enter Tailscale.

But simply installing Tailscale on my laptop wasn’t enough. I needed my entire home lab infrastructure—Proxmox LXC containers, Docker swarms, and random smart devices—to be accessible from anywhere, without installing the Tailscale client on every single toaster.

This is the guide I wish I had read. It covers Subnet Routing, ACLs (Access Control Lists), and the deep Linux routing table hacks needed to make it work with Proxmox containers.

The Problem: The “Isolated Island”

My home lab sits on 192.168.10.0/24. When I’m at a coffee shop, I’m on some random subnet. I wanted to type ssh 192.168.10.25 and have it just work, as if I was sitting on my couch.

Tailscale’s Subnet Routers promise significantly this capability, but out of the box, they often hit a wall when dealing with complex setups like Proxmox LXC containers, which have their own networking quirks.

The Solution: A Programmable Network

1. The Subnet Router

First, I designated a node (in this case, an LXC container acting as my gateway, 10.42.0.1) to act as the bridge.

sudo tailscale up \
  --advertise-routes=10.42.0.0/24,192.168.10.0/24 \
  --advertise-tags=tag:gateway

This tells the Tailnet: “Hey, if anyone wants to reach 192.168.10.x, send the packets to me.”

2. The ACLs (The “Bouncer”)

By default, Tailscale is “allow all.” That’s bad security practice. I locked it down using ACLs. The magic here uses Tags and Auto-Approvers.

{
  "tagOwners": {
    "tag:gateway": ["[email protected]"],
    "tag:server": ["[email protected]"]
  },
  "acls": [
    // Allow me to access everything
    { "action": "accept", "src": ["[email protected]"], "dst": ["*:*"] },
    // Allow gateway to route traffic
    { "action": "accept", "src": ["tag:gateway"], "dst": ["*:*"] }
  ],
  "autoApprovers": {
    "routes": {
      "192.168.10.0/24": ["tag:gateway"],
      "10.42.0.0/24": ["tag:gateway"]
    }
  }
}

The autoApprovers section is critical. It prevents the annoying manual approval step in the Tailscale admin console every time the router restarts.

3. The “Deep Magic”: Linux Routing Tables

This is where most people get stuck. If you’re running LXC containers (which share the host kernel), they often ignore the subnet router because the return path is confused. The packet comes in via Tailscale, but tries to leave via the default gateway, which drops it.

We need Source Based Routing.

I created a custom routing table script (/etc/network/if-up.d/tailscale-routes):

#!/bin/bash
# Wait for Tailscale interface
sleep 5

# Add routes to a specific table (52)
ip route add 192.168.10.0/24 dev vmbr0 src 192.168.10.10 table 52 2>/dev/null || true

# Rule: If traffic comes from Tailscale, use Table 52
ip rule add iif tailscale0 lookup 52 prio 100 2>/dev/null || true

This ensures that traffic entering via Tailscale returns via Tailscale, preventing asymmetric routing drops.

Conclusion

With this setup, my network is no longer a collection of isolated servers. It’s a flat, global mesh. I can access my NAS (192.168.10.10) from a hotel room in Tokyo purely by its internal IP. No port forwarding, no firewall holes.

It feels like the future.