Limit Bandwidth on Linux with tc
Shape Linux network traffic with tc, HTB qdiscs, and fq_codel. Enforce per-interface and per-host bandwidth limits that survive reboots via systemd.
Before you start
- ▸Root or sudo access on the target machine
- ▸iproute2 installed (provides the tc command)
- ▸Knowledge of your interface name (use ip link show) and the IP addresses to shape
- ▸iperf3 recommended for testing rate limits
The Linux kernel's Traffic Control subsystem (tc) lets you shape, schedule, and police network traffic with surgical precision. Unlike crude firewall rules that drop packets, tc qdiscs (queuing disciplines) smooth bursts, enforce rate ceilings, and keep latency low — all in kernel space. This guide covers the two most useful qdiscs for rate limiting: HTB (Hierarchical Token Bucket) for guaranteed and ceiling rates, and fq_codel for fair queuing with automatic buffer-bloat control. You'll shape an entire interface and then drill down to per-host limits.
Concepts You Need First
Every network interface has a root qdisc. By default that is pfifo_fast (or fq_codel on newer kernels) — a simple first-in-first-out queue with no shaping. To rate-limit traffic you replace or attach to that root with a classful qdisc like HTB, then assign classes with bandwidth parameters, and optionally attach filters that steer packets into specific classes.
- qdisc — queuing discipline; the algorithm that holds and releases packets.
- class — a subdivision of a classful qdisc, each with its own rate parameters.
- filter — matches packet fields (IP address, port, mark) and directs packets to a class.
- handle — a numeric identifier written as
major:minor. The root is conventionally1:0; classes are1:10,1:20, etc.
All rates accept suffixes: kbit, mbit, gbit for bits per second, or kbps, mbps for bytes per second.
Prerequisites and Installation
tc lives in the iproute2 package, which is installed by default on virtually every modern distro. Confirm it is present and check the kernel modules you will need:
tc -Version
# HTB and fq_codel are compiled into most distro kernels.
# If you get "RTNETLINK answers: No such file or directory"
# load the modules manually:
modprobe sch_htb
modprobe sch_fq_codel
modprobe cls_u32
All tc commands require root. Use sudo or run as root. Replace eth0 with your actual interface name throughout — find it with ip link show.
Limit an Entire Interface with HTB
The simplest use case: cap all outbound traffic on eth0 to 50 Mbit/s. HTB is the right tool — it supports both a rate (guaranteed) and a ceil (maximum) per class, and unused bandwidth from one class can be lent to another.
Step 1 — Remove any existing root qdisc
Always start clean. This command is safe to run even if no qdisc exists:
tc qdisc del dev eth0 root 2>/dev/null || true
Step 2 — Attach the HTB root qdisc
tc qdisc add dev eth0 root handle 1: htb default 10
default 10 means unclassified traffic falls into class 1:10. Any handle you don't match with a filter lands there.
Step 3 — Create the root class and a child class
# Root class — total bandwidth ceiling for the interface
tc class add dev eth0 parent 1: classid 1:1 htb rate 50mbit ceil 50mbit
# Default child class — everything not matched by a specific filter
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 50mbit ceil 50mbit burst 15k
Step 4 — Attach fq_codel as the leaf qdisc
Attaching fq_codel to each leaf class gives you per-flow fair queuing and active queue management (AQM), which prevents buffer bloat inside the shaped queue:
tc qdisc add dev eth0 parent 1:10 handle 10: fq_codel
Verify the single-class setup
tc -s qdisc show dev eth0
tc -s class show dev eth0
You should see the HTB root at 1:, the class hierarchy, and fq_codel as the leaf. Generate traffic (iperf3, a large file transfer) and confirm the rate caps near 50 Mbit/s.
Per-Host Rate Limiting with HTB and u32 Filters
Now the more powerful scenario: different bandwidth ceilings for individual hosts on your LAN. For example, a server at 192.168.1.100 gets 20 Mbit/s, a workstation at 192.168.1.200 gets 10 Mbit/s, and everyone else shares the remainder up to 50 Mbit/s.
Step 1 — Set up the root and parent class
tc qdisc del dev eth0 root 2>/dev/null || true
tc qdisc add dev eth0 root handle 1: htb default 30
tc class add dev eth0 parent 1: classid 1:1 htb rate 50mbit ceil 50mbit
Step 2 — Create per-host classes
# Class for 192.168.1.100 — 20 Mbit/s guaranteed and ceiling
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 20mbit ceil 20mbit burst 15k
# Class for 192.168.1.200 — 10 Mbit/s
tc class add dev eth0 parent 1:1 classid 1:20 htb rate 10mbit ceil 10mbit burst 15k
# Default class — everyone else, up to 50mbit total
tc class add dev eth0 parent 1:1 classid 1:30 htb rate 10mbit ceil 50mbit burst 15k
Notice class 1:30 has rate 10mbit but ceil 50mbit: it can borrow unused bandwidth from sibling classes up to the parent ceiling.
Step 3 — Attach fq_codel to each leaf
tc qdisc add dev eth0 parent 1:10 handle 10: fq_codel
tc qdisc add dev eth0 parent 1:20 handle 20: fq_codel
tc qdisc add dev eth0 parent 1:30 handle 30: fq_codel
Step 4 — Add u32 filters to steer traffic
Filters run at the root qdisc and match on the destination IP (outbound traffic leaving the router toward the hosts). Adjust to match source IP if you are shaping traffic arriving from the hosts.
# Steer packets destined for 192.168.1.100 into class 1:10
tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
match ip dst 192.168.1.100/32 flowid 1:10
# Steer packets destined for 192.168.1.200 into class 1:20
tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
match ip dst 192.168.1.200/32 flowid 1:20
Unmatched packets fall into the default class 1:30 automatically.
Verify per-host shaping
tc -s filter show dev eth0
tc -s class show dev eth0
Run iperf3 -c 192.168.1.100 from a machine routing through eth0 and watch the throughput cap at 20 Mbit/s. The Sent and Dropped counters in tc -s class increment in real time.
Making Rules Persistent
tc rules live in memory and are lost on reboot. The cleanest approach is a shell script run by systemd.
cat > /usr/local/sbin/tc-shape.sh << 'EOF'
#!/bin/bash
DEV=eth0
tc qdisc del dev $DEV root 2>/dev/null || true
tc qdisc add dev $DEV root handle 1: htb default 30
tc class add dev $DEV parent 1: classid 1:1 htb rate 50mbit ceil 50mbit
tc class add dev $DEV parent 1:1 classid 1:10 htb rate 20mbit ceil 20mbit burst 15k
tc class add dev $DEV parent 1:1 classid 1:20 htb rate 10mbit ceil 10mbit burst 15k
tc class add dev $DEV parent 1:1 classid 1:30 htb rate 10mbit ceil 50mbit burst 15k
tc qdisc add dev $DEV parent 1:10 handle 10: fq_codel
tc qdisc add dev $DEV parent 1:20 handle 20: fq_codel
tc qdisc add dev $DEV parent 1:30 handle 30: fq_codel
tc filter add dev $DEV protocol ip parent 1:0 prio 1 u32 match ip dst 192.168.1.100/32 flowid 1:10
tc filter add dev $DEV protocol ip parent 1:0 prio 1 u32 match ip dst 192.168.1.200/32 flowid 1:20
EOF
chmod +x /usr/local/sbin/tc-shape.sh
cat > /etc/systemd/system/tc-shape.service << 'EOF'
[Unit]
Description=Traffic shaping rules
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/tc-shape.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now tc-shape.service
Troubleshooting
- Rules disappear after network restart: NetworkManager or systemd-networkd may reset the interface. Add
After=NetworkManager.serviceor use a NetworkManager dispatcher script in/etc/NetworkManager/dispatcher.d/. - Rate limit not enforced: Check that the filter is actually matching —
tc -s filter show dev eth0shows a packet count per filter. A zero count means packets aren't hitting the filter. Verify you are shaping in the correct direction (egress vs. ingress); HTB only applies to egress by default. - Ingress limiting:
tccannot shape inbound traffic with HTB directly. Use anifb(Intermediate Functional Block) device to redirect ingress to a virtual egress, then shape that. This is significantly more complex and warrants its own guide. - Too much CPU usage: Very large u32 filter tables are O(n). For dozens of hosts, use
cls_flowerortc-hashingfilters, or mark packets withnftablesand classify by mark. - Module not found errors: On minimal installs (containers, embedded) the kernel may lack
sch_htb. Confirm withmodinfo sch_htb; on RHEL/Rocky you may need thekernel-modules-extrapackage.
Frequently asked questions
- Does tc shape inbound (ingress) traffic the same way?
- No. HTB and most classful qdiscs only apply to egress (outbound) traffic. To shape ingress you must redirect it to an IFB (Intermediate Functional Block) virtual device and apply your qdisc there.
- What is the difference between rate and ceil in an HTB class?
- rate is the guaranteed bandwidth a class always receives. ceil is the maximum it can use when borrowing idle bandwidth from sibling classes. Setting them equal prevents any borrowing.
- Why use fq_codel instead of pfifo as the leaf qdisc?
- fq_codel provides per-flow fair queuing and active queue management that actively drops or ECN-marks packets when queue delay grows too large, preventing buffer bloat and keeping latency low under load.
- Will NetworkManager or systemd-networkd wipe my tc rules?
- Yes — any event that brings the interface down and back up will reset it. Make your systemd service depend on NetworkManager.service or use a NetworkManager dispatcher script to reapply rules after link-up events.
- How do I remove all shaping and return to the default qdisc?
- Run tc qdisc del dev eth0 root to delete the root qdisc and everything under it. The kernel automatically restores the default pfifo_fast or fq_codel qdisc.
Related guides
Build a Mesh VPN with Nebula
Build a fully self-hosted mesh VPN with Nebula: create a CA, sign node certs, configure lighthouses, enforce group-based firewall rules, and run as a systemd service.
Common Linux Network Ports Reference
Learn Linux port ranges, read /etc/services, find what's listening with ss and nmap, and apply solid firewall rules to expose or block the right ports.
How to Configure a Static IP on Linux
Configure a static IP on Linux using Netplan, NetworkManager (nmcli), or systemd-networkd across Ubuntu, Fedora, Debian, and Arch with verified steps.
Expose a Service with Cloudflare Tunnel
Expose local services to the internet without port-forwarding using Cloudflare Tunnel. Install cloudflared, create a named tunnel, configure ingress rules, and run as a systemd service.