Why I Started Looking at WireGuard QoS
I run WireGuard tunnels from my home network to a couple of remote sites. One of them has a terrible connection—high latency, packet loss, and bandwidth that drops randomly. SSH sessions would freeze whenever something else decided to use the tunnel. A file transfer would start, and suddenly I couldn't type commands anymore.
This wasn't theoretical. I'd be mid-command on a remote server, hit a key, and wait five seconds for it to appear. Meanwhile, a backup script was shoving data through the same tunnel with no regard for my interactive session.
I needed a way to prioritize SSH traffic over bulk transfers without adding more complexity than necessary. That meant working with what Linux already provides: tc (traffic control) and potentially eBPF if I needed more control.
My Setup
The core machine is a Proxmox host running a VM that acts as the WireGuard endpoint. It's an Ubuntu 22.04 VM with 2 CPU cores and 2GB of RAM. Nothing fancy.
The WireGuard interface is wg0, configured with a simple peer-to-peer topology. One endpoint is my home network, the other is the remote site with the unstable connection. The tunnel uses a /24 subnet, and both sides route specific traffic through it.
I'm not using this for full internet routing—just specific services and SSH access to machines on the remote side.
First Attempt: Basic tc with netem
I started by testing whether tc could even affect traffic on the WireGuard interface. The simplest way to confirm this was to add artificial delay using the netem qdisc.
Here's what I ran on the WireGuard endpoint:
sudo tc qdisc add dev wg0 root netem delay 500ms
This added a flat 500ms delay to everything passing through wg0. I tested it by pinging the remote side and watching the round-trip time jump by exactly 500ms. It worked, which meant tc could manipulate WireGuard traffic just like any other interface.
To remove it:
sudo tc qdisc del dev wg0 root
This confirmed the basics, but didn't solve my problem. I needed selective prioritization, not blanket delays.
Setting Up Priority-Based Queuing
The next step was to use a priority qdisc to separate traffic into different classes. The idea: put SSH traffic in a high-priority queue and everything else in a lower-priority one.
I configured a priority qdisc with three bands (queues):
sudo tc qdisc add dev wg0 root handle 1: prio bands 3 priomap 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
The priomap values determine which band traffic goes into by default. I set everything to band 1 (medium priority) initially, then used filters to override this for specific traffic.
To prioritize SSH (port 22), I added a filter:
sudo tc filter add dev wg0 protocol ip parent 1:0 prio 1 u32 match ip dport 22 0xffff flowid 1:1
This says: if the destination port is 22, send it to band 1:1 (the highest priority queue). I also added a similar rule for source port 22 to catch return traffic:
sudo tc filter add dev wg0 protocol ip parent 1:0 prio 1 u32 match ip sport 22 0xffff flowid 1:1
Everything else stayed in band 1:2 (default) or 1:3 (lowest priority).
Testing the Priority Queue
I tested this by starting an SSH session to the remote side and then running a large file transfer through the same tunnel using scp.
Before the tc rules, the SSH session would become unresponsive within seconds. After applying the priority queue, I could still type commands and see responses, even with the transfer running.
It wasn't perfect—there was still some lag—but it was usable. The key difference was that SSH packets were being transmitted first, before the bulk transfer could flood the queue.
To verify what was happening, I checked the qdisc statistics:
sudo tc -s qdisc show dev wg0
This showed packet counts for each band. Band 1:1 had SSH traffic, and the others had everything else. The numbers confirmed packets were being separated correctly.
Adding Rate Limiting to Prevent Queue Overflow
Priority queuing helped, but it didn't prevent the lower-priority traffic from overwhelming the connection. If the bulk transfer tried to push 10Mbps through a 2Mbps link, the queue would still fill up and cause delays.
I added a token bucket filter (TBF) to band 1:2 to limit the rate of non-SSH traffic:
sudo tc qdisc add dev wg0 parent 1:2 handle 20: tbf rate 1mbit burst 32kbit latency 400ms
This capped the bulk transfer rate at 1Mbps, leaving headroom for SSH packets in the high-priority queue. The burst value allowed short bursts above the rate limit, and latency controlled how long packets could wait before being dropped.
I chose 1Mbps based on the observed stable throughput of the unstable connection. It wasn't scientific—I just watched the transfer speeds over a few days and picked a conservative value.
What Didn't Work
I tried using the Hierarchical Token Bucket (HTB) qdisc instead of the simple priority queue. HTB is more flexible and allows nested classes, but it was overkill for this setup. The configuration was more complex, and the results weren't noticeably better.
I also experimented with marking packets using iptables and then filtering based on the mark. This worked, but it added an extra layer of rules that didn't provide any real benefit over matching ports directly.
Another failed attempt: trying to use tc on the client side instead of the WireGuard endpoint. This didn't work because the client doesn't see the individual flows—it just sees encrypted WireGuard packets. The filtering has to happen where the traffic is decrypted, which is at the WireGuard endpoint.
Looking at eBPF
After getting tc working, I wanted to see if eBPF could offer more control. eBPF programs can inspect and modify packets at various points in the network stack, and they can make decisions based on more than just port numbers.
I wrote a simple eBPF program that attached to the wg0 interface and logged packet headers. The program used the XDP (eXpress Data Path) hook, which processes packets before they even reach the network stack.
The code was basic—just enough to confirm I could intercept packets and read their contents. I used bpftool to load and attach the program:
sudo bpftool prog load xdp_prog.o /sys/fs/bpf/xdp_wg
sudo bpftool net attach xdp id $(sudo bpftool prog show name xdp_prog -j | jq '.[0].id') dev wg0
The program logged SSH packets to a trace pipe, which I could read with:
sudo cat /sys/kernel/debug/tracing/trace_pipe
This worked, but I didn't take it further. The tc solution was already doing what I needed, and writing a full eBPF-based QoS system would have been a much larger project.
eBPF is powerful, but it's also more complex. Unless you need programmability that tc can't provide, it's probably not worth the effort.
Current State
The setup I'm running now uses the priority qdisc with port-based filters for SSH and a TBF rate limit on bulk traffic. It's been stable for several months.
SSH sessions remain responsive even when large file transfers are running. The connection still has its underlying issues—latency spikes, occasional packet loss—but the QoS rules prevent those problems from making the tunnel completely unusable.
I haven't needed to adjust the configuration since the initial setup. The rate limit of 1Mbps for bulk traffic has been sufficient, and the priority queue handles the rest.
Key Takeaways
tc works on WireGuard interfaces. There's nothing special about WireGuard that prevents traffic control from functioning. Treat wg0 like any other network interface.
Priority queuing is straightforward. The prio qdisc is simple to configure and effective for basic traffic separation. You don't need complex class hierarchies for most use cases.
Rate limiting prevents queue overflow. Prioritization alone doesn't stop bulk traffic from filling the queue. Adding a rate limit to lower-priority traffic keeps the high-priority queue responsive.
eBPF is powerful but unnecessary here. For port-based filtering and basic QoS, tc is sufficient. eBPF makes sense if you need more complex logic or want to avoid the overhead of tc filters.
Test on the WireGuard endpoint, not the client. QoS rules need to see the decrypted traffic, which means they have to run where the WireGuard interface terminates.
Unstable connections need conservative limits. Don't assume the connection can handle its theoretical maximum. Observe real-world behavior and set limits based on what actually works.