Tailscale is one of those tools that quietly becomes load-bearing in your life. I use it at work, at home, and I’ve run it on machines I barely remember owning. So naturally the moment I wanted to connect to two tailnets simultaneously, on the same laptop, I was bummed to discover you basically can’t.
Logging out and back in works, sure. Doing that twelve times a day adds up.
Tailscale runs a single daemon, tailscaled, which creates one tunnel interface (tailscale0) and plants itself firmly in the host’s networking stack. It assumes it owns the networking environment. Connecting to a second tailnet means evicting the first one.
The core idea
The easiest way to get a completely separate networking stack is to, well, run VMs on the host. I use this approach on my Mac with Orbstack. Orbstack machines are almost-like full vms, but much faster and lighter. You can also use Lima or Colima to achieve the same. The basic idea is the host connects to one tailnet, the VM connects to another, and they never interact.
Host machine → tailnet A
VM → tailnet B
On Linux, you can get the exact same isolation without the hypervisor overhead using network namespaces. A network namespace gives a process its own interfaces, routing table, and firewall rules. Software running inside it can’t even tell it’s sharing a kernel with anything else. Containers work like this under the hood. But running a VPN client inside a container usually means handing it elevated privileges so it can create tunnel interfaces and mess with routing tables.
Another approach worth considering is a userspace networking stack via SOCKS5 proxy. Instead of creating actual tunnel interfaces, Tailscale can run with --tun=userspace-networking, routing all traffic through a SOCKS5 proxy on localhost. This doesn’t require elevated privileges and works well if your applications understand SOCKS5.
$ tailscaled --tun=userspace-networking --socks5-server=localhost:1890 --socket /home/bnjoroge/.local/share/tailscale1/tailscaled.sock --statedir /home/bnjoroge/.local/share/tailscale1/ &
$ tailscaled --tun=userspace-networking --socks5-server=localhost:1056 --socket /home/bnjoroge/.local/share/tailscale2/tailscaled.sock --statedir /home/bnjoroge/.local/share/tailscale2/ &
However, the SOCKS5 approach has a significant limitation: most applications don’t understand SOCKS5 natively. You’d need to configure each tool individually—SSH via ProxyCommand, curl with --socks5, browsers with manual proxy settings. For anything that doesn’t support SOCKS5, you’d have to layer an HTTP proxy on top, which adds complexity and breaks protocols like SSH that don’t work through HTTP proxies. If you try to use Docker or Orbstack to isolate apps that need the proxy, you’re back to managing VMs, which defeats the purpose of avoiding hypervisor overhead.
For my use case where I want CLI tools, databases, and services to transparently connect to different tailnets without per-application configuration, the proxy approach doesn’t cut it. Working directly with namespaces gives you a completely separate networking stack where everything just works. Any tool, any protocol, no configuration needed beyond the initial setup.
You might think Docker would be perfect here. Containers use namespaces and cgroups under the hood, which is exactly what we need for isolation and resource limiting. VPN daemons, though, need to create tunnel interfaces and manipulate routing tables, which requires CAP_NET_ADMIN and CAP_SYS_MODULE capabilities. Running a containerized Tailscale instance involves some friction. You could run with the privileged flag and lose the isolation benefit, or pass specific capabilities and end up doing low-level networking setup inside the container anyway. You could also use host networking mode to let the container reach the host’s network stack, but then you’re back to a single shared network namespace and can’t run two Tailscale instances simultaneously. Even with custom bridge networks and veth pairs between containers, you’re essentially recreating the namespace setup inside Docker’s abstraction layer, which adds complexity without much benefit. Docker shines for application isolation and multi-tenancy where you want resource limits, but for this specific task of running network daemons with their own stacks, the tooling overhead outweighs the containerization benefits.
Let’s get into it.
We’ll start by creating the namespace.
sudo ip netns add ts-b
Verify it’s there:
ip netns list
Right now it’s basically a loopback interface and no internet access or anything really. So we need to hook it up with a virtual ethernet cable. A veth pair is exactly what it sounds like: a virtual cable with two ends. Anything going into one end comes out the other.
sudo ip link add veth-host type veth peer name veth-ns
Both ends currently live in the host namespace. We need to fix that by setting one end to the host and the other in the tailscale one.
sudo ip link set veth-ns netns ts-b
Now the topology looks like this:
Host namespace
|
veth-host
|
[cable]
|
veth-ns
|
Namespace ts-b
Host side:
sudo ip addr add 10.200.1.1/24 dev veth-host
sudo ip link set veth-host up
Namespace side:
sudo ip netns exec ts-b ip addr add 10.200.1.2/24 dev veth-ns
sudo ip netns exec ts-b ip link set veth-ns up
sudo ip netns exec ts-b ip link set lo up
Without a default route, traffic from inside the namespace has nowhere to go. We tell it to send everything to the host:
sudo ip netns exec ts-b ip route add default via 10.200.1.1
sudo sysctl -w net.ipv4.ip_forward=1
Now we need to handle NAT (Network Address Translation) so traffic leaving the namespace appears to come from the host. The namespace has its own private IP range (10.200.1.0/24), but external systems on the internet only know how to reach the host’s IP. The host needs to “masquerade” outgoing traffic from the namespace, rewriting the source IP to the host’s address.
First, find your main network interface by running ip link show and looking for your primary Ethernet or Wi-Fi interface (usually eth0, enp3s0, or something like wlan0). Then add the masquerading rule:
sudo iptables -t nat -A POSTROUTING -s 10.200.1.0/24 -o eth0 -j MASQUERADE
This says: “Any packet leaving the host out interface eth0 with source IP in 10.200.1.0/24 (our namespace), rewrite the source IP to the host’s IP.” Without this, the remote system would see packets coming from 10.200.1.2, have no way to route back to that private IP, and drop the response.
But masquerading alone isn’t enough. The Linux kernel has a FORWARD chain that controls whether packets can traverse between interfaces. By default on many distributions, this chain has a policy of DROP, meaning it refuses to forward anything unless explicitly allowed. We need to allow traffic from the namespace (veth-host) to the outside world (eth0) and allow responses back:
sudo iptables -A FORWARD -i veth-host -o eth0 -j ACCEPT
sudo iptables -A FORWARD -i eth0 -o veth-host -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
The first rule allows outgoing traffic from the namespace. The second allows incoming traffic only if it’s related to a connection we initiated (the conntrack module tracks connection state). This way, the namespace can initiate connections, but random inbound traffic from the internet still gets dropped.
Note: If your system uses nftables instead of iptables, add the equivalent rules to your nftables config instead of mixing firewall systems. You can check with iptables --version or nft list ruleset.
Before bringing up Tailscale, it’s worth checking that the namespace can actually reach the internet:
sudo ip netns exec ts-b ping -c 1 1.1.1.1
If that works, the namespace has a way out and Tailscale has a fair shot at coming up cleanly.
We’ve got the namespace setup, so just need to launch the second tailscaled inside the namespace, pointing it at its own state file and socket so it doesn’t know the first one exists. If you want this to survive reboots, make sure to use a persistent path under something like /var/lib/ and /var/run/.
Btw tailscaled runs in the foreground. Either open a second terminal for the next step, background it, or wire it up to a service manager later.
sudo ip netns exec ts-b tailscaled \
--state=/tmp/tailscale-b.state \
--socket=/tmp/tailscale-b.sock
In another terminal:
sudo ip netns exec ts-b tailscale \
--socket=/tmp/tailscale-b.sock \
up
Authenticate the url and you are pretty much all set. The namespace spins up its own tailscale0 interface.
Check the host is still connected to tailnet A:
tailscale status
Check the namespace is connected to tailnet B:
sudo ip netns exec ts-b tailscale \
--socket=/tmp/tailscale-b.sock \
status
And just to see it with your own eyes:
sudo ip netns exec ts-b ip addr
Two tailscale0 interfaces, two separate tailnets, one machine.
What we actually built
Linux host
│
├─ root namespace
│ tailscale0 → tailnet A
│
└─ namespace ts-b
tailscale0 → tailnet B
Each namespace has its own routing table, firewall state, and network interfaces. They share a kernel but otherwise have no idea about each other.
Once you have two separate networking stacks on one machine, you could let them talk to each other. Maybe you wanna easily send something from one tailnet to the other. The host can selectively forward traffic between namespaces, which effectively turns it into a bridge between tailnets. From there it’s a short walk to things like controlled access gateways between environments, smooth migration paths when you’re moving infrastructure between networks, or exposing specific services from one tailnet into another without fully merging them. And of course, you get the rest of Tailscale’s benefits like funnel, ssh etc. I spend most of my time split between a mac and linux machines, so I use both this approach and via Orbstack machines.