Mullvad VPN Setup with Wireguard for Qubes 4.2+ (with killswitch)

Hello here is a guide I wrote on how to configure mullvad vpn for Qubes 4.2+ if it can help anyone. I have tried it and it works with Qubes 4.3. Dont hesitate to suggest anything.

Markdown guide below:

Mullvad VPN on Qubes OS

Full Traffic Routing with Hardened Kill Switch

Goal: All AppVM traffic exits exclusively through Mullvad. If the VPN drops for any
reason, traffic is blocked at multiple layers — not rerouted, not leaked.


Architecture

AppVMs (work, disp-*)
        │
        ▼
  sys-vpn-work          ← Runs Mullvad, enforces kill switch
  [orange, ProxyVM]
        │
        ▼
  sys-firewall          ← Only allows Mullvad endpoints outbound
  [green, FirewallVM]
        │
        ▼
    sys-net             ← Physical network hardware
  [red, NetVM]
        │
        ▼
    Internet

Why this layering matters:

  • sys-net is the most exposed qube — it touches raw network hardware directly
  • sys-firewall isolates sys-net from everything else and enforces outbound rules
  • sys-vpn-work runs the VPN and kill switch — AppVMs have no path around it
  • AppVMs are completely unaware of the underlying network — they only see the VPN tunnel

Kill Switch — Three Independent Layers

Do not rely on a single mechanism. Use all three. Any one of them alone stops leaks.

Layer Where What it does
Layer 1 Mullvad Lockdown mode Mullvad’s nftables blocks all non-VPN traffic when disconnected; lan=allow permits vif+ interfaces
Layer 2 qubes-firewall-user-script in sys-vpn-work Drops any forwarded traffic exiting eth0 — survives Mullvad crashing
Layer 3 sys-firewall Qubes rules Enforced outside sys-vpn-work; only Mullvad server endpoints are allowed outbound

If Mullvad crashes → Layer 2 blocks eth0 leaks.
If qubes-firewall-user-script fails → Layer 1 has already blocked non-VPN traffic inside sys-vpn-work.
If sys-vpn-work is compromised → Layer 3 blocks at sys-firewall before it reaches the internet.


Prerequisites

  • Qubes OS 4.2 or later installed
  • sys-net, sys-firewall already running (default Qubes setup)
  • Mullvad account number (get from mullvad.net — no email required)
  • Debian 13 minimal template installed (debian-13-minimal)

Step 1 — Create a Dedicated VPN Template

Never install VPN software in a shared template. A dedicated template means only
sys-vpn-work inherits Mullvad — no other qube is affected.

In dom0 terminal:

# Install the template if not already present
qvm-template install debian-13-minimal

# Clone it to a dedicated VPN template
qvm-clone debian-13-minimal t-mullvad

Minimal templates have no sudo or passwordless root by default.
Open a root terminal from dom0 instead of a normal user terminal:

In dom0 terminal:

qvm-run -u root t-mullvad xterm

Inside that root xterm (no sudo needed), install the required packages:

apt update
apt install --no-install-recommends \
    qubes-core-agent-passwordless-root \
    qubes-core-agent-networking \
    inotify-tools \
    curl \
    ca-certificates \
    iproute2
  • qubes-core-agent-passwordless-root — enables sudo in all subsequent steps
  • qubes-core-agent-networking — required for sys-vpn-work to function as a network provider; without it, AppVMs connected to it will have no network
  • inotify-tools — provides inotifywait, used by the DNS fix script to watch /etc/resolv.conf for changes and keep Qubes’ DNS DNAT rules in sync with Mullvad’s current DNS server

Close the root xterm. All further steps use a normal user terminal in t-mullvad (sudo now works).


Step 2 — Install Mullvad in the Template

Templates have no direct network access by default in Qubes. The qubes-updates-proxy
only handles apt/dnf traffic — curl requests fail with “Could not resolve host”.
Temporarily assign a netvm before running curl, then remove it after installation.

In dom0, grant temporary network access to the template:

qvm-prefs t-mullvad netvm sys-firewall

Still inside t-mullvad:

# Download Mullvad signing key
sudo curl -fsSLo /usr/share/keyrings/mullvad-keyring.asc \
    https://repository.mullvad.net/deb/mullvad-keyring.asc

# Add Mullvad repository
# Note: Mullvad uses a fixed 'stable' suite name — do not use $(lsb_release -cs) here
echo "deb [signed-by=/usr/share/keyrings/mullvad-keyring.asc arch=$(dpkg --print-architecture)] https://repository.mullvad.net/deb/stable stable main" \
    | sudo tee /etc/apt/sources.list.d/mullvad.list

sudo apt update
sudo apt install mullvad-vpn

# Enable daemon at boot
sudo systemctl enable mullvad-daemon

# Remove network access from the template — templates should not have persistent network
# Run this in dom0 after installation is complete
# qvm-prefs t-mullvad netvm ''

Once installation is done, run this in dom0 to remove the template’s network:

qvm-prefs t-mullvad netvm ''

Step 3 — Configure Mullvad in the Template

These settings are written to /etc/mullvad-vpn/ in the template and inherited by
sys-vpn-work on every start (Qubes AppVMs mount the template’s root read-only).

The daemon must be running before any mullvad commands are issued.
It is only enabled at boot after Step 2 — it is not yet started.
Running mullvad lan set allow without a running daemon silently fails and the
setting is never written to disk. This is the most common reason AppVM traffic
is blocked even after all firewall rules appear correct.

# Start the daemon now so commands below are accepted
sudo systemctl start mullvad-daemon

# Log in — account number only, no email
mullvad account login YOUR_ACCOUNT_NUMBER

# WireGuard is the default and only supported protocol — verify it is active
mullvad tunnel get

# Sweden exit servers — outside Five Eyes, off NSA backbone infrastructure
mullvad relay set location se

# Use Mullvad's own DNS — prevents DNS leaks to your ISP
mullvad dns set default

# REQUIRED for Qubes ProxyVMs: permit traffic from virtual interfaces (vif+).
# Mullvad's built-in nftables blocks all traffic from private IP ranges (10.x, 192.168.x)
# by default. AppVM virtual interfaces are in the 10.x.x.x range. Without this setting,
# ALL downstream AppVM traffic is dropped at Mullvad's own table — no iptables or nft
# rule changes inside sys-vpn-work can override this.
mullvad lan set allow

# Enable Lockdown mode — this is the Layer 2 kill switch.
# With lan=allow already active, Lockdown mode correctly permits vif+ traffic while
# blocking any non-VPN outbound traffic. Recommended by privsec.dev and forum.qubes-os.org.
mullvad lockdown-mode set on

# Connect automatically when the qube starts
mullvad auto-connect set on

# Verify
mullvad status
mullvad account get

Confirm it shows Connected before continuing.

Now create the DNS fix script. Mullvad changes /etc/resolv.conf dynamically — different
DNS IPs depending on the server and DNS filtering options selected. This script watches that
file and updates Qubes’ ip qubes dnat-dns chain so downstream AppVMs always use the
correct DNS. The script lives in the template so sys-vpn-work inherits it.

sudo tee /usr/local/bin/mullvad-dns.sh << 'EOF'
#!/usr/bin/env bash
# Watches /etc/resolv.conf and keeps ip qubes dnat-dns in sync with Mullvad's current DNS.

update_dns() {
    # Mullvad DNS is NOT in the 10.139.x.x range (that is Qubes-internal)
    mullvad_on=$([[ $(grep -vc 'nameserver[[:space:]]\+10\.139' /etc/resolv.conf) -gt 0 ]] && echo 1 || echo 0)

    if [[ $mullvad_on -eq 1 ]]; then
        mullvad_dns_ip=$(awk '/nameserver/ {print $2; exit}' /etc/resolv.conf)
        sudo nft flush chain ip qubes dnat-dns
        sudo nft add rule ip qubes dnat-dns meta l4proto { tcp, udp } ip daddr { 10.139.1.1, 10.139.1.2 } th dport 53 dnat to "$mullvad_dns_ip"
    else
        nameserver_ips=$(awk '/nameserver/ {print $2}' /etc/resolv.conf)
        sudo nft flush chain ip qubes dnat-dns
        for ip in $nameserver_ips; do
            sudo nft add rule ip qubes dnat-dns ip daddr "$ip" udp dport 53 dnat to "$ip"
            sudo nft add rule ip qubes dnat-dns ip daddr "$ip" tcp dport 53 dnat to "$ip"
        done
    fi
}

update_dns
inotifywait -m -q -e close_write /etc/resolv.conf | while read -r; do
    update_dns
done
EOF
sudo chmod +x /usr/local/bin/mullvad-dns.sh

Stop the daemon and shut down the template:

sudo systemctl stop mullvad-daemon
sudo shutdown now

Step 4 — Create sys-vpn-work ProxyVM

In dom0 terminal:

# Create the ProxyVM using the dedicated template
qvm-create --class AppVM \
    --template t-mullvad \
    --label orange \
    sys-vpn-work

# Mark as network provider — this makes it a ProxyVM
qvm-prefs sys-vpn-work provides_network true

# Set its upstream to sys-firewall (not sys-net directly)
qvm-prefs sys-vpn-work netvm sys-firewall

# Give it enough RAM for the Mullvad daemon
qvm-prefs sys-vpn-work memory 400
qvm-prefs sys-vpn-work maxmem 600

Step 5 — Layer 2 Kill Switch: Custom iptables in sys-vpn-work

Mullvad’s lockdown-mode is designed for end-user machines, not Qubes ProxyVMs.
In a ProxyVM, AppVM traffic arrives on vif+ interfaces and must be forwarded through
the VPN tunnel. These custom rules handle that correctly and add a second kill switch layer.

The kill switch is Mullvad’s own Lockdown mode (set in Step 3) — it uses nftables
in Mullvad’s own table to block all non-VPN traffic, independent of the app’s state.
mullvad lan set allow (also Step 3) ensures Mullvad’s table permits traffic from
vif+ interfaces before Lockdown mode applies.

These two files in sys-vpn-work add a backup layer and fix DNS:

Why /rw/config/: This is the AppVM’s own persistent volume. It survives
restarts unlike /usr/local/bin/ or any path outside /rw and /home.

Part A — rc.local: Start DNS Fix at Boot

sudo tee /rw/config/rc.local << 'EOF'
#!/bin/bash
/usr/local/bin/mullvad-dns.sh &
EOF
sudo sed -i 's/\r//' /rw/config/rc.local
sudo chmod +x /rw/config/rc.local

Part B — qubes-firewall-user-script: Backup Kill Switch + MTU Fix

Runs after every qubes-firewall update (every time an AppVM connects).

  • Lines 1–2: Backup kill switch. Even if Mullvad crashes, no AppVM traffic can
    leak to the physical eth0 interface. These use Qubes’ own custom-forward chain.
  • Line 3: MTU fix for WireGuard — clamps TCP MSS to prevent oversized packets
    breaking specific sites (DuckDuckGo, some banking sites, etc.).
sudo tee /rw/config/qubes-firewall-user-script << 'EOF'
#!/bin/bash
nft add rule qubes custom-forward oifname eth0 counter drop
nft add rule ip6 qubes custom-forward oifname eth0 counter drop
nft add rule ip qubes custom-forward tcp flags syn / syn,rst tcp option maxseg size set rt mtu
EOF
sudo sed -i 's/\r//' /rw/config/qubes-firewall-user-script
sudo chmod +x /rw/config/qubes-firewall-user-script

Apply both immediately:

sudo /rw/config/rc.local
sudo /rw/config/qubes-firewall-user-script

Step 6 — Layer 3 Kill Switch: sys-firewall Rules

sys-firewall is the last line of defense. Even if everything inside sys-vpn-work
fails, sys-firewall only allows connections to Mullvad endpoints outbound.

In Qubes Manager:

  1. Right-click sys-vpn-workFirewall rules
  2. Set default policy to Drop
  3. Add the following allow rules:
Action Protocol Destination Port Purpose
Allow UDP any 51820 WireGuard (primary)
Allow UDP any 53 Mullvad DNS
Allow TCP any 443 Mullvad fallback
Allow UDP any 443 Mullvad QUIC fallback

Setting destination to “any” for these ports is acceptable because all outbound
traffic from sys-vpn-work on these ports goes to Mullvad server IPs only —
the Mullvad daemon handles routing. If you want maximum restriction, download
the Mullvad server IP list and restrict to those ranges.

Block everything else:

Ensure the default rule is Drop — any connection attempt on a port not listed above
is rejected at sys-firewall before it ever reaches the internet.


Step 7 — Set sys-vpn-work as Default NetVM

To ensure all system network traffic passes on the VPN first, route both standard AppVMs and Whonix Gateway through sys-vpn-work.

In dom0 terminal:

# Set as global default for all new qubes
qubes-prefs default_netvm sys-vpn-work

# Assign to existing standard qubes
qvm-prefs work          netvm sys-vpn-work
qvm-prefs personal      netvm sys-vpn-work

# Assign to the default disposable template
qvm-prefs default-dvm   netvm sys-vpn-work

# ROUTE WHONIX (TOR) THROUGH THE VPN:
# This forces all Tor traffic to go through the VPN first (User -> VPN -> Tor)
qvm-prefs sys-whonix    netvm sys-vpn-work

:lock: Whonix over VPN Security Considerations (User → VPN → Tor):

  • Why do this: Hides Tor usage from your local network and ISP. Your ISP only sees encrypted traffic going to a Mullvad server, completely obscuring that you are using Tor/Whonix. It also acts as defense-in-depth if a Tor vulnerability targets your entry Guard node.
  • What to keep in mind: It does not hide your identity from Mullvad (who can see your true IP and that you are using Tor). It does not increase anonymity on the destination website. Always use a reputable, privacy-focused VPN like Mullvad that does not require personal details or store logs.

Do not assign vault qubes to any NetVM. They must have netvm set to none.

qvm-prefs vault-work    netvm ''
qvm-prefs vault-personal netvm ''

Step 8 — Verify Everything Works

Run these tests in order. Each one should behave exactly as described.

Test 1: VPN is connected and shows Swedish IP

Open terminal in work qube:

curl https://am.i.mullvad.net/json

Expected: Swedish IP address, mullvad_exit_ip: true

Test 2: Kill switch blocks traffic when VPN disconnects

In sys-vpn-work:

mullvad disconnect

In work qube:

curl --max-time 5 https://am.i.mullvad.net/json

Expected: connection times out — no response, no real IP shown

Reconnect:

mullvad connect

Test 3: No DNS leaks

Open terminal in work qube:

# All DNS servers shown should be Mullvad's — not your ISP
curl https://am.i.mullvad.net/json | grep dns

Expected: Only Mullvad DNS servers listed

Test 4: Vault qubes have no network

In vault-work:

curl --max-time 3 https://example.com

Expected: immediate failure — no network interface available

Test 5: iptables rules are applied

In sys-vpn-work:

sudo iptables -L -n -v | head -40

Expected: Default policies show DROP, rules show allow on wg0-mullvad and vif+


sys-firewall — What It Should and Should Not Do

What sys-firewall SHOULD do

Task Why
Isolate sys-net from all other qubes sys-net is the most exposed qube — nothing should talk to it directly
Enforce per-qube outbound rules Each qube only reaches what it needs
Block all outbound from sys-vpn-work except Mullvad ports Layer 3 kill switch — last hardware-level barrier
Block inbound connections not initiated by a qube Default deny inbound
Stay minimal — no extra software installed Its only job is routing and firewall rules
Remain always-on, auto-start with the system Every qube’s traffic passes through it

What sys-firewall SHOULD NOT do

Task Why not
Run a VPN That is sys-vpn-work’s job — mixing roles breaks the isolation model
Browse the internet sys-firewall should have no AppVM use case
Store any data No user files, no persistent state beyond firewall rules
Run any user applications Increases attack surface on the most critical routing qube
Be used as a NetVM for vault qubes Vault qubes must have no network at all
Have its firewall rules set to “allow all” Defeats the entire purpose of the layer

sys-firewall Rule Philosophy

Think of sys-firewall as a default-deny border router, not a permissive gateway.
Every qube that connects through it should have an explicit allowlist — not an open pipe.

The default Qubes sys-firewall is configured to allow all outbound by default. Change this.
For each qube using sys-firewall, open Firewall rules and restrict to only what that qube needs:

Qube Allowed outbound
sys-vpn-work UDP 51820, TCP/UDP 443, UDP 53 only
Any qube directly on sys-firewall (not behind VPN) Nothing — there should be none

Qubes Best Practices Summary

Practice Rule
dom0 Never install software, never browse, never open files from other qubes
Templates One template per purpose — never share templates between sensitive and less-sensitive qubes
sys-net Red label, minimal software, never use for anything except network hardware management
sys-firewall Green label, default-deny, no user applications
sys-vpn-work Orange label, only runs Mullvad daemon, provides network to all AppVMs
AppVMs Each qube has minimum necessary network access and software
Vault qubes Black label, NetVM = none, no exceptions
Disposables Use for opening any untrusted file, link, or attachment — destroyed after use
Updates Apply template updates promptly — templates are the shared attack surface
Shutdown Shut down qubes not in use — a qube that is not running cannot be attacked

Troubleshooting

AppVM has no internet after setup:

  • Mullvad LAN setting: Ensure Mullvad isn’t blocking local virtual interfaces (vif+). Run mullvad lan get in sys-vpn-work — it must show allow or allowed. If not, run mullvad lan set allow.
  • IP Forwarding: Verify that IP forwarding is enabled inside sys-vpn-work with cat /proc/sys/net/ipv4/ip_forward (it should display 1).
  • vif+ Input block (DNS Dropped): Ensure rc.local has the critical rules allowing DNS requests on vif+ interfaces on port 53. If DNS is blocked, AppVMs cannot resolve any hostnames.
  • Check Connected Status: Run mullvad status in sys-vpn-work — must show Connected.
  • Verify iptables rules: Run sudo iptables -L FORWARD -n -v in sys-vpn-work — confirm vif+ and wg+ rules exist and have packets passing through.
  • Set correct NetVM: Verify AppVM’s NetVM is set to sys-vpn-work in dom0 (qvm-prefs <appvm-name> netvm).

mullvad status shows Disconnected on sys-vpn-work start:

  • Check sudo systemctl status mullvad-daemon — daemon must be running
  • Check the t-mullvad template has mullvad-daemon enabled: sudo systemctl is-enabled mullvad-daemon
  • Check auto-connect is on: mullvad auto-connect get

rc.local not running on startup:

  • Verify it is executable: ls -la /rw/config/rc.local
  • Check Qubes rc.local service: sudo systemctl status qubes-rc.local
  • Check for script errors: sudo bash -x /rw/config/rc.local

Kill switch test leaks real IP:

  • iptables rules may not have applied — recheck rc.local executed cleanly.
  • sys-firewall rules may be too permissive — re-verify firewall configuration in dom0.

Built for Qubes OS 4.2/4.3

1 Like