I’ve had some issues getting a VPN VM running NymVPN to work on Qubes OS (4.3.1). The issue was that while ping traffic would get through, DNS was being blocked by Nym. FYI, this solution uses a Standalone VM running Debian 13 and has only been tested with two-hop WireGuard. I take no credit for the answer, which was provided by an LLM (GLM 5.3):
First, a diagnosis of your actual ruleset
Before giving you rules, it’s worth being precise about where the block actually is, because it’s easy to aim at the wrong layer here.
The nftables forward path is mostly already open. Nym’s own inet nym forward chain contains, before its final reject:
oif “tun0” accept
iif “tun0” ct state established accept
oif “tun1” accept
iif “tun1” ct state established accept
So a packet from vif14.0 destined out tun0/tun1 is accepted by Nym’s chain, and the Qubes chains (ip qubes, ip qubes-firewall) also pass it (the only drop rules there target oifgroup 2 / oifname “eth0”). Source NAT is already handled — table ip qubes postrouting masquerades the vif source to the tun address, so the Nym client sees its “own” IPs. Routing is also fine: the policy rule 32765: not from all fwmark 0x14d lookup 333 steers all unmarked traffic (i.e., everything except Nym’s own gateway connections) into table 333 → the tunnel. This is the standard “fwmark killswitch” pattern also used by the Qubes VPN guides (forum.qubes-os.org, forum.qubes-os.org).
What actually breaks is DNS — and it breaks in three places at once:
- Your AppVM resolves via 10.139.1.1/10.139.1.2, which the dnat-dns chain forwards upstream. But with the tunnel up, rule 32765 routes those upstream queries into table 333 → out tun0 — where 10.139.1.1 is meaningless. Blackholed.
- Any DNS that does traverse Nym’s chains dies: in both inet nym forward and inet nym output, the blanket udp dport 53 reject / tcp dport 53 reject with tcp reset rules sit before the oif “tun*” accepts. Nym only whitelists TCP DNS to 9.9.9.9, 149.112.112.112, 1.1.1.1, 1.0.0.1 via tun1.
- inet nym input has policy drop and accepts nothing from vif*, so even locally-answered DNS would be dropped.
No name resolution → from the AppVM it looks like “all traffic is blocked,” even though the transit path itself is open.
Two structural constraints you can’t get around:
- You cannot override a reject in someone else’s table from your own table. nftables traverses all base chains at a hook in priority order, and an accept verdict in one base chain does not stop evaluation of later base chains — only drop is final across chains. So a custom table can carry NAT/DNAT rules, but the permissive forward rule must be inserted into inet nym itself.
- Nym builds/flushes its own table when the tunnel comes up. Anything you put there at boot gets wiped. So /etc/nftables.conf alone (loaded once by nftables.service) cannot be the whole answer; the insert into inet nym has to happen after Nym connects (and be re-applied on reconnects).
The fix
- /etc/nftables.conf — DNS redirection (persistent, own table)
Append this to /etc/nftables.conf (make sure the file does not contain a flush ruleset line — that would wipe the Qubes tables), and enable nftables.service. dstnat - 10 makes it run before Qubes’ dnat-dns chain so our translation wins:
#!/usr/sbin/nft -f
table ip qubes-nym-dns {
chain prerouting {
type nat hook prerouting priority dstnat - 10; policy accept;
iifname “vif*” udp dport 53 counter dnat ip to 9.9.9.9
iifname “vif*” tcp dport 53 counter dnat ip to 9.9.9.9
}
}
9.9.9.9 is one of the resolvers Nym itself whitelists, so it’s the natural target; 149.112.112.112 or 1.1.1.1/1.0.0.1 work too. This catches all DNS from any downstream VM (including hardcoded resolvers) and sends it through the tunnel.
- The forward-permitting rule — must be inserted into inet nym after connect
One-shot, to test right now with the tunnel up:
sudo nft insert rule inet nym forward iifname “vif*” oifname “tun*” counter accept
insert puts it at the head of the chain, so it matches before Nym’s DNS reject rules and its final reject. This single rule covers everything from vif* into both tunnels, DNS included.
Because Nym recreates its table on every connect, wrap it in a watcher. /usr/local/sbin/nym-vif-allow.sh:
#!/bin/bash
while true; do
if nft list table inet nym >/dev/null 2>&1; then
if ! nft list chain inet nym forward 2>/dev/null |
grep -qF ‘iifname “vif*” oifname “tun*”’; then
nft insert rule inet nym forward
iifname “vif*” oifname “tun*” counter accept
fi
fi
sleep 5
done
/etc/systemd/system/nym-vif-allow.service:
[Unit]
Description=Permit Qubes vif* forwarding into Nym tunnel
After=multi-user.target
[Service]
Type=simple
ExecStart=/usr/local/sbin/nym-vif-allow.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Then sudo systemctl enable --now nym-vif-allow.service.
Return traffic needs nothing extra: Nym’s iif “tun*” ct state established accept and Qubes’ ct state established,related accept rules already cover it.
Verify
sudo nft list chain inet nym forward # rule on top; watch the counter climb
sudo nft list ruleset ip qubes-nym-dns # DNS DNAT counters
#in the AppVM:
ping 9.9.9.9 # raw IP — tests forwarding without DNS
dig example.com # tests the DNS path
If ping-by-IP works but dig doesn’t, check sudo tcpdump -ni tun0 port 53 to see whether Nym’s exit actually carries UDP/53 (see caveat below).
Caveats worth knowing
- Mixnet vs WireGuard mode: if after this fix TCP works but UDP doesn’t, you’re likely running the mixnet mode, which is TCP-only end-to-end. WireGuard (two-hop) mode is what you want for transparent forwarding of arbitrary traffic from other VMs.
- MTU: tun1 is 1340. The Qubes custom-forward chain already MSS-clamps TCP (tcp option maxseg size set rt mtu), so TCP self-adjusts; large UDP datagrams (QUIC) may still black-hole. If you see that, set the downstream MTU accordingly rather than fighting it in nftables.
- Fail-open on disconnect: you noted that traffic forwards normally (i.e., in the clear via eth0) when the VPN is down. If you want fail-closed instead, add to your qubes-nym-dns table a forward chain with iifname “vif*” oifname “eth0” counter drop at priority filter - 10 — this is the leakage-prevention step the standard Qubes VPN guides emphasize (forum.qubes-os.org, forum.qubes-os.org).
- IPv6: downstream IPv6 is already dropped by the ip6 qubes antispoof chain (its allowed set is empty), and the tunnels only carry link-local v6 — so there’s no v6 leak path to worry about.
- Why not pure /etc/nftables.conf: nothing in a separate table can preempt Nym’s reject rules (all base chains at a hook are traversed; accept isn’t final across tables), and Nym rebuilds its table at every connection — hence the watcher pattern above. The persistent part (DNS DNAT) lives in /etc/nftables.conf; the part that must live in Nym’s table is re-inserted automatically.
End quote.
So, the upshot is that “you cannot override a reject in someone else’s table from your own table.” And “Nym builds/flushes its own table when the tunnel comes up. Anything you put there at boot gets wiped. So /etc/nftables.conf alone (loaded once by nftables.service) cannot be the whole answer.”
To allow DNS, we need one line added to the top of the Nym nft forward chain and a new rule in /etc/nftables.conf. If Nym developers could add that line for Qubes users, that would remove the need for the ugly hack of having to poll nft to see if the line has been removed.
Bottom line: we have a working solution to using NymVPN in a VPN VM on Qubes. It works, but it’s currently a bit hacky. With some help from Nym devs, it should be a lot easier, going forward.