Tailscale in a Home NAS environment
A few years ago I opted into using Tailscale to manage my personal home network’s devices. Overall, it’s been great and the whole setup process for each device was super efficient- they’ve abstracted a lot of the tricky parts of setting a VPN, to the point where it is a smooth click and install process now.
It’s widely available across multiple platforms. In fact, it’s so ubiquitous I was even surprised to find it was available to install on my Fire TV stick!
As great as Tailscale has been, as with any self hosted VPN, one of the limiting factors was the CPU. The Raspberry Pi 3 it was running on was once hailed as a great success with it’s low cost being incredibly appealing to then at the time, a budget strapped university student.
The constrained resources that the Pi 3 had, on top of the heavy computation done at the Tailscale level meant doing simple operations like file transfers became increasingly frustrating.
Upgrading from the Pi 3 to the N100 chip-series
I wanted better, so after a bit of research, I got my hands on a “Beelink” N100 box.
From what I had found many of these N100 chips were white labelled under several different Chinese manufacturers, with some claims that a couple of rogue manufacturers have been putting malware into the boxes before shipping it out.
I was hesitant, but the most reputable one seemed to be the Beelink brand.
It came with Windows 11 installed with a verified license key, which became quickly redundant after I had flashed it with Ubuntu Server 24.04 LTS.
Building an Exit Node
One of the issues I had regularly was being unable to access both my Tailscale VPN and NordVPN at the same time on my Pixel device. This wasn’t a limitation of Tailscale though, rather, this is a well known documented restriction of Android:
There can be only one VPN connection running at the same time. The existing interface is deactivated when a new one is created.
To get around this, we can build an exit node in Tailscale that sits behind NordVPN. The architecture would look like this:
┌─────────────────────┐ ┌─────────────────────┐
│DOCKER CONTAINER │ │DOCKER CONTAINER │
│┌───────────────────┐│ │┌───────────────────┐│
││TAILSCALE EXIT NODE│├────►││NORDVPN (NORDLYNX) │├────► INTERNET
│└───────────────────┘│ │└───────────────────┘│
└─────────────────────┘ └─────────────────────┘
Devices would be able to connect through using Tailscale’s network. All traffic that isn’t Tailscale’s network would then get forwarded to the NordVPN container.
We can set the NordVPN container to then forward any traffic it receives out through NordVPN to the public internet.
Building the Tailscale Exit Node container
Tailscale made this a breeze as they had an official Docker image provided. To keep things readable, I started fleshing out a docker-compose.yml file:
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale_exit_node
privileged: true # Required for Tailscale to control
volumes:
- ./tmp/tailscale:/var/lib/tailscale # Persist Tailscale state
env_file: ".env"
restart: always
With a corresponding .env file that will get read by Tailscale on runtime:
TS_AUTH_KEY=<TS_AUTH_KEY>
TS_EXTRA_ARGS=--advertise-exit-node # makes this an exit node
TS_STATE_DIR=/var/lib/tailscale # persists the state
TS_AUTH_ONCE=false # persists the state
TS_SOCKET=/var/run/tailscale/tailscaled.sock # persists the state
The last 3 lines to persist the state are necessary, otherwise restarting your docker container causes Tailscale to re-register the exit node as another brand new device.
The <TS_AUTH_KEY> starts with “ts-auth-” and can be manually generated in Tailscale.
Once all environment variables are filled in, we should be able to startup out Tailscale service using Docker Compose.
docker compose up -d
Building the NordVPN container
The starting point was looking at NordVPN’s official documentation. There wasn’t an official Docker image but it did provide instructions on building out one yourself.
However, it turns out that while this works as a standalone container, it doesn’t actually work too well when sitting in the same network as the Tailscale container.
The reason for this is because when you connect to NordVPN, it actually rewrites your route tables so that traffic flows through NordVPN, rather than your local network. The official documentation also doubles down on this as it requires you to run the container with:
--cap-add=NET_ADMIN
which providers admin powers over network settings within the Docker container to perform that route table update.
In practice though, this also eliminates any routing with Tailscale.
If we we’re going to make this work, we’d need to take control over what exactly gets written in these route tables. I started searching and it looked like someone else had already done the heavy lifting, thankfully.
It was specifically this block of code that I had needed to exclude my local network after NordVPN had connected:
for net in ${NET_LOCAL//[;,]/ }; do
echo "Enabling connection to network ${net}"
ip route | grep -q "$net" || ip route add "$net" via "$gw" dev eth0
iptables -A INPUT -i eth0 -s "$net" -j ACCEPT
iptables -A OUTPUT -o eth0 -d "$net" -j ACCEPT
iptables -A FORWARD -i eth0 -d "$net" -j ACCEPT
iptables -A FORWARD -i eth0 -s "$net" -j ACCEPT
done
Rather than reinvent the wheel, it was simpler to take this prebuilt Docker container and spin it up. Adding this docker-compose.yml like so:
services:
vpn:
image: ghcr.io/bubuntux/nordvpn
cap_add:
- NET_ADMIN # Required
- NET_RAW # Required
environment: # Review https://github.com/bubuntux/nordvpn#environment-variables
- TECHNOLOGY=NordLynx
- NETWORK=192.168.1.0/24,100.64.0.0/10 # Local + Tailscale's network
env_file: ".env"
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "https://google.com"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
There was a problem though. For some reason when you boot this up, all traffic seemed to not go through after connecting. Manually going into this container and forcing NordVPN to reconnect fixed the issue, which was odd.
Diving into it, it seemed like this file was causing our connections to drop. NordVPN already offers a similar feature called the Kill Switch, which we can pass in, meaning we can safely remove this file from our Docker build sequence.
Putting it altogether we then have the following:
nordvpn.Dockerfile
FROM ghcr.io/bubuntux/nordvpn
RUN rm /etc/cont-init.d/00-firewall
CMD nord_login && nord_config && nord_connect && nord_migrate && nord_watch
docker-compose.yml
services:
vpn:
build:
dockerfile: nordvpn.Dockerfile
cap_add:
- NET_ADMIN # Required
- NET_RAW # Required
environment: # Review https://github.com/bubuntux/nordvpn#environment-variables
- TECHNOLOGY=NordLynx
- PRE_CONNECT=nordvpn set killswitch enabled && nordvpn set autoconnect enabled
- NETWORK=192.168.1.0/24,100.64.0.0/10
networks:
- vpnsecure
env_file: ".env"
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "https://google.com"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale_exit_node
privileged: true # Required for Tailscale to control networking
network_mode: "service:vpn" # Link the Tailscale container to the NordVPN network
volumes:
- ./tmp/tailscale:/var/lib/tailscale # Persist Tailscale state
env_file: ".env"
depends_on:
vpn:
condition: service_healthy
restart: always
networks:
vpnsecure:
driver: bridge
enable_ipv6: false
.env
TOKEN=<NORDVPN_TOKEN>
TS_AUTH_KEY=<TAILSCALE TOKEN>
TS_EXTRA_ARGS=--advertise-exit-node
TS_STATE_DIR=/var/lib/tailscale
TS_AUTH_ONCE=false
TS_SOCKET=/var/run/tailscale/tailscaled.sock
The NordVPN token can be generated here. This token gets passed into the NordVPN container and automatically used to authenticate on startup.
If you’ve also notice, we’re adding in additional checks to ensure that the Tailscale container only starts up once NordVPN has connected.
This is important as Tailscale needs to connect to the Tailscale services, but it can’t do this if NordVPN hasn’t been connected yet with the kill switch now active.
Running Docker Compose once more, we should see our Tailscale exit node being advertised in our account:
docker compose up -d
One final thing to do is to accept this as an exit node in our Tailscale account!