--- title: "How to Limit Bandwidth with tc on Ubuntu 24.04" description: "Use Linux tc to cap upload and download speed on Ubuntu 24.04 with TBF, HTB classes and an IFB device, verify it with iperf3 and keep the rules after a reboot." category: "Network Configuration" language: en source: https://cubepath.com/docs/network-configuration/bandwidth-limiting-with-tc-traffic-control updated: 2026-09-25 --- # How to Limit Bandwidth with tc on Ubuntu 24.04 `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: | Term | What it is | |------|------------| | qdisc | Queueing discipline: the scheduler attached to an interface that decides when each packet leaves. | | class | A bucket inside a classful qdisc (like HTB) with its own rate. Classes form a tree. | | filter | A rule that sorts packets into classes (by port, IP, mark...). | | handle | The 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`: ```bash ip -br link ``` ```output lo UNKNOWN 00:00:00:00:00:00 eth0 UP bc:24:11:4a:7e:21 ``` The rest of this guide uses `eth0`. Replace it with your interface name in every command. Check which qdisc is active now: ```bash tc qdisc show dev eth0 ``` ```output 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**: ```bash sudo apt update sudo apt install iperf3 ``` On the second machine, start a server: ```bash 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: ```bash iperf3 -c second_server_ip ``` ```output [ 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): ```bash 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: ```bash 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: ```bash iperf3 -c second_server_ip ``` ```output [ 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: ```bash tc -s qdisc show dev eth0 ``` ```output 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: ```bash 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: | Class | Traffic | Guaranteed | Maximum | |-------|---------|------------|---------| | `1:10` | SSH (port 22) | 5 Mbit/s | 100 Mbit/s | | `1:20` | Web (ports 80 and 443) | 70 Mbit/s | 100 Mbit/s | | `1:30` | Everything else | 25 Mbit/s | 50 Mbit/s | Create the root HTB qdisc. `default 30` sends any packet that no filter matches to class `1:30`: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```bash tc -s class show dev eth0 ``` ```output 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: ```bash 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: ```bash 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`: ```bash 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: ```bash sudo tc qdisc add dev ifb0 root tbf rate 50mbit burst 128kb latency 50ms ``` Verify with a reverse test: ```bash iperf3 -c second_server_ip -R ``` ```output [ 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: ```bash 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: ```bash sudo tc qdisc replace dev eth0 root netem delay 100ms 20ms loss 1% ``` From the second machine, ping the server: ```bash ping -c 5 your_server_ip ``` ```output 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: ```bash 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: ```bash 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: ```bash #!/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: ```bash sudo chmod 755 /usr/local/sbin/tc-shaping.sh ``` Create the unit: ```bash sudo nano /etc/systemd/system/tc-shaping.service ``` ```ini [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: ```bash sudo systemctl daemon-reload sudo systemctl enable --now tc-shaping.service ``` Check that it ran without errors and that the classes exist: ```bash systemctl status tc-shaping.service tc class show dev eth0 ``` ```output ● 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: ```bash 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.