Having a VPN in today’s world is a must, and I can’t recommend Mullvad VPN enough. I’ve been using it for years now and am very satisfied (not paid sponsor).
Mullvad VPN is serious about privacy, zero-knowledge, requires no email registration, and you can pay with crypto (and even better, Monero). Under the hood, Mullvad runs on WireGuard, which means low overhead, fast handshakes, and a minimal attack surface compared to OpenVPN.

Mullvad Clients
Linux Client: Mullvad has a Linux client that works great on Fedora (and any other distro). It’s simple to use and works great. Good GUI for normal configurations and a CLI client for advanced settings.
Android Client: Mullvad also has a really good Android client app (don’t like Apple ecosystem jail), which works great, simple, and fully featured.
Running Multiple VPNs
Normally, you would use only one VPN for all traffic, however I need to use 2 VPNs at once:
- Mullvad to protect all my network traffic
- Tailscale to access my private services available on a public VPS. The VPS runs a Headscale server that allows connections only from authenticated Tailscale clients.
The problem is fundamental: both VPNs compete for the default route. When Mullvad connects, it installs a default route through its WireGuard interface (
wg0) and uses policy-based routing to force all traffic through it. Tailscale installs its own routes for the100.64.0.0/10CGNAT range and expects direct connectivity to its coordination server. When Mullvad’s catch-all routing rule wins, Tailscale’s control plane traffic gets tunneled through Mullvad, which breaks authentication and peer discovery.
Mullvad does offer split tunneling to selectively exclude app traffic from the tunnel, but it works by cgroup or PID matching, not by destination address. Since Tailscale is a kernel-level WireGuard peer rather than a regular userspace application, app-based exclusions don’t apply cleanly. You can’t tell Mullvad “exclude the Tailscale interface” through the GUI.

Using Both Mullvad and Tailscale
I researched online for a solution and found that Tailscale offers a paid add-on for allowing Tailscale traffic over Mullvad via exit nodes, so, I decided to create a simple nftables rule to allow both connections on Linux.
How Mullvad Split Tunneling Works
Mullvad’s split tunneling is implemented entirely in nftables using packet marks, not application-level interception. Two marks control the behavior:
0x00000f41: applied to the connection tracking entry (ct mark), causing Mullvad’s conntrack rules to not capture the flow0x6d6f6c65: applied to the packet itself (meta mark), directing the kernel’s policy routing to skip the Mullvad routing table When Mullvad sees these marks on a packet, its nftables rules let it pass through to the normal routing table rather than forcing it intowg0. This is standard Linux policy routing: Mullvad adds a high-priorityip rulethat sends marked traffic to a separate routing table, and the exclusion marks tell the kernel to skip that rule.
Tailscale sets its own mark (0x80000) on all packets it originates, and all Tailscale peer addresses fall within 100.64.0.0/10 (IANA-reserved for carrier-grade NAT, reused here for the mesh network). Those two facts give us everything we need to intercept and re-mark Tailscale traffic before Mullvad’s rules process it.
The nftables Solution
Instead of fighting both VPNs at the routing level, we inject the Mullvad exclusion marks onto Tailscale traffic before Mullvad’s own rules see it. The table uses two chains, each registered at priority -100 so they run before Mullvad’s own nftables hooks.
The prerouting chain handles traffic arriving from the Tailscale mesh (source in 100.64.0.0/10). The output chain handles two cases: packets Tailscale generated itself (carrying the 0x80000 mark) and packets your applications send to Tailscale addresses (destination in 100.64.0.0/10). All three paths set the same pair of exclusion marks, landing in the same bypass outcome.
flowchart TD
PKT["Packet enters kernel"] --> PRE{"prerouting<br>src 100.64.0.0/10?"}
PRE -->|Yes| MPRE["ct mark 0x00000f41<br>meta mark 0x6d6f6c65"]
PRE -->|No| OUT{"output<br>Tailscale mark 0x80000?"}
OUT -->|Yes| MOUT["ct mark 0x00000f41<br>meta mark 0x6d6f6c65"]
OUT -->|No| DST{"dst 100.64.0.0/10?"}
DST -->|Yes| MDST["ct mark 0x00000f41<br>meta mark 0x6d6f6c65"]
DST -->|No| MULLVAD["Route via Mullvad wg0"]
MPRE --> BYPASS["Bypass Mullvad tunnel"]
MOUT --> BYPASS
MDST --> BYPASS
Configuration
Create The nftables Config
# Create the nftables directory if it doesn't exist
sudo mkdir -p /etc/nftables
# Create the config file
sudo vim /etc/nftables/mullvad-tailscale.nft
# /etc/nftables/mullvad-tailscale.nft
table inet mullvad-tailscale {
chain prerouting {
type filter hook prerouting priority -100; policy accept;
# Mark traffic arriving from the Tailscale mesh (100.64.0.0/10 CGNAT range)
ip saddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
}
chain outgoing {
type route hook output priority -100; policy accept;
# Mark packets Tailscale originated itself (carries 0x80000 by default)
meta mark 0x80000 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
# Mark application traffic destined for Tailscale peers
ip daddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
}
}
Load The Rules
# Load the table immediately into the running kernel
sudo nft -f /etc/nftables/mullvad-tailscale.nft
Make Changes Persistent
This step is critical, systemctl enable nftables loads /etc/sysconfig/nftables.conf on Fedora, not your custom file. Without the include, the rules vanish on next boot and Tailscale silently routes through Mullvad again.
# Tell the nftables service to also load your custom table on boot
echo 'include "/etc/nftables/mullvad-tailscale.nft"' | sudo tee -a /etc/sysconfig/nftables.conf
# Enable and start the nftables service
sudo systemctl enable --now nftables
Verify The Table Is Loaded
# List the table contents to confirm both chains are present
sudo nft list table inet mullvad-tailscale
Automating Tailscale on Boot
If tailscaled is already enabled, it reconnects automatically on boot using the stored node key in /var/lib/tailscale/. Check that first:
# If this is already active, you're done
sudo systemctl status tailscaled
If you’re running tailscale up manually after each boot (common when pointing at a custom Headscale server), wrap it in a oneshot service that fires after both tailscaled and nftables are ready. The ordering matters: if nftables rules aren’t loaded before Tailscale connects, the first few packets to your Headscale server go through Mullvad, and the handshake may fail depending on whether your Headscale endpoint is reachable through Mullvad’s exit node.
# Create the oneshot service
sudo vim /etc/systemd/system/tailscale-up.service
[Unit]
Description=Tailscale up
After=tailscaled.service nftables.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/tailscale up --login-server https://your.headscale.server
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
# Enable the service
sudo systemctl daemon-reload
sudo systemctl enable --now tailscale-up.service
If your Headscale node key expires (check expiry_disabled in your Headscale config if you don’t want expiry), tailscale-up will fail silently. Add Restart=on-failure and RestartSec=30 to the [Service] block if you want automatic retry.
Testing
This should be enough. No complex configuration is needed.
Regular traffic goes through Mullvad and Tailscale connections bypass Mullvad (which makes Headscale server access possible).
When This Makes Sense
This is useful if you need to access Headscale/Tailscale VPN protected services while also need to route all your traffic through Mullvad VPN.
It has worked for me for many months now and had no issues. So I’m sharing it with the world.