You cannot exempt a program from a full-tunnel WireGuard host by editing routes. Routes select a path by destination, and what you want to exempt is a process, so the kernel has nothing to match on. The workable approach is to give that process its own network context: a namespace with its own interface, addresses and default route, joined to the host by a veth pair. I ran this on Ubuntu 24.04 with wireguard-tools 1.0.20210914, and the namespace was never the problem — the host's forwarding policy and the tunnel's marking rules were.

Choose the kind of context you need first

Two shapes show up in practice. If the host runs a full tunnel and one program should leave by the ISP path, you build a second namespace, connect it to the host, and let the host forward and translate for it. If a namespace already carries a VPN interface and you only need a command started inside it, that is the plain ip netns exec case and most of the routing work is already done.

Neither shape gives you a rule scoped to a single process id. Netfilter matches addresses, ports, marks and interfaces; there is no per-process routing table. Marking can steer traffic, but the mark has to be applied to packets you already identified, which is the same problem one layer down. A namespace separates the whole stack for that process tree, and that is why it is the usual answer.

If the requirement is really that nothing leaves the tunnel, stop the tunnel instead. The namespace exists for the mixed case: one program out, everything else in, on the same machine, at the same time.

Build the second context and join it to the host

An illustrative layout: the host has a WireGuard interface carrying the default route, and a network namespace called clearnet linked to the host by a veth pair. The addresses below are examples.

ip netns add clearnet
ip link add veth-clear-out type veth peer name veth-clear-in
ip addr add 192.168.100.1/24 dev veth-clear-out
ip link set veth-clear-out up
ip link set veth-clear-in netns clearnet
ip netns exec clearnet ip addr add 192.168.100.2/24 dev veth-clear-in
ip netns exec clearnet ip link set veth-clear-in up
ip netns exec clearnet ip route add default via 192.168.100.1

That namespace can now reach the host and nothing else. The host has to forward the traffic and rewrite the source address, because 192.168.100.0/24 is private and the upstream router has no route back into it:

sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o <wan-interface> -j MASQUERADE

Confirm the namespace side before going further. ip netns exec clearnet ip route should show the veth route and the default through 192.168.100.1, and a ping to that address should answer. If ping to the host address fails, the addresses or the link state are wrong and no firewall rule will help.

When names resolve but nothing comes back, read FORWARD

The classic failure looks like a routing bug: the namespace resolves a name, the packet leaves, and no reply ever arrives. On hosts that ship a FORWARD policy of DROP — several distributions and most hardened images do — the packet is dropped on the way through, and the error the application prints says nothing about filtering.

Read the chain before adding anything:

iptables -S FORWARD
nft list chain inet filter forward

Then admit the traffic in both directions, with counters, so you can tell whether the rule ever matched:

iptables -A FORWARD -i veth-clear-out -o <wan-interface> -j ACCEPT
iptables -A FORWARD -i <wan-interface> -o veth-clear-out -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
watch -n1 'iptables -L FORWARD -v -n'

A rule with a zero packet counter is not in the path. Either the interface names differ from what you assumed, or an earlier rule in the chain accepted, dropped or returned the packet first. Fix the order, not the rule text.

Reply traffic is the half people forget. The return rule needs the same veth name plus a connection state match, or a plain interface accept if you are keeping the policy open. Add only the outbound rule and you get the original symptom back, with your new rule sitting there looking correct.

Keep the tunnel's marking rules off the new path

This step turns a working namespace into a leaking one. WireGuard setups commonly install a firewall mark policy plus their own routing rules and a dedicated table, so unmarked packets fall back to the main table while marked packets use the tunnel table. Forwarded traffic from the namespace is evaluated against policy routing on its way out, and depending on how those rules were written it can land in the tunnel table.

Two additions separate the paths. Return early in the mangle chain for the namespace subnet, and add a source-based rule that puts that subnet in the main table:

iptables -t mangle -I PREROUTING -s 192.168.100.0/24 -j RETURN
ip rule add from 192.168.100.0/24 lookup main priority 100

Priority 100 sorts ahead of the tunnel rules on my host, so the ordinary table decides for that source. Do not copy the number blindly: the priorities that wg-quick installs differ between versions and between the IPv4 and IPv6 setups, so run ip rule show and pick a priority that lands in front of the tunnel rules on your machine.

Verify with a source-specific lookup rather than by trusting the rule list:

ip route get 1.1.1.1 from 192.168.100.2
ip netns exec clearnet ip route get 1.1.1.1

If the first command reports the WireGuard interface, the new rule is not in front of the tunnel policy yet. If it reports the WAN interface while the second still shows the veth default, the host and the namespace disagree and you should reread both routing tables.

Start the process that opens the connection

Starting the command is one line, but start the process that actually creates the socket. ip netns exec clearnet curl -s https://ifconfig.me is the smallest honest test: it shows the address the namespace presents to the world, not the one the host would use.

For a service, attach the namespace by path so the process stays in it for its whole life:

[Service]
NetworkNamespacePath=/run/netns/clearnet

Graphical applications are where the one-line approach quietly fails. A browser launched from a menu often hands the request to an instance already running outside the namespace, so the new window appears and the traffic still leaves by the tunnel. Give it a separate profile so a new instance really is created, then check the process context instead of guessing:

readlink /proc/$(pgrep -f clearnet-profile | head -1)/ns/net
readlink /run/netns/clearnet

The two outputs must match. The same check works for any long-running program, and it is worth doing once per application rather than assuming the launcher did what you meant.

Name resolution needs its own decision. Creating a namespace does not give it a resolver configuration; per-namespace files are supported through /etc/netns/<name>/resolv.conf, and without one you inherit behaviour that is easy to misread. IPv6 needs the same treatment: if the namespace has no v6 default route while the application prefers v6, requests can stall before falling back, which looks like a broken tunnel.

Check the framing, not just the exit address

A working public address proves the route, not the whole requirement. Walk the rest in this order:

  • ip netns exec clearnet ip route get 1.1.1.1 shows the veth default, not the WireGuard interface.
  • The FORWARD counters on both rules increase while a request runs.
  • The application process reports the namespace inode you expect.
  • After a reboot the namespace exists before the service starts; a unit that runs earlier fails, or worse, runs outside the namespace.

Then decide what should happen when the tunnel drops. The namespace keeps working, which is the point of the exercise and also the risk: the exempt program stays connected while everything else stops. Record that as a policy choice, and be clear about what the exemption does not cover — account identity, saved browser state and your DNS provider all sit outside the scope of a network namespace, and treating the exemption as anonymity is the mistake that outlives the configuration.

Barruulee wal qabatan