How to Set Up Traffic Shaping and QoS on Fedora

Configure traffic shaping and QoS on Fedora using tc commands to define bandwidth limits and prioritize specific network traffic classes.

Story / scenario opener

You are running a Fedora workstation or server that shares a single internet connection. A background process starts downloading a large ISO, or a backup job syncs to the cloud, and your SSH session freezes. Your VoIP calls break up. The link is not down, but it is completely saturated. You need to guarantee bandwidth for critical services while capping everything else. Traffic shaping solves this by telling the kernel exactly how to prioritize packets before they hit the wire.

What is actually happening

Linux handles outgoing network traffic through a queueing discipline, known as a qdisc. By default, Fedora uses a simple first-in-first-out queue. When the interface buffer fills, the kernel drops packets. Traffic shaping replaces that default queue with a hierarchical scheduler. The standard tool is tc, short for traffic control. tc does not modify your network hardware. It sits in the kernel network stack and sorts packets into classes. Each class receives a guaranteed minimum bandwidth and a maximum ceiling. Filters examine packet headers and route them into the correct class.

Think of it like a highway with dedicated lanes. Emergency vehicles get the fast lane. Delivery trucks get the middle lane. Everything else shares the slow lane. The kernel enforces the rules at the interface level, so the shaping works regardless of which application generates the traffic. The most common scheduler is HTB, or Hierarchical Token Bucket. HTB lets you create a tree of classes, assign bandwidth limits to each branch, and attach filters to sort traffic dynamically. The token bucket mechanism works by refilling a virtual bucket at a fixed rate. Each packet consumes tokens proportional to its size. When the bucket is empty, packets wait in a queue until tokens regenerate. This prevents sudden bursts from overwhelming the link while still allowing efficient pipe utilization.

How the kernel processes the packets

When an application sends data, the kernel routes it through the network stack. Before the packet reaches the physical driver, it hits the egress qdisc. The qdisc checks for attached filters. Filters run in priority order. The first matching filter assigns the packet to a specific class. If no filter matches, the qdisc routes the packet to the default class. The class then applies its rate and ceiling limits. If the class has enough tokens, the packet transmits immediately. If not, the packet queues until bandwidth becomes available. This pipeline runs entirely in kernel space, so it adds negligible CPU overhead.

Run tc qdisc show dev eth0 to see the current queueing discipline. The output will list the root qdisc and any child classes. Trust the package manager. Manual file edits drift, snapshots stay.

The fix

You will build a three-class HTB tree on your active interface. The tree will contain a default class that caps general traffic, a priority class that guarantees bandwidth for SSH and HTTP, and a filter that routes matching packets into the priority lane. Run these commands as root or with sudo.

First, identify your active interface. Fedora uses predictable network interface names, so your device might be eth0, enp3s0, or wlp2s0.

ip -br link show # List all interfaces and their operational states
# Replace eth0 in the following commands with your actual interface name

Clear any existing traffic control rules on that interface. Old rules will conflict with the new tree and cause silent failures.

sudo tc qdisc del dev eth0 root 2>/dev/null # Remove existing root qdisc if it exists
# The 2>/dev/null suppresses the error if no qdisc is currently attached

Attach the HTB scheduler as the root queueing discipline. This creates the foundation for all classes and filters.

sudo tc qdisc add dev eth0 root handle 1: htb default 30
# handle 1: assigns the root node an ID of 1
# default 30 routes unmatched traffic to class 1:30 automatically

Create the default class. This class handles all traffic that does not match your priority filters. Set a conservative rate to prevent background downloads from starving other services.

sudo tc class add dev eth0 parent 1: classid 1:30 htb rate 5mbit ceil 10mbit
# rate 5mbit guarantees this class gets at least 5 Mbps
# ceil 10mbit allows it to borrow unused bandwidth up to 10 Mbps

Create the priority class. This class will handle SSH, HTTP, and DNS traffic. Give it a higher guaranteed rate so interactive sessions stay responsive.

sudo tc class add dev eth0 parent 1: classid 1:10 htb rate 2mbit ceil 50mbit
# rate 2mbit ensures critical traffic never drops below this threshold
# ceil 50mbit lets it consume the full pipe when the default class is idle

Add a filter to route SSH traffic into the priority class. The u32 filter matches against IP headers and TCP/UDP ports.

sudo tc filter add dev eth0 parent 1: protocol ip prio 1 u32 \
  match ip dport 22 0xffff flowid 1:10
# protocol ip limits matching to IPv4 packets
# match ip dport 22 0xffff targets TCP port 22 exactly
# flowid 1:10 sends matched packets to the priority class

Add a second filter for HTTP and HTTPS traffic. Web browsing and API calls will now share the priority lane.

sudo tc filter add dev eth0 parent 1: protocol ip prio 2 u32 \
  match ip dport 80 0xffff flowid 1:10 \
  match ip dport 443 0xffff flowid 1:10
# prio 2 ensures this filter runs after the SSH filter
# Multiple match statements on one line are evaluated as AND conditions

Traffic control rules are volatile. They disappear when the interface goes down or the system reboots. Fedora uses systemd-networkd by default, which supports persistent tc configuration through .network files. Create a drop-in configuration in /etc/systemd/network/ to survive reboots.

sudo mkdir -p /etc/systemd/network/
sudo tee /etc/systemd/network/10-eth0-qos.network > /dev/null << 'EOF'
[Match]
Name=eth0

[Network]
# Leave DHCP or static IP configuration in the main .network file
# This file only adds traffic control rules

[QDisc]
Kind=htb
Default=30

[Class "1:10"]
Rate=2mbit
Ceil=50mbit

[Class "1:30"]
Rate=5mbit
Ceil=10mbit

[Filter]
Priority=1
Kind=u32
Match=ip dport 22 0xffff
Flow=1:10

[Filter]
Priority=2
Kind=u32
Match=ip dport 80 0xffff
Match=ip dport 443 0xffff
Flow=1:10
EOF
# /etc/ files are user-modified and survive package updates
# systemd-networkd reads this on boot and applies the rules automatically

Reload the network manager and restart the interface to apply the persistent configuration.

sudo systemctl restart systemd-networkd
# systemd-networkd will re-apply the .network file and restore the tc tree

Reboot before you debug. Half the time the symptom is gone once the persistent rules load cleanly.

Verify it worked

Check the queueing discipline and class statistics to confirm the kernel is actively shaping traffic.

tc -s qdisc show dev eth0
# The -s flag prints packet and byte counters for each qdisc
# Look for "htb" and verify the default class matches your configuration

Inspect the class tree to see bandwidth allocation and current usage.

tc -s class show dev eth0
# Each class shows sent bytes, dropped packets, and active queue length
# A healthy setup shows steady byte counters with zero drops under normal load

Generate test traffic to watch the filters route packets. Open two terminals. In the first, run a large download. In the second, run a lightweight SSH session or curl request. Watch the counters update. The priority class should show activity during the SSH session, while the default class absorbs the bulk of the download bytes.

watch -n 1 "tc -s class show dev eth0 | grep -A 3 '1:10\|1:30'"
# watch refreshes the output every second so you can observe live traffic sorting
# The priority class counters should increment during interactive sessions

Run journalctl -xeu systemd-networkd if the rules fail to apply on boot. The journal will show configuration parsing errors or interface mismatch warnings. Read the actual error before guessing.

Common pitfalls and what the error looks like

The tc command will refuse to add a class if the parent qdisc does not exist. You will see RTNETLINK answers: Operation not possible. Always attach the root qdisc before creating classes.

Filter syntax is strict. A missing 0xffff mask or a typo in the port number will cause the filter to match nothing. Traffic will silently fall through to the default class. Run tc filter show dev eth0 to verify your filter tree matches your intent.

Interface names change when hardware moves or when you switch between wired and wireless connections. Hardcoding eth0 in a script will break when Fedora assigns enp3s0 instead. Use ip -br link to confirm the active device before running shaping commands.

Confusing rate and ceil causes unexpected bottlenecks. rate is the guaranteed minimum. ceil is the maximum burst allowance. If you set both to the same value, the class becomes a rigid pipe that cannot borrow unused bandwidth. Leave ceil higher than rate to allow dynamic sharing.

Snapshot the system before the upgrade. Future-you will thank you when you need to roll back a broken network configuration.

When to use this vs alternatives

Use tc with HTB when you need fine-grained control over multiple traffic classes and explicit bandwidth guarantees. Use the cake qdisc when you want automatic bufferbloat mitigation without writing complex filter rules. Use wondershaper when you only need a simple global upload and download cap for a single interface. Push QoS to your router when multiple machines share the same physical link and you cannot install software on each one. Stay on the default FIFO queue if your connection has plenty of headroom and interactive latency is not a concern.

Where to go next