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-netis the most exposed qube — it touches raw network hardware directlysys-firewallisolatessys-netfrom everything else and enforces outbound rulessys-vpn-workruns 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-firewallalready 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
sudoor 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— enablessudoin all subsequent stepsqubes-core-agent-networking— required forsys-vpn-workto function as a network provider; without it, AppVMs connected to it will have no networkinotify-tools— providesinotifywait, used by the DNS fix script to watch/etc/resolv.conffor 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 handlesapt/dnftraffic —curlrequests 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
mullvadcommands are issued.
It is only enabled at boot after Step 2 — it is not yet started.
Runningmullvad lan set allowwithout 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/rwand/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 physicaleth0interface. These use Qubes’ owncustom-forwardchain. - 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:
- Right-click
sys-vpn-work→ Firewall rules - Set default policy to Drop
- 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
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
netvmset tonone.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 getinsys-vpn-work— it must showalloworallowed. If not, runmullvad lan set allow. - IP Forwarding: Verify that IP forwarding is enabled inside
sys-vpn-workwithcat /proc/sys/net/ipv4/ip_forward(it should display1). - vif+ Input block (DNS Dropped): Ensure
rc.localhas the critical rules allowing DNS requests onvif+interfaces on port 53. If DNS is blocked, AppVMs cannot resolve any hostnames. - Check Connected Status: Run
mullvad statusinsys-vpn-work— must show Connected. - Verify iptables rules: Run
sudo iptables -L FORWARD -n -vinsys-vpn-work— confirmvif+andwg+rules exist and have packets passing through. - Set correct NetVM: Verify AppVM’s NetVM is set to
sys-vpn-workin 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-daemonenabled:sudo systemctl is-enabled mullvad-daemon - Check
auto-connectis 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