Fixing the Mullvad + Tailscale DNS Deadlock

Published on 2026/06/13

There’s an update to this post!

I run community infrastructure for my friends over at glitched.systems, most of which is hosted on Hetzner. Recently, I’ve been trying to move parts of it onto some physical servers I have at my house and get a hybrid cloud thing going on and so I tried to get all the servers, physical and virtual, to talk to each other over Tailscale.

This was supposed to be a simple 30 minute task, but it ended up taking a few hours. All because I run Mullvad on my PC.

See, when you have both Mullvad and Tailscale running at the same time, they fight over your DNS. So if you try to ping something over your tailnet, you end up with a cursor that blinks forever because Mullvad is just sending all the packets into the shadow realm.

Here’s how I ended up debugging the problem and fixing it!

The Symptoms

  1. After getting everything onto the tailnet, I tried pinging a device using its MagicDNS name (e.g., server1.ts.net)
  2. The terminal seemed to resolve the name, but it hung infinitely.
  3. I ran ip route get <tailscale-ip> and it showed me that my traffic was trying to escape out of my wlan0 interface instead of going through my tailscale0 interface.

systemd-resolved and Firewall Poking

Turns out, because I was running multiple VPNs alongside local interfaces, two distinct issues were happening simultaneously:

1. DNS Routing Fights

Both VPNs were fighting to control my DNS in systemd-resolved. I inspected my setup using resolvectl status and found multiple interfaces laying claim to the default routing-only domain (~.). From what I understand, this basically told the system to split or blindly interleave all the global traffic between competing VPN interfaces which led to DNS query drops and other really fun stuff.

2. Running into the Firewall

Mullvad specifically has really aggressive firewall rules that it enforces through nftables that mark packets to force them through its tunnel. When Tailscale tried to route traffic via the standard routing tables, Mullvad’s daemon sent them straight into the void because they didn’t match its strict routing policies.

The kernel then lost track of where the 100.64.0.0/10 Tailscale subnet lived and caused packets to leak into my other network interfaces.

The Fix

Here’s what I ended up doing to fix the issue. (btw if I did anything wrong here or if there’s a better way to do this, I’d love to know).

Step 1: Repair the systemd-resolved Split

I had to ensure that Mullvad owned the general internet routing domain.

sudo resolvectl domain wg0-mullvad "~."

(Sidenote: If you have other custom network links running that show up with ~. in resolvectl status, you’ll probably have to manually turn off their default routes using "sudo resolvectl default-route <link-name> no").

Step 2: Fix the nftables Hook Priorities

I then set up a custom bypass chain rule for tailscale packets so that they’re sent to the right place before Mullvad touches them.

# /etc/nftables.d/mullvad_tailscale.conf

table inet mullvad_tailscale {
  chain output {
    type route hook output priority -100; policy accept;
    ip daddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
    ip daddr 100.100.100.100 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
  }
}

We load this configuration with:

sudo nft -f /etc/nftables.d/mullvad_tailscale.conf

Step 3: Force Tailscale to Reclaim Its Subnet

The last thing I had to do was get tailscale to re-inject its network subnet definitions back into the kernel.

sudo tailscale up --snat-subnet-routes=false --accept-routes

Once you run through these steps, you should check your traffic path with the ip route command from earlier to verify whether it’s targeting your Tailscale interface. If it is, then MagicDNS resolution should start working again.

Update - 2026/06/15

The problems did not end! Every time Mullvad reconnected I’d lose DNS and have other issues. After a bit of research I realized that the original solution I came up with was more complicated than it needed to be. The systemd-resolved stuff from before is still the same, Mullvad still needs to own “~.”. The stuff that changed is steps 2 and 3.

Thanks to this comment on mullvad/mullvadvpn-app#6086 I realized that instead of having a separate table that tries to intercept packets before Mullvad does, I could just mark Tailscale bound traffic with Mullvad’s own exclusion marks in both directions, so the kill switch would just naturally let it all through.

# /etc/nftables.d/mullvad_tailscale.conf

table inet excludeTraffic {
  chain excludeOutgoing {
    type route hook output priority -100; policy accept;
    ip daddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
  }
  chain excludeIncoming {
    type filter hook input priority -100; policy accept;
    ip saddr 100.64.0.0/10 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
  }
}

The excludeIncoming chain was something I missed earlier and because of that, return traffic from the tailnet didn’t get marked and I ended up with the same packet loss as before. Now the whole flow is properly Mullvad-excluded.

Then, looking into tailscale a little more, I realized I didn’t need the --snat-subnet-routes from before. The only thing I really needed to do was tell systemd-resolved to send .ts.net traffic queries to Tailscale’s resolver.

To do this, I added a NetworkManager dispatcher script at /etc/NetworkManager/dispatcher.d/99-tailscale-dns:

#!/usr/bin/env bash

INTERFACE="$1"
ACTION="$2"

if [[ "$INTERFACE" == "tailscale0" && ( "$ACTION" == "up" || "$ACTION" == "vpn-up" ) ]]; then
    sleep 1
    resolvectl domain tailscale0 "ts.net" "your-tailnet-name.ts.net"
    resolvectl default-route tailscale0 no
fi

Marked that script as executable with a sudo chmod +x /etc/NetworkManager/dispatcher.d/99-tailscale-dns and boom, everything now works even when Mullvad reconnects!


Comments

You can comment on this blog post by replying to this post using any ActivityPub/Fediverse account!