Network Quality of Service (QoS) lets you control how bandwidth is distributed across different types of traffic, ensuring that latency-sensitive applications like VoIP or interactive SSH sessions remain responsive even when bulk transfers saturate the link. The Linux tc (traffic control) subsystem implements this through a combination of queuing disciplines, classes, and packet filters. This tutorial shows you how to set up a Hierarchical Token Bucket (HTB) qdisc on a RHEL 9 server, classify traffic, and persist the rules across reboots.

Prerequisites

  • RHEL 9 with root access
  • The iproute package installed (provides tc): dnf install -y iproute
  • iperf3 for bandwidth testing: dnf install -y iperf3
  • A known network interface name — this tutorial uses ens3; replace with your interface
  • Basic understanding of CIDR notation and TCP/IP

Step 1 — Understand tc Concepts

Before issuing commands, understand the three key building blocks:

  • qdisc (queuing discipline): Attached to a network interface; decides how packets are held and released. The root qdisc is the entry point. HTB is the most common classful qdisc for bandwidth management.
  • class: A subdivision of a classful qdisc. Each class has a guaranteed rate and an optional ceil (burst ceiling). Classes form a tree — child classes borrow unused bandwidth from their parent.
  • filter: Matches packets to a class using criteria such as IP address, port, DSCP marking, or connection mark.

Inspect the current state of your interface before making changes:

# Show current qdiscs
tc qdisc show dev ens3

# Show current classes (empty if no classful qdisc is set)
tc class show dev ens3

# Show current filters
tc filter show dev ens3

Step 2 — Add a Root HTB Qdisc

Replace the default pfifo_fast qdisc with HTB. The default 30 parameter sends unmatched traffic to class 1:30 (the bulk class created in the next step):

# Remove any existing root qdisc
tc qdisc del dev ens3 root 2>/dev/null || true

# Add HTB root qdisc
# handle 1: assigns the major number 1 to this qdisc
# default 30 routes unclassified traffic to class 1:30
tc qdisc add dev ens3 root handle 1: htb default 30

# Confirm
tc qdisc show dev ens3

Step 3 — Add Bandwidth Classes

Create a hierarchy: a root class (1:1) representing the full link, then child classes for interactive traffic (1:10), standard traffic (1:20), and bulk/background traffic (1:30). Assume a 100 Mbit/s uplink:

# Root class — represents the total link capacity
tc class add dev ens3 parent 1:  classid 1:1  htb rate 100mbit

# Interactive class — guaranteed 50 Mbit, may burst to 100 Mbit
# Use for SSH, DNS, VoIP, latency-sensitive services
tc class add dev ens3 parent 1:1 classid 1:10 htb rate 50mbit ceil 100mbit prio 1

# Standard class — guaranteed 30 Mbit, may burst to 100 Mbit
tc class add dev ens3 parent 1:1 classid 1:20 htb rate 30mbit ceil 100mbit prio 2

# Bulk class — guaranteed 10 Mbit, may burst to 100 Mbit
# Unclassified traffic defaults here (see htb default 30)
tc class add dev ens3 parent 1:1 classid 1:30 htb rate 10mbit ceil 100mbit prio 3

# Add SFQ (stochastic fair queuing) leaf qdiscs for fairness within each class
tc qdisc add dev ens3 parent 1:10 handle 10: sfq perturb 10
tc qdisc add dev ens3 parent 1:20 handle 20: sfq perturb 10
tc qdisc add dev ens3 parent 1:30 handle 30: sfq perturb 10

# Verify
tc class show dev ens3

Step 4 — Add Filters to Match Traffic

Filters classify packets into classes. The u32 filter can match on any field in the IP header:

# Direct traffic destined for 192.168.1.100 to the interactive class
tc filter add dev ens3 protocol ip parent 1:0 prio 1 
    u32 match ip dst 192.168.1.100/32 
    flowid 1:10

# Direct SSH traffic (destination port 22) to the interactive class
tc filter add dev ens3 protocol ip parent 1:0 prio 2 
    u32 match ip protocol 6 0xff 
       match ip dport 22 0xffff 
    flowid 1:10

# Direct HTTP/HTTPS traffic to the standard class
tc filter add dev ens3 protocol ip parent 1:0 prio 3 
    u32 match ip protocol 6 0xff 
       match ip dport 80 0xffff 
    flowid 1:20

tc filter add dev ens3 protocol ip parent 1:0 prio 4 
    u32 match ip protocol 6 0xff 
       match ip dport 443 0xffff 
    flowid 1:20

# Verify filters
tc filter show dev ens3

Step 5 — Test with iperf3

Run iperf3 tests to verify that bandwidth limits are being applied as expected:

# On a second machine, start the iperf3 server
iperf3 -s

# On the RHEL 9 server, test upload throughput toward a bulk destination
iperf3 -c  -t 30 -P 4

# Watch real-time class statistics while the test runs
watch -n1 tc -s class show dev ens3

The -s flag in tc -s class show outputs packet counters and byte counts for each class, letting you confirm that traffic is landing in the expected class.

Step 6 — Persist the QoS Rules at Boot

Create a shell script and a systemd service so the rules survive reboots:

# Create the script
cat > /usr/local/sbin/setup-qos.sh </dev/null || true
tc qdisc add dev $IFACE root handle 1: htb default 30
tc class add dev $IFACE parent 1:  classid 1:1  htb rate 100mbit
tc class add dev $IFACE parent 1:1 classid 1:10 htb rate 50mbit  ceil 100mbit prio 1
tc class add dev $IFACE parent 1:1 classid 1:20 htb rate 30mbit  ceil 100mbit prio 2
tc class add dev $IFACE parent 1:1 classid 1:30 htb rate 10mbit  ceil 100mbit prio 3
tc qdisc add dev $IFACE parent 1:10 handle 10: sfq perturb 10
tc qdisc add dev $IFACE parent 1:20 handle 20: sfq perturb 10
tc qdisc add dev $IFACE parent 1:30 handle 30: sfq perturb 10
tc filter add dev $IFACE protocol ip parent 1:0 prio 1 u32 match ip dport 22 0xffff flowid 1:10
tc filter add dev $IFACE protocol ip parent 1:0 prio 2 u32 match ip dport 443 0xffff flowid 1:20
EOF
chmod +x /usr/local/sbin/setup-qos.sh

# Create a systemd service
cat > /etc/systemd/system/network-qos.service << 'EOF'
[Unit]
Description=Network QoS (tc HTB)
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/setup-qos.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now network-qos.service

Conclusion

You now have a fully functional HTB-based QoS setup on RHEL 9 that guarantees bandwidth for interactive services while allowing bulk traffic to use spare capacity. The SFQ leaf qdiscs ensure fairness among flows within each class. As your network grows, you can add more classes, apply DSCP-based matching, or integrate with nftables marks for more granular control.

Next steps: How to Implement DSCP Traffic Marking with nftables on RHEL 9, How to Monitor Network Bandwidth Per Process with nethogs on RHEL 9, and How to Set Up a Bandwidth-Limited Bridge with tc on RHEL 9.