tc (traffic control) is the kernel's packet scheduler interface, shipped with the iproute2 package. It lets you cap how fast a server sends and receives data, reserve bandwidth for important traffic such as SSH, and even simulate latency and packet loss. In this tutorial you will measure a baseline with iperf3, apply a simple rate limit with TBF, build a class-based setup with HTB, shape incoming traffic through an IFB device and make the configuration persistent with a systemd unit on Ubuntu 24.04.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • A second machine you can run iperf3 on (another server is ideal) to measure throughput.
  • Access to the server's console (VNC or serial) in addition to SSH. A wrong tc rule can make your SSH session unusable, and the console is how you undo it.

tc only acts on the machine where you run it. It shapes what this server sends (egress) and, with the IFB trick in Step 5, what it receives (ingress).

Key concepts

You only need four terms to read every command in this guide:

TermWhat it is
qdiscQueueing discipline: the scheduler attached to an interface that decides when each packet leaves.
classA bucket inside a classful qdisc (like HTB) with its own rate. Classes form a tree.
filterA rule that sorts packets into classes (by port, IP, mark...).
handleThe ID of a qdisc or class, written major:minor, for example 1: or 1:10.

Rates in tc use bits: mbit means megabits per second. The suffix mbps means megabytes per second, eight times more, so be careful not to mix them up.

Step 1 - Identifying the interface and current qdisc

Find the name of your public interface. On most virtual servers it is eth0, ens3 or ens18:

ip -br link
lo               UNKNOWN        00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>
eth0             UP             bc:24:11:4a:7e:21 <BROADCAST,MULTICAST,UP,LOWER_UP>

The rest of this guide uses eth0. Replace it with your interface name in every command.

Check which qdisc is active now:

tc qdisc show dev eth0
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64

Ubuntu uses fq_codel by default. If you see mq with several child qdiscs, your NIC has multiple queues; that is fine, the root qdisc you add in the next steps replaces it. Deleting your custom root qdisc at any time restores the default.

Step 2 - Measuring a baseline with iperf3

Install iperf3 on both machines. When the installer asks whether to start the daemon automatically, answer No:

sudo apt update
sudo apt install iperf3

On the second machine, start a server:

iperf3 -s

If that machine uses UFW, allow the test port first with sudo ufw allow 5201/tcp.

On the server you are going to shape, run a 10 second test. Without options, the client sends data, so this measures upload (egress) from your server:

iperf3 -c second_server_ip
[ ID] Interval           Transfer     Bitrate         Retr
[  5]   0.00-10.00  sec  1.09 GBytes   938 Mbits/sec   12            sender
[  5]   0.00-10.00  sec  1.09 GBytes   936 Mbits/sec                  receiver

Add -R to reverse the direction and measure download (ingress):

iperf3 -c second_server_ip -R

Write down both numbers so you can compare them after each change.

Step 3 - Capping upload speed with TBF

The Token Bucket Filter (TBF) is the simplest way to limit an interface to a fixed rate. The following command limits everything eth0 sends to 100 Mbit/s:

sudo tc qdisc replace dev eth0 root tbf rate 100mbit burst 128kb latency 50ms
  • rate: the sustained speed.
  • burst: how many bytes can be sent at once above the rate. Too small a burst prevents reaching the configured rate; 128 KB works well for 100 Mbit/s.
  • latency: the maximum time a packet may wait in the queue before being dropped.

Using replace instead of add swaps the existing root qdisc in one step, so you do not get Exclusivity flag on errors.

Run the upload test again:

iperf3 -c second_server_ip
[  5]   0.00-10.00  sec   114 MBytes  95.4 Mbits/sec    0            sender

You can also see how many packets TBF has delayed or dropped:

tc -s qdisc show dev eth0
qdisc tbf 8001: root refcnt 2 rate 100Mbit burst 128Kb lat 50ms
 Sent 121548233 bytes 80321 pkt (dropped 214, overlimits 9543 requeues 0)
 backlog 0b 0p requeues 0

Remove the limit before moving on:

sudo tc qdisc del dev eth0 root

tc qdisc show dev eth0 now shows the default fq_codel again.

Step 4 - Sharing bandwidth between services with HTB

TBF treats all traffic the same. The Hierarchical Token Bucket (HTB) qdisc lets you split a total rate into classes with a guaranteed rate (rate) and a maximum they can borrow up to (ceil) when other classes are idle.

This example caps the server at 100 Mbit/s and splits it into three classes:

ClassTrafficGuaranteedMaximum
1:10SSH (port 22)5 Mbit/s100 Mbit/s
1:20Web (ports 80 and 443)70 Mbit/s100 Mbit/s
1:30Everything else25 Mbit/s50 Mbit/s

Create the root HTB qdisc. default 30 sends any packet that no filter matches to class 1:30:

sudo tc qdisc replace dev eth0 root handle 1: htb default 30

Add a parent class holding the total rate, then the three child classes:

sudo tc class add dev eth0 parent 1: classid 1:1 htb rate 100mbit ceil 100mbit
sudo tc class add dev eth0 parent 1:1 classid 1:10 htb rate 5mbit ceil 100mbit prio 0
sudo tc class add dev eth0 parent 1:1 classid 1:20 htb rate 70mbit ceil 100mbit prio 1
sudo tc class add dev eth0 parent 1:1 classid 1:30 htb rate 25mbit ceil 50mbit prio 2

Classes with a lower prio value get spare bandwidth first, which keeps SSH responsive under load.

Attach an fq_codel qdisc to each leaf class so that flows inside a class share it fairly and queues stay short:

sudo tc qdisc add dev eth0 parent 1:10 handle 10: fq_codel
sudo tc qdisc add dev eth0 parent 1:20 handle 20: fq_codel
sudo tc qdisc add dev eth0 parent 1:30 handle 30: fq_codel

Now add filters. Because you are shaping what the server sends, match the source port: replies from your SSH and web servers leave from ports 22, 80 and 443. The first three filters handle IPv4, the last three IPv6:

sudo tc filter add dev eth0 parent 1: protocol ip prio 1 u32 match ip sport 22 0xffff flowid 1:10
sudo tc filter add dev eth0 parent 1: protocol ip prio 1 u32 match ip sport 80 0xffff flowid 1:20
sudo tc filter add dev eth0 parent 1: protocol ip prio 1 u32 match ip sport 443 0xffff flowid 1:20
sudo tc filter add dev eth0 parent 1: protocol ipv6 prio 2 u32 match ip6 sport 22 0xffff flowid 1:10
sudo tc filter add dev eth0 parent 1: protocol ipv6 prio 2 u32 match ip6 sport 80 0xffff flowid 1:20
sudo tc filter add dev eth0 parent 1: protocol ipv6 prio 2 u32 match ip6 sport 443 0xffff flowid 1:20

Review the result and the per-class counters:

tc -s class show dev eth0
class htb 1:1 root rate 100Mbit ceil 100Mbit burst 1600b cburst 1600b
 Sent 48213377 bytes 33190 pkt (dropped 0, overlimits 0 requeues 0)
class htb 1:10 parent 1:1 leaf 10: prio 0 rate 5Mbit ceil 100Mbit burst 1600b cburst 1600b
 Sent 184322 bytes 1311 pkt (dropped 0, overlimits 0 requeues 0)
class htb 1:20 parent 1:1 leaf 20: prio 1 rate 70Mbit ceil 100Mbit burst 1600b cburst 1600b
 Sent 45109822 bytes 30874 pkt (dropped 0, overlimits 0 requeues 0)
class htb 1:30 parent 1:1 leaf 30: prio 2 rate 25Mbit ceil 50Mbit burst 1600b cburst 1600b
 Sent 2919233 bytes 1005 pkt (dropped 0, overlimits 0 requeues 0)

The Sent counter of 1:10 grows while you type in your SSH session, which confirms the filters work. An iperf3 -c second_server_ip test goes through the default class 1:30 and should now top out around 48 Mbit/s, the ceil of that class.

To list the filters you added:

tc filter show dev eth0

Step 5 - Limiting download speed with an IFB device

Linux can only queue packets on the way out. To shape incoming traffic, redirect it to an Intermediate Functional Block (IFB) device and apply a normal egress qdisc there.

Create and enable the IFB device:

sudo ip link add ifb0 type ifb
sudo ip link set ifb0 up

Attach an ingress qdisc to eth0 and redirect every incoming packet to ifb0:

sudo tc qdisc add dev eth0 handle ffff: ingress
sudo tc filter add dev eth0 parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev ifb0

Limit what leaves ifb0, which is what eth0 receives, to 50 Mbit/s:

sudo tc qdisc add dev ifb0 root tbf rate 50mbit burst 128kb latency 50ms

Verify with a reverse test:

iperf3 -c second_server_ip -R
[  5]   0.00-10.00  sec  57.8 MBytes  48.5 Mbits/sec                  receiver

Shaping ingress works because TCP senders slow down when packets are delayed or dropped. It does not protect the uplink from a flood: those packets have already crossed the network by the time tc sees them.

To remove the ingress limit:

sudo tc qdisc del dev eth0 ingress
sudo ip link del ifb0

Step 6 - Simulating latency and packet loss with netem (optional)

The netem qdisc adds delay, jitter and loss, which is useful to test how an application behaves on a bad link. Try it on a test server, not in production, because it affects your SSH session too:

sudo tc qdisc replace dev eth0 root netem delay 100ms 20ms loss 1%

From the second machine, ping the server:

ping -c 5 your_server_ip
64 bytes from your_server_ip: icmp_seq=1 ttl=62 time=108 ms
64 bytes from your_server_ip: icmp_seq=2 ttl=62 time=94.7 ms

Remove it when you are done:

sudo tc qdisc del dev eth0 root

Step 7 - Making the rules persistent

tc rules live in the kernel and are lost at reboot. The cleanest way to restore them is a small script run by a systemd unit.

Create the script:

sudo nano /usr/local/sbin/tc-shaping.sh

Paste the HTB configuration from Step 4. The script deletes any existing root qdisc first so it can be run more than once:

#!/usr/bin/env bash
set -euo pipefail

IFACE="eth0"

tc qdisc del dev "$IFACE" root 2>/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 ceil 100mbit
tc class add dev "$IFACE" parent 1:1 classid 1:10 htb rate 5mbit ceil 100mbit prio 0
tc class add dev "$IFACE" parent 1:1 classid 1:20 htb rate 70mbit ceil 100mbit prio 1
tc class add dev "$IFACE" parent 1:1 classid 1:30 htb rate 25mbit ceil 50mbit prio 2

tc qdisc add dev "$IFACE" parent 1:10 handle 10: fq_codel
tc qdisc add dev "$IFACE" parent 1:20 handle 20: fq_codel
tc qdisc add dev "$IFACE" parent 1:30 handle 30: fq_codel

tc filter add dev "$IFACE" parent 1: protocol ip prio 1 u32 match ip sport 22 0xffff flowid 1:10
tc filter add dev "$IFACE" parent 1: protocol ipv6 prio 2 u32 match ip6 sport 22 0xffff flowid 1:10

for port in 80 443; do
  tc filter add dev "$IFACE" parent 1: protocol ip prio 1 u32 match ip sport "$port" 0xffff flowid 1:20
  tc filter add dev "$IFACE" parent 1: protocol ipv6 prio 2 u32 match ip6 sport "$port" 0xffff flowid 1:20
done

Make it executable:

sudo chmod 755 /usr/local/sbin/tc-shaping.sh

Create the unit:

sudo nano /etc/systemd/system/tc-shaping.service
[Unit]
Description=Traffic shaping with tc
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/tc-shaping.sh
ExecStop=/usr/sbin/tc qdisc del dev eth0 root

[Install]
WantedBy=multi-user.target

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now tc-shaping.service

Check that it ran without errors and that the classes exist:

systemctl status tc-shaping.service
tc class show dev eth0
● tc-shaping.service - Traffic shaping with tc
     Loaded: loaded (/etc/systemd/system/tc-shaping.service; enabled; preset: enabled)
     Active: active (exited) since Thu 2026-09-24 10:12:31 UTC; 3s ago

Reboot once and run tc class show dev eth0 again to confirm the rules come back. sudo systemctl stop tc-shaping.service removes them.

Troubleshooting

Error: Exclusivity flag on, cannot modify. A root qdisc already exists. Use tc qdisc replace, or delete it first with sudo tc qdisc del dev eth0 root.

Error: Specified qdisc kind is unknown. or Cannot find device "ifb0" The kernel module (sch_netem, ifb and others) is not installed. On Ubuntu's generic kernel some of them are in a separate package:

sudo apt install linux-modules-extra-$(uname -r)

SSH stops responding after a change. Log in through the console and run sudo tc qdisc del dev eth0 root (and sudo tc qdisc del dev eth0 ingress if you used IFB).

The measured rate is well below the configured rate. The burst is too small for the rate. Increase it (for example burst 256kb for 500 Mbit/s and above) and test again.

Traffic lands in the wrong HTB class. Check the counters with tc -s filter show dev eth0 and remember that on egress you match source ports of your own services, not destination ports.

Conclusion

You measured your server's throughput, capped it with TBF, split bandwidth between services with HTB, limited downloads through an IFB device and made the setup survive reboots with systemd. As next steps, match traffic by firewall mark (fw filter with nftables meta mark set) for more complex rules, read man tc-htb and man tc-netem for all options, and monitor the class counters regularly to tune the rates to your real traffic.