Using Raspberry Pi and Tailscale to join two home networks together


This is a project I did over Christmas 2024 - joining two physically separate home networks into a single network.

This means that any device in Site A can reach any device in Site B and vice-versa. Those devices do not need to have any custom software installed on them for this to work.

I accomplished this by leveraging two Raspberry Pis - one in each network - running a free VPN tool called Tailscale.

Here’s how it looks like:

Site-to-site VPN


Goals

Here is what I wanted to achieve with this project and what I will show you how to do in this post:

  1. Any device in Site A can reach any device in Site B (and the other way around)
  2. Those devices DO NOT need to have any custom software installed to do that
  3. The whole process is automated as much as possible, so I can come back to this in two years and be able to easily work with it
  4. BONUS: I can reach any device in Site A or Site B from anywhere in the world using a pre-approved device when I connect it to the VPN

Assumptions

Before I start describing how to do stuff, let’s agree on a few definitions so it’s easier to follow.

Let’s assume we have two physically separates networks, called “Site A” and “Site B”.

Site A uses the IPs: 192.168.1.0 up to 192.168.1.255, so 192.168.1.0/24

Site B uses the IPs: 192.168.2.0 up to 192.168.2.255, so 192.168.2.0/24

The Raspberry Pi in Site A has the IP 192.168.1.100

The Raspberry Pi in Site B has the IP 192.168.2.100

The router in Site A has the IP: 192.168.1.1

The router in Site B has the IP: 192.168.2.1


Required software and hardware

The sites will be joined together using site-to-site VPN. If you have advanced enough routers at your disposal, you can probably achieve the same result by just properly configuring the VPN on those devices. My routers don’t allow for this and I didn’t feel like purchasing new ones, so I achieved the same using two Raspberry Pis I had lying around.

When it comes to software, we’ll be using Tailscale. Tailscale is a VPN solution that we can self-host to connect the two sites together. Traffic between the sites will go through an encrypted tunnel over the public web.

So here’s what we need:


A word on automation

I will be automating this process as much as possible by using a great tool called Just.

Just is a modern version of Make. It allows you to write small and simple scripts, called “recipes”, which you can run from your terminal.

The end goal is to have a few of these recipes in place to manage this whole thing. A few examples:


Step 1: setup Rasberry Pis

I’m a big fan of using containers and isolating the applications so they can be easily installed and uninstalled without affecting the host system. Therefore, I will be using Docker and Docker Compose to install Tailscale.

There are, however, a few changes that need to be introduced outside Docker to make this work - but more on that later.

Once you have your Raspberry Pi OS installed (I used the newest 64-bit one) we need to install docker.

Having automation in mind, let’s initiate our justfile with the first recipe, called provision, which will be responsible for installing all we need on our Raspberry Pi. In this case, the only thing we need there is Docker.

rpi_user := "your_rpi_user"
rpi_hostname := "your_rpi_hostname_or_ip"

# Provision the Raspberry Pi (install Docker)
provision:
    ssh {{rpi_user}}@{{rpi_hostname}} 'curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh && sudo usermod -aG docker pi'

If we now run just provision, we should get Docker installed on the Raspberry Pi. We can now SSH there and verify with docker --version. While we’re at it, we can add a ssh recipe:

ssh:
    ssh {{rpi_user}}@{{rpi_hostname}}

That’s it, our Raspberry Pi is ready to go. Remember to repeat this process on your second Raspberry Pi!


Step 2: Install Tailscale

With both Pis provisioned, we now need to install Tailscale on each of them. Let’s create a docker-compose.yml file, ideally in a folder, for example tailscale:

services:
  tailscale:
    image: tailscale/tailscale:latest
    container_name: tailscale
    network_mode: "host" # Allows Tailscale to manage networking
    cap_add:
      - NET_ADMIN
      - NET_RAW
      - SYS_MODULE
    devices:
      - /dev/net/tun:/dev/net/tun
    security_opt:
      - apparmor:unconfined
    volumes:
      - /var/lib/tailscale:/var/lib/tailscale
    environment:
      - TS_AUTH_KEY=tskey-your-auth-key # Replace with your Tailscale auth key
    command: >
      tailscaled
      --state=/var/lib/tailscale/tailscaled.state
    restart: unless-stopped
    entrypoint:
      - sh
      - -c
      - |
        tailscaled --state=/var/lib/tailscale/tailscaled.state &
        sleep 1
        tailscale up --advertise-routes=192.168.1.0/24 --snat-subnet-routes=false --accept-routes
        wait

What does it do? Let’s break it down:

Notice the --advertise-routes flag. This is the only part of this file that will differ between the two Raspberry Pis. The Raspberry Pi in Site A needs to advertise the routes of Site A, so 192.168.1.0/24. The Raspberry Pi in Site B needs to advertise the routes of Site B, so 192.168.2.0/24.

So we need two docker-compose.yml files, both of them the same except for that one IP.

Deploying

Once we have both docker-compose.yml files (one for Site A, one for Site B) we can deploy them to the Raspberry Pis and start them. Let’s add these recipes to justfile:

# Copy all the files (excluding MD, png) in tailscale/ to /home/pi/tailscale
deploy:
    rsync -avh --inplace --no-perms --exclude '*.MD' --exclude '*.png' tailscale/* {{rpi_user}}@{{rpi_hostname}}:/home/pi/tailscale/

# Start the service
start:
    ssh {{rpi_user}}@{{rpi_hostname}} 'cd tailscale && docker compose up -d --build'

# Stop the service
stop:
    ssh {{rpi_user}}@{{rpi_hostname}} 'cd tailscale && docker compose down'

# Display Docker logs for service
log service:
    ssh {{rpi_user}}@{{rpi_hostname}} 'docker logs -f tailscale'

(I used rsync here but you can just use scp if you want to)

just deploy will copy all files in the tailscale folder (excluding .png and .MD) to /home/pi/tailscale on the Pi. just start and just stop will start and stop the service, respecively just log will display logs of the running service

If we now run just deploy and then just start, we should see Docker building our app. If all goes fine, just log should display logs of a running Tailscale instance.

You can now go to the admin panel of Tailscale and you should see the Raspberry Pi appear as a connected machine. At this point, a few manual clicks are needed:

Great! This step should be repeated for the second Raspberry Pi. Remember to change the advertised routes to match the subnet of the second site!

If all goes well, you should see both Pis in the admin panel of Tailscale. Remember to accept the advertised routes (this is a one-time thing).

At this point you probably should be able to ping one Pi from the other using their respective Tailscale IPs (you can see them in the panel).


Step 3: Setup routing

Okay cool, our two Pis can see each other but what about all the other devices in the networks? We want all devices in Site A to see all devices in Site B (and the othery way around) without needing to change anything on those devices. They should be unaware of Tailscale and our setup as a whole. From their perspective, they are all in a single network - that’s what we want. So how do we get there? At this point we basically have this:

Site-to-site VPN - before routing

If we would now try to ping a device in Site B from a device in Site A it would fail. That’s because our request goes to the router but it has no idea that in order to reach the other site it needs to go through the Raspberry Pi. The router will just try to “send it out there” to the internet and will fail miserably.

We need to setup a few things that will instruct our routers and Raspberry Pis on how to route the traffic so it reaches the destination:

Let’s go through this one by one

IP Forwarding

IP Forwarding refers to the process of enabling a device to forward IP packets from one network interface to another - which effectively turns the device into a router.

By default, Linux processes only packets addressed to its own IP addresses - but when IP forwarding is enabled, the system forwards packets from one network interface to another if the packet is not intended for that device.

In our case, we want packets arriving to the wlan0 interface (or eth0 if your RPi is connected to your LAN through a wire) that are intended for the other site to be forwarded to the tailscale0 network interface. The tailscale0 network interface is created when we install and run Tailscale - packets exiting through that interface will go to the Tailscale Network and be directed further.

Enabling IP Forwarding on Raspberry Pi is very simple, we just need to run this:

sudo sysctl -w net.ipv4.ip_forward=1

Note: this will not persist through a restart!

We want to automate this so that IP Forwarding gets enabled when we install Tailscale on the RPi and disabled if we uninstall it. Let’s modify our justfile:

# Install service from the ground up - deploy files, run setup, start the service
install service:
    just deploy
    just _setup
    just start

# Uninstall the service entirely - stop, run teardown, delete files
uninstall service:
    just stop
    just _teardown
    just remove

# Remove the whole /home/pi/tailscale folder
remove service:
    ssh {{rpi_user}}@{{rpi_hostname}} 'rm -rf /home/pi/tailscale'

_setup: _ip-forwarding-enable
_teardown: _ip-forwarding-disable

# Enable IP Forwarding
_ip-forwarding-enable:
    ssh {{rpi_user}}@{{rpi_hostname}} 'sudo sysctl -w net.ipv4.ip_forward=1'

# Disable IP Forwarding
_ip-forwarding-disable:
    ssh {{rpi_user}}@{{rpi_hostname}} 'sudo sysctl -w net.ipv4.ip_forward=0'

Running just install will deploy the files, enable IP Forwarding and start Tailscale. Running just uninstall will stop Tailscale, disable IP Forwarding and remove the files.

You may wonder about the weird definition of _setup and _teardown - we will make use of it in the next step.

Routing rules in IP Tables

Alright, our Raspberry Pis now have IP Forwarding enabled and are able to route the traffic - but we need to setup rules that will tell them which traffic to route and where to direct it.

We set these rules in the IP Tables using the iptables command. For our case we want these scenarios:

Commands for adding these look like this (example for Site A):

sudo iptables -A FORWARD -i wlan0 -o tailscale0 -s 192.168.1.0/24 -d 192.168.2.0/24 -j ACCEPT
sudo iptables -A FORWARD -i tailscale0 -o wlan0 -s 192.168.2.0/24 -d 192.168.1.0/24 -j ACCEPT

However, there’s one caveat! When the packets go through Tailscale, it does something called SNAT - it overwrite the Source IP of the packet to be the Tailscale IP.

Consider this scenario:

To solve that, we need to add another pair of rules that takes the Tailscale IPs into consideration (example for Site A and assuming Site B’s Raspberry Pi Tailscale IP is 100.101.25.34):

sudo iptables -A FORWARD -i wlan0 -o tailscale0 -s 192.168.1.0/24 -d 100.101.25.34 -j ACCEPT
sudo iptables -A FORWARD -i tailscale0 -o wlan0 -s 100.101.25.34 -d 192.168.1.0/24 -j ACCEPT

This will handle the SNAT.

Note: some useful commands around iptables:

Okay so we need to setup four routes on each of the Raspberry Pis, switching the IP ranges and stuff… how do we automate it? It’s not that difficult, although slightly more complicated:

this_subnet_cidr := "192.168.1.0/24"
other_subnet_cidr := "192.168.2.0/24"
other_rpi_tailscale_ip := "100.101.25.34"

_add-forwarding-rules:
    #!/usr/bin/env bash
    echo 'Checking if the rules are already set up'
    RULES_EXIST=$(ssh {{rpi_user}}@{{rpi_hostname}} "sudo iptables -L FORWARD -v -n --line-numbers | grep 'tailscale0.*wlan0.*{{other_subnet_cidr}}.*{{this_subnet_cidr}}'")
    if [[ -n "$RULES_EXIST" ]]; then
        echo 'Rules already exist, skipping this step'
    else
        echo 'Setting up forwarding rules'
        ssh {{rpi_user}}@{{rpi_hostname}} 'sudo iptables -A FORWARD -i tailscale0 -o wlan0 -s {{other_subnet_cidr}} -d {{this_subnet_cidr}} -j ACCEPT'
        ssh {{rpi_user}}@{{rpi_hostname}} 'sudo iptables -A FORWARD -i wlan0 -o tailscale0 -s {{this_subnet_cidr}} -d {{other_subnet_cidr}} -j ACCEPT'
        ssh {{rpi_user}}@{{rpi_hostname}} 'sudo iptables -A FORWARD -i tailscale0 -o wlan0 -s {{other_rpi_tailscale_ip}} -d {{this_subnet_cidr}} -j ACCEPT'
        ssh {{rpi_user}}@{{rpi_hostname}} 'sudo iptables -A FORWARD -i wlan0 -o tailscale0 -s {{this_subnet_cidr}} -d {{other_rpi_tailscale_ip}} -j ACCEPT'
    fi

_remove-forwarding-rules:
    #!/usr/bin/env bash
    echo 'Checking if the rules exist'
    RULES_EXIST=$(ssh {{rpi_user}}@{{rpi_hostname}} "sudo iptables -L FORWARD -v -n --line-numbers | grep 'tailscale0.*wlan0.*{{other_subnet_cidr}}.*{{this_subnet_cidr}}'")
    if [[ -n "$RULES_EXIST" ]]; then
        echo 'Removing forwarding rules'
        ssh {{rpi_user}}@{{rpi_hostname}} "sudo iptables -L FORWARD -v -n --line-numbers | grep 'tailscale0.*wlan0.*{{other_subnet_cidr}}.*{{this_subnet_cidr}}' | awk '{print \$1}' | xargs -r -I{} sudo iptables -D FORWARD {}"
        ssh {{rpi_user}}@{{rpi_hostname}} "sudo iptables -L FORWARD -v -n --line-numbers | grep 'wlan0.*tailscale0.*{{this_subnet_cidr}}.*{{other_subnet_cidr}}' | awk '{print \$1}' | xargs -r -I{} sudo iptables -D FORWARD {}"
        ssh {{rpi_user}}@{{rpi_hostname}} "sudo iptables -L FORWARD -v -n --line-numbers | grep 'tailscale0.*wlan0.*{{other_rpi_tailscale_ip}}.*{{this_subnet_cidr}}' | awk '{print \$1}' | xargs -r -I{} sudo iptables -D FORWARD {}"
        ssh {{rpi_user}}@{{rpi_hostname}} "sudo iptables -L FORWARD -v -n --line-numbers | grep 'wlan0.*tailscale0.*{{this_subnet_cidr}}.*{{other_rpi_tailscale_ip}}' | awk '{print \$1}' | xargs -r -I{} sudo iptables -D FORWARD {}"
    else
        echo "Rules don't exist, skipping this step"
    fi

Alright, this might seem daunting at first but at the gist of it are just the 4 rules we described and a simple if-else clause to create them if they are not in yet; and the other way around for removing them.

By defining the three variables at the top we make it easy for ourselves to edit this for the other Raspberry Pi. The names of the variables should be self explanatory, but just to make sure:

The scripts themselves simply check if those rules are added already and if note - add them. Then on removal it checks if they are present and removes them (with grep and xargs).

Now all we need to do is to plug those _add-forwarding-rules and _remove-forwarding-rules into out _setup and _teardown recipes (which in turn are part of install and uninstall ):

Simply find this part of the justfile:

_setup: _ip-forwarding-enable
_teardown: _ip-forwarding-disable

And switch it to this:

_setup: _ip-forwarding-enable _add-forwarding-rules
_teardown: _ip-forwarding-disable _remove-forwarding-rules

So now our _setup (called by install) will call _ip-forwarding-enable and _add-forwarding-rules and our _teardown (called by uninstall) will call _ip-forwarding-disable and _remove-forwarding-rules.


Two notes for the avid reader:

Static Routes

Our Raspberry Pi’s are now configured to route the traffic according to the rules we setup in the step above - but how do we make the traffic from our devices go through the Raspberry Pi’s in the first place?

The simplest approach would be to configure each device and set the Raspberry Pi as the router - but that violates the goal we set for ourselves, which is that we do not want to change anything on the devices in the networks - they should just see each other out of the box.

So what we will do instead is we will setup two Static Routes inside our existing routers.

That way any device in the network will still go through the router, but if the packet has a Destination IP set to the other Site, the router will know to route it to the Raspberry Pi - which, in turn, will forward it to Tailscale, as per the rules we set up in the previous step.

Unfortunately, setting up static routes in the router is a manual step - and each router has a different UI for that. What you need to do is login to your router, find the place where you can add Static Routes and set them up there.

Each Router needs two static routes (examples for Site A):

Setup the same for Site B, modifying accordingly.

If we set this up, we now have this:

Site-to-site VPN without external access


With all of this in place, any device in one of the sites should now be able to reach any device in the other site!

You can try to test like this:

If anything is wrong, refer to the Troubleshooting section

Remember to manually approve the advertised routes in the Tailscale Admin Panel!


BONUS: Access both your networks from the internet

Another benefit of this setup is that you can connect to the Tailscale VPN Network from anywhere and be treated as if you were part of your both home networks!

To do that you need to go to the Admin Panel on Tailscale website and follow the instructions there to add a device to be approved for accessing the network. Those instructions will also tell you how to install the Tailscale Client to be able to do so.

With that in place, all you need to do is connect to VPN and done - you can now see your home network from anywhere.

I personally have connected my phone and MacBook and I keep them disconnected, only connecting to the VPN when I need to reach the home network.

With this in place, we reach the final outcome:

Site-to-site VPN


Troubleshooting

If you try to ping a device in one Site from the other Site and you are not getting a response, you can try to pinpoint the location of the problem using tcpdump.

Note: tcpdump is not preinstalled, you need to install it on whatever device you want to use it in (either Raspberry Pi or your own laptop, etc).

Once you have it, you can use it to monitor the flow of packets on that device. Here’s how you can use tcpdump:

Let’s assume this scenario: you go to a device in Site A (192.168.1.25) and try to ping device in Site B (192.168.2.34) - but you get a timeout. Here’s how I would trace the problem:

Check if the packets are reaching the Raspberry Pi in Site A

SSH into Raspberry Pi in Site A and run sudo tcpdump -i wlan0 icmp. Do you see the packets arriving?

If so, continue to the next check

If there is nothing here, then the issue is that the packets from your device in Site A intended for Site B either don’t know how to reach the Raspberry Pi in Site A or don’t know that they should go through it.

Possible causes:

Check if the packets reaching Raspberry Pi in Site A are properly forwarded to Tailscale

If you see packets arriving to wlan0 of the RPi (as in previous check), next thing to check is whether they are forwarded from wlan0 to tailscale0.

You can check that by running: sudo tcpdump -i tailscale0 icmp. Do you see the packets here (should be the same as in the previous check)?

If so, continue to the next check

If there is nothing here, then the IP Forwarding and the rules we added to iptables are failing.

Possible causes:

Check if the packets are coming from Tailscale to the OTHER Raspberry Pi

If Raspberry Pi in Site A is receiving packets on wlan0 and forwarding them to tailscale0, the next step is to check whether they are reaching the other Raspberry Pi.

SSH to the other Raspberry Pi and verify with: sudo tcpdump -i tailscale0 icmp. Do you see the packets here?

If so, continue to the next check

If there is nothing here, then Tailscale is not delivering the traffic.

A few checks to be made:

If the devices are registered but offline then verify the logs of tailscale, either using just log or by running docker logs tailscale on the RPi. Make sure there are no errors

Check if the packets coming from Tailscale to the OTHER Raspberry Pi are forwarded from tailscale0 to wlan0

If Tailscale is delivering packets to the other site, the next thing to check is the forwarding from tailscale0 to wlan0.

Check using: sudo tcpdump -i wlan0 icmp. You should see the same packets as in sudo tcpdump -i tailscale0 icmp.

If there are packets, continue to the next check

Otherwise, the issue is with IP Forwarding or IP Tables rules.

Possible causes:

Check the destination device

It is possible that the destination device in Site B (192.168.2.34) is receiving the ping packets but doesn’t know how to route them back.

Check on that device with sudo tcpdump -i wlan0 icmp.

If packets are arriving but the original device still gets a timeout in ping, then the ping packets don’t know how to route back.

This might be caused by SNAT of Tailscale changing the Source IP and you not having the routes setup for Tailscale IPs:

Other checks

If none of the above helped, then I’m a bit out of ideas. Potential issues might be caused by: