Back to Blog
11 min read 69 views By Maximus Barbare Featured

Routing Tailscale Traffic Through Mullvad Correctly on macOS

A tutorial/story about how my journey to route Tailscale through Mullvad (on Macbook), and how you can too.

networkingtutorialtailscalevpn

The Problem:

I run Tailscale on my Macbook with the Mullvad exit node addon. However, my network owner blocks connections to the Tailscale control plane with DPI. No big deal. I'll just connect on my hotspot and then I can use the exit nodes no problem. But I still can't use Tailscale on my network.

To fix this, I purchase Mullvad VPN standalone and install it. However, I soon run into a problem. When I connect Tailscale over the VPN, it is unable to reach any of the other devices because Mullvad's routing rules and firewall are blocking the requests from going out to Tailscale's DERP servers. Enabling split tunneling through the Mullvad GUI doesn't help, it actually makes it worse because the traffic just tries to go through the base network, which blocks it. And since Mullvad doesn't let you specify IP ranges for local access outside of their predefined routes, I had to get creative.

My Solution:

I needed a solution that would correctly pass Tailscale IP traffic to the Tailscale interface, then through the Mullvad tunnel untouched so it could access the DERP servers. Not being able to find anything about this online, I enlisted Claude and my boundless intuition to come up with a solution.

The solution lies within the packet filter rules for Mullvad. To see what rules Mullvad is currently using, you can run:

sudo pfctl -a mullvad -s rules

What we need to add is a rule that passes Tailscale traffic through this barrier untouched. It'll look something like this:

pass quick on utun15 all flags any keep state

Tailscale operates at the IP range "100.64.0.0/10" (side note: these addresses are within the carrier-grade network address translation range, CGNAT). We need to find the interface that has an IP address within this range be running:

ifconfig

It will probably be in the format "utunXX". Once you have this, you can use the output from the above command to get current packet filtering rules for Mullvad, copy them, and append our "pass" line to the end for a manual test. I used EOF, and it looked something like this:

sudo pfctl -a mullvad -f - <<'EOF'
scrub all fragment reassemble
pass quick on lo0 all flags any keep state
pass quick on utun15 all flags any keep state
pass out quick inet proto udp from any port = 68 to 255.255.255.255 port = 67 no state
pass in quick inet proto udp from any port = 67 to any port = 68 no state
pass out quick inet6 proto udp from fe80::/10 port = 546 to ff02::1:2 port = 547 no state
pass out quick inet6 proto udp from fe80::/10 port = 546 to ff05::1:3 port = 547 no state
pass in quick inet6 proto udp from fe80::/10 port = 547 to fe80::/10 port = 546 no state
pass out quick inet6 proto ipv6-icmp from any to ff02::2 icmp6-type routersol no state
pass in quick inet6 proto ipv6-icmp from fe80::/10 to any icmp6-type routeradv no state
pass in quick inet6 proto ipv6-icmp from fe80::/10 to any icmp6-type redir no state
pass out quick inet6 proto ipv6-icmp from any to ff02::1:ff00:0/104 icmp6-type neighbrsol no state
pass out quick inet6 proto ipv6-icmp from any to fe80::/10 icmp6-type neighbrsol no state
pass in quick inet6 proto ipv6-icmp from fe80::/10 to any icmp6-type neighbrsol no state
pass out quick inet6 proto ipv6-icmp from any to fe80::/10 icmp6-type neighbradv no state
pass in quick inet6 proto ipv6-icmp all icmp6-type neighbradv no state
pass out quick on utun14 inet proto tcp from any to 10.64.0.1 port = 53 flags S/SA keep state
pass out quick on utun14 inet proto udp from any to 10.64.0.1 port = 53 no state
pass out quick on utun14 inet6 proto tcp from any to fc00:bbbb:bbbb:bb01::1 port = 53 flags S/SA keep state
pass out quick on utun14 inet6 proto udp from any to fc00:bbbb:bbbb:bb01::1 port = 53 no state
pass out quick inet proto udp from any to 45.134.142.193 port = 23643 user = 0 keep state
block return out quick proto tcp from any to any port = 53
block return out quick proto udp from any to any port = 53
pass out quick inet from any to 10.0.0.0/8 flags any keep state
pass in quick inet from 10.0.0.0/8 to any flags any keep state
pass out quick inet from any to 172.16.0.0/12 flags any keep state
pass in quick inet from 172.16.0.0/12 to any flags any keep state
pass out quick inet from any to 192.168.0.0/16 flags any keep state
pass in quick inet from 192.168.0.0/16 to any flags any keep state
pass out quick inet from any to 169.254.0.0/16 flags any keep state
pass in quick inet from 169.254.0.0/16 to any flags any keep state
pass out quick inet from any to 100.64.0.0/10 flags any keep state
pass in quick inet from 100.64.0.0/10 to any flags any keep state
pass out quick inet6 from any to fe80::/10 flags any keep state
pass in quick inet6 from fe80::/10 to any flags any keep state
pass out quick inet6 from any to fc00::/7 flags any keep state
pass in quick inet6 from fc00::/7 to any flags any keep state
pass out quick inet from any to 255.255.255.255 no state
pass out quick inet from any to 224.0.0.0/24 no state
pass out quick inet from any to 239.0.0.0/8 no state
pass out quick inet6 from any to ff01::/16 no state
pass out quick inet6 from any to ff02::/16 no state
pass out quick inet6 from any to ff03::/16 no state
pass out quick inet6 from any to ff04::/16 no state
pass out quick inet6 from any to ff05::/16 no state
pass out quick inet proto udp from any port = 67 to any port = 68 no state
pass in quick inet proto udp from any port = 68 to 255.255.255.255 port = 67 no state
pass quick on utun14 all flags S/SA keep state
block return out quick all
block drop quick all
EOF

Once you've saved your changes, test your connection by pinging the IPv4 of your remote machine (MagicDNS probably still won't work). If it works, great! Let's automate it.

We'll create a system service that runs every five minutes, checking which interface name Tailscale is using and switching the rule to point to that interface. That way, even if Tailscale loses connection and reconnects, everything is automated. Create a service using:

sudo tee /usr/local/bin/update-mullvad-tailscale.sh > /dev/null <<'EOF'
#!/bin/bash

# Set PATH for LaunchDaemon
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

# Find Tailscale's interface by looking for its unique IPv6 prefix (fd7a:115c:a1e0)
TS_INTERFACE=$(ifconfig | grep -B 10 "fd7a:115c:a1e0" | grep "^utun" | head -1 | awk '{print $1}' | tr -d ':')

# Fallback: look for 100.64-100.127 IP addresses if IPv6 method fails
if [ -z "$TS_INTERFACE" ]; then
    TS_INTERFACE=$(ifconfig | grep -B 5 "inet 100\." | grep "^utun" | while read line; do
        iface=$(echo $line | awk '{print $1}' | tr -d ':')
        ip=$(ifconfig $iface | grep "inet 100\." | awk '{print $2}')
        # Check if IP is in 100.64.0.0/10 range (100.64.x.x to 100.127.x.x)
        second_octet=$(echo $ip | cut -d. -f2)
        if [ $second_octet -ge 64 ] && [ $second_octet -le 127 ]; then
            echo $iface
            break
        fi
    done)
fi

if [ -z "$TS_INTERFACE" ]; then
    echo "Tailscale interface not found"
    exit 1
fi

echo "Found Tailscale on $TS_INTERFACE"

# Get Mullvad's interface - use full path
MULLVAD_INTERFACE=$(/usr/local/bin/mullvad status -v 2>/dev/null | grep "Tunnel interface" | awk '{print $3}')

# Fallback if mullvad is in a different location
if [ -z "$MULLVAD_INTERFACE" ]; then
    MULLVAD_INTERFACE=$(/opt/homebrew/bin/mullvad status -v 2>/dev/null | grep "Tunnel interface" | awk '{print $3}')
fi

# Another fallback: find utun with Mullvad's private IP (10.x)
if [ -z "$MULLVAD_INTERFACE" ]; then
    MULLVAD_INTERFACE=$(ifconfig | grep -B 5 "inet 10\." | grep "^utun" | tail -1 | awk '{print $1}' | tr -d ':')
fi

if [ -z "$MULLVAD_INTERFACE" ]; then
    echo "Mullvad interface not found"
    exit 1
fi

echo "Found Mullvad on $MULLVAD_INTERFACE"

# Apply the firewall rules
/sbin/pfctl -a mullvad -f - <<RULES
scrub all fragment reassemble
pass quick on lo0 all flags any keep state
pass quick on ${TS_INTERFACE} all flags any keep state
pass out quick inet proto udp from any port = 68 to 255.255.255.255 port = 67 no state
pass in quick inet proto udp from any port = 67 to any port = 68 no state
pass out quick inet6 proto udp from fe80::/10 port = 546 to ff02::1:2 port = 547 no state
pass out quick inet6 proto udp from fe80::/10 port = 546 to ff05::1:3 port = 547 no state
pass in quick inet6 proto udp from fe80::/10 port = 547 to fe80::/10 port = 546 no state
pass out quick inet6 proto ipv6-icmp from any to ff02::2 icmp6-type routersol no state
pass in quick inet6 proto ipv6-icmp from fe80::/10 to any icmp6-type routeradv no state
pass in quick inet6 proto ipv6-icmp from fe80::/10 to any icmp6-type redir no state
pass out quick inet6 proto ipv6-icmp from any to ff02::1:ff00:0/104 icmp6-type neighbrsol no state
pass out quick inet6 proto ipv6-icmp from any to fe80::/10 icmp6-type neighbrsol no state
pass in quick inet6 proto ipv6-icmp from fe80::/10 to any icmp6-type neighbrsol no state
pass out quick inet6 proto ipv6-icmp from any to fe80::/10 icmp6-type neighbradv no state
pass in quick inet6 proto ipv6-icmp all icmp6-type neighbradv no state
pass out quick on ${MULLVAD_INTERFACE} inet proto tcp from any to 10.64.0.1 port = 53 flags S/SA keep state
pass out quick on ${MULLVAD_INTERFACE} inet proto udp from any to 10.64.0.1 port = 53 no state
pass out quick on ${MULLVAD_INTERFACE} inet6 proto tcp from any to fc00:bbbb:bbbb:bb01::1 port = 53 flags S/SA keep state
pass out quick on ${MULLVAD_INTERFACE} inet6 proto udp from any to fc00:bbbb:bbbb:bb01::1 port = 53 no state
pass out quick inet proto udp from any to 45.134.142.193 port = 23643 user = 0 keep state
block return out quick proto tcp from any to any port = 53
block return out quick proto udp from any to any port = 53
pass out quick inet from any to 10.0.0.0/8 flags any keep state
pass in quick inet from 10.0.0.0/8 to any flags any keep state
pass out quick inet from any to 172.16.0.0/12 flags any keep state
pass in quick inet from 172.16.0.0/12 to any flags any keep state
pass out quick inet from any to 192.168.0.0/16 flags any keep state
pass in quick inet from 192.168.0.0/16 to any flags any keep state
pass out quick inet from any to 169.254.0.0/16 flags any keep state
pass in quick inet from 169.254.0.0/16 to any flags any keep state
pass out quick inet from any to 100.64.0.0/10 flags any keep state
pass in quick inet from 100.64.0.0/10 to any flags any keep state
pass out quick inet6 from any to fe80::/10 flags any keep state
pass in quick inet6 from fe80::/10 to any flags any keep state
pass out quick inet6 from any to fc00::/7 flags any keep state
pass in quick inet6 from fc00::/7 to any flags any keep state
pass out quick inet from any to 255.255.255.255 no state
pass out quick inet from any to 224.0.0.0/24 no state
pass out quick inet from any to 239.0.0.0/8 no state
pass out quick inet6 from any to ff01::/16 no state
pass out quick inet6 from any to ff02::/16 no state
pass out quick inet6 from any to ff03::/16 no state
pass out quick inet6 from any to ff04::/16 no state
pass out quick inet6 from any to ff05::/16 no state
pass out quick inet proto udp from any port = 67 to any port = 68 no state
pass in quick inet proto udp from any port = 68 to 255.255.255.255 port = 67 no state
pass quick on ${MULLVAD_INTERFACE} all flags S/SA keep state
block return out quick all
block drop quick all
RULES

echo "Rules updated successfully for $TS_INTERFACE via $MULLVAD_INTERFACE"
EOF

&

sudo chmod +x /usr/local/bin/update-mullvad-tailscale.sh

Now your service is set up, we can set up the launch daemon.

sudo tee /Library/LaunchDaemons/com.mullvad.tailscale.plist > /dev/null <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.mullvad.tailscale</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/update-mullvad-tailscale.sh</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StartInterval</key>
    <integer>300</integer>
    <key>StandardErrorPath</key>
    <string>/tmp/mullvad-tailscale.err</string>
    <key>StandardOutPath</key>
    <string>/tmp/mullvad-tailscale.log</string>
</dict>
</plist>
EOF

Then load it:

# Set proper permissions
sudo chown root:wheel /Library/LaunchDaemons/com.mullvad.tailscale.plist
sudo chmod 644 /Library/LaunchDaemons/com.mullvad.tailscale.plist

# Load it (starts immediately and every 5 minutes after)
sudo launchctl load /Library/LaunchDaemons/com.mullvad.tailscale.plist

To check it's running:

# Check if loaded
sudo launchctl list | grep mullvad.tailscale

# View logs
tail -f /tmp/mullvad-tailscale.log

# View errors (if any)
tail -f /tmp/mullvad-tailscale.err

Go ahead and connect to your Tailscale machine, you earned it!

Notes:

I do still have occasional problems with this setup. For instance, I will occasionally have to restart the Tailscale service for some reason and let the script run, as it will randomly stop working. As of now, I do not know why this happens, but I will update this article with a fix if I find out. However, this is the only method I've found that seems to work at all. Do with it as you will.

Share this article

Help others discover this content

MAXIMUS BARBARE

Animator, Web Designer & Developer creating immersive digital experiences.

Quick Links

Connect

© 2025 Maximus Barbare. All rights reserved.