Your backup job kicks off at 9 PM and suddenly your VoIP call turns into a robot reading you a ransom note. Or your VPS starts hitting transfer limits halfway through the month because one containerized service decided to become a firehose. Or you’re running a small shared server and one tenant’s bulk upload saturates the entire NIC.
You’ve probably looked at tc before and immediately closed the man page. It’s notoriously dense. The terminology is opaque, the command syntax is hostile, and most tutorials either stop at "here’s a basic example" or drown you in kernel internals. Neither helps you get actual work done.
This article skips the theory lectures and gives you battle-tested recipes. By the end, you’ll understand the mental model well enough to adapt them, and you’ll have rules that survive a reboot.
The Mental Model (Just Enough Theory)
tc works on egress (outgoing) traffic by default. It attaches a qdisc (queuing discipline) to a network interface. A qdisc is the algorithm that decides how packets wait and leave.
There are two kinds:
- Classless qdiscs — simple algorithms like
fq_codelorpfifo_fast. No subdivision possible. - Classful qdiscs — they have classes (subdivisions), and you can attach filters to direct packets into the right class. HTB is the one you want.
HTB (Hierarchical Token Bucket) lets you build a tree of traffic classes. Each class gets:
rate— the guaranteed bandwidth floorceil— the maximum it can borrow when parent bandwidth is idle
Unused bandwidth flows up and gets redistributed. A class sitting at rate 10mbit ceil 100mbit will get 10 Mbit guaranteed but can burst to 100 Mbit if nobody else is using the pipe. This is the "borrow" mechanic and it’s what makes HTB actually useful.
Filters sit at the root and steer packets into classes. The most common filter type is u32, which matches on IP headers — source/destination address, port, protocol.
That’s the whole model. Root qdisc → classes → filters → leaf qdiscs (optional, for fairness within a class).
Prerequisites
tc is part of iproute2, which ships on every serious Linux distro:
# Debian/Ubuntu
apt install iproute2
# RHEL/Rocky/Alma
dnf install iproute-tc
# Verify
tc -Version
For the ingress shaping recipe later, you’ll also need the ifb kernel module:
modprobe ifb
Check what’s already attached to an interface:
tc qdisc show dev eth0
tc class show dev eth0
tc filter show dev eth0
On a fresh system you’ll see qdisc fq_codel 0: root or qdisc pfifo_fast 0: root — the defaults. We’ll replace those.
Recipe 1: Cap Total Egress on a VPS
The simplest use case. You have a 1 Gbit VPS but want to cap egress at 50 Mbit to stay within your monthly transfer budget.
#!/bin/bash
# Simple egress cap — set IFACE and RATE for your environment
IFACE=eth0
RATE=50mbit
# Remove any existing root qdisc
tc qdisc del dev $IFACE root 2>/dev/null
# Add HTB root qdisc, default class 10
tc qdisc add dev $IFACE root handle 1: htb default 10
# Root class — set to your actual link speed
tc class add dev $IFACE parent 1: classid 1:1 htb rate 1gbit ceil 1gbit
# Single class capped at RATE
tc class add dev $IFACE parent 1:1 classid 1:10 htb rate $RATE ceil $RATE
# Add SFQ leaf for fairness within the class (prevents single-flow starvation)
tc qdisc add dev $IFACE parent 1:10 handle 10: sfq perturb 10
The default 10 on the root qdisc tells HTB to shove all unclassified traffic into class 1:10. Since there’s only one class, everything gets capped. Clean and predictable.
SFQ (Stochastic Fairness Queuing) as a leaf qdisc is a habit worth forming. Without it, one TCP flow can hog an entire class’s allocation. SFQ hashes flows and round-robins them, preventing that.
Recipe 2: Traffic Prioritization (The Real HTB Power Move)
This is the recipe that solves the "backup job kills VoIP" problem. We split egress into priority tiers and guarantee each one a floor, with borrowing allowed up to the link ceiling.
Scenario: 100 Mbit uplink, home server. We want:
- High priority (SSH, DNS, VoIP/RTP): 30 Mbit guaranteed, can borrow up to 100 Mbit
- Default traffic (HTTP/HTTPS): 50 Mbit guaranteed, up to 100 Mbit
- Bulk/background (backups, torrents, rsync): 10 Mbit guaranteed, up to 40 Mbit
#!/bin/bash
IFACE=eth0
LINK=100mbit # Your actual uplink
tc qdisc del dev $IFACE root 2>/dev/null
# Root HTB — unmatched traffic falls into class 1:20 (default)
tc qdisc add dev $IFACE root handle 1: htb default 20
# Parent class — total link budget
tc class add dev $IFACE parent 1: classid 1:1 htb rate $LINK ceil $LINK
# --- High priority: SSH (22), DNS (53), RTP (16384-32767)
tc class add dev $IFACE parent 1:1 classid 1:10 htb rate 30mbit ceil 100mbit prio 1
tc qdisc add dev $IFACE parent 1:10 handle 10: sfq perturb 10
# --- Default traffic
tc class add dev $IFACE parent 1:1 classid 1:20 htb rate 50mbit ceil 100mbit prio 2
tc qdisc add dev $IFACE parent 1:20 handle 20: sfq perturb 10
# --- Bulk / background
tc class add dev $IFACE parent 1:1 classid 1:30 htb rate 10mbit ceil 40mbit prio 3
tc qdisc add dev $IFACE parent 1:30 handle 30: sfq perturb 10
# ---- FILTERS ----
# SSH
tc filter add dev $IFACE parent 1:0 protocol ip prio 1 u32 \
match ip dport 22 0xffff flowid 1:10
# DNS
tc filter add dev $IFACE parent 1:0 protocol ip prio 1 u32 \
match ip dport 53 0xffff flowid 1:10
# RTP (VoIP UDP range — adjust to your softphone)
tc filter add dev $IFACE parent 1:0 protocol ip prio 1 u32 \
match ip dport 16384 0xc000 flowid 1:10
# rsync and common backup ports → bulk
tc filter add dev $IFACE parent 1:0 protocol ip prio 2 u32 \
match ip dport 873 0xffff flowid 1:30
# BitTorrent default port → bulk
tc filter add dev $IFACE parent 1:0 protocol ip prio 2 u32 \
match ip dport 6881 0xffff flowid 1:30
Gotcha:
prioontc classandprioontc filterare different things. Class priority determines borrowing preference when bandwidth is scarce. Filter priority determines the order in which filters are evaluated — lower number wins first. Don’t confuse them.
Gotcha: The RTP port range match
match ip dport 16384 0xc000uses a bitmask.0xc000masks the top two bits, matching the range 16384–32767. If your VoIP app uses a different range, adjust accordingly. Test withtcpdumpfirst.
Recipe 3: Per-IP Rate Limiting
You’re running a home server or small LAN. One device — a NAS doing a cloud sync, or a teenager with an obsession for 4K streams — is monopolizing the uplink. You want to cap that specific IP.
#!/bin/bash
IFACE=eth0
LINK=200mbit
GREEDY_IP=192.168.1.42
GREEDY_CAP=20mbit # Maximum this device can use
DEFAULT_FLOOR=50mbit # Floor for everyone else
tc qdisc del dev $IFACE root 2>/dev/null
tc qdisc add dev $IFACE root handle 1: htb default 20
tc class add dev $IFACE parent 1: classid 1:1 htb rate $LINK ceil $LINK
# The restricted device
tc class add dev $IFACE parent 1:1 classid 1:10 \
htb rate $GREEDY_CAP ceil $GREEDY_CAP prio 2
tc qdisc add dev $IFACE parent 1:10 handle 10: sfq perturb 10
# Everyone else (default)
tc class add dev $IFACE parent 1:1 classid 1:20 \
htb rate $DEFAULT_FLOOR ceil $LINK prio 1
tc qdisc add dev $IFACE parent 1:20 handle 20: sfq perturb 10
# Match the restricted IP's source traffic
tc filter add dev $IFACE parent 1:0 protocol ip prio 1 u32 \
match ip src $GREEDY_IP/32 flowid 1:10
If you’re shaping at the router (the Linux box is the gateway), the source IP is the LAN device. If you’re shaping at a server behind a router, you’ll match destination IPs instead (ip dst).
To restrict multiple IPs or a subnet:
# Entire subnet gets capped together
tc filter add dev $IFACE parent 1:0 protocol ip prio 1 u32 \
match ip src 192.168.2.0/24 flowid 1:10
Recipe 4: Ingress Shaping — The IFB Trick
Here’s the thing nobody tells you upfront: tc natively only controls egress. You can’t directly rate-limit incoming traffic because by the time the kernel sees it, it’s already arrived. The standard workaround is the IFB (Intermediate Functional Block) virtual interface.
The trick: redirect all incoming packets on eth0 to ifb0, then apply egress shaping on ifb0. The kernel sees it as outgoing traffic on the virtual interface, so HTB can shape it.
#!/bin/bash
IFACE=eth0
IFB=ifb0
INGRESS_CAP=100mbit
# Load the IFB module and bring up the interface
modprobe ifb numifbs=1
ip link set dev $IFB up
# ---- Redirect ingress on eth0 to ifb0 ----
tc qdisc del dev $IFACE ingress 2>/dev/null
tc qdisc add dev $IFACE handle ffff: ingress
# Mirror all incoming traffic to ifb0
tc filter add dev $IFACE parent ffff: protocol ip u32 \
match u32 0 0 \
action mirred egress redirect dev $IFB
# ---- Now shape on ifb0 as if it were egress ----
tc qdisc del dev $IFB root 2>/dev/null
tc qdisc add dev $IFB root handle 1: htb default 10
tc class add dev $IFB parent 1: classid 1:1 htb rate $INGRESS_CAP ceil $INGRESS_CAP
tc class add dev $IFB parent 1:1 classid 1:10 htb rate $INGRESS_CAP ceil $INGRESS_CAP
tc qdisc add dev $IFB parent 1:10 handle 10: sfq perturb 10
Gotcha: The IFB redirect uses
action mirred egress redirect, notmirror.mirrorduplicates —redirectmoves the packet. Use redirect or you’ll see every packet twice.
Gotcha:
modprobe ifb numifbs=1only creates one IFB interface. If you need to shape ingress on multiple physical interfaces simultaneously, usenumifbs=2(or however many you need) and assign them individually.
Debugging Your Rules
After applying rules, verify they’re attached and watch the counters:
# Show all qdiscs
tc -s qdisc show dev eth0
# Show classes with byte/packet counters
tc -s class show dev eth0
# Show filters
tc filter show dev eth0
# Watch counters update live (no -s flag, but watch it)
watch -n1 'tc -s class show dev eth0'
The -s flag gives you statistics: bytes sent, packets, drops, overlimits. If a class shows overlimits climbing, that class is hitting its ceil and dropping packets — expected behavior, not a bug.
To test your shaping is actually working, iperf3 is your friend:
# On a remote server
iperf3 -s
# On the shaped machine
iperf3 -c remote-server -t 30
Compare the reported throughput against your configured ceil.
Making Rules Persistent
tc rules evaporate on reboot. There are a few ways to persist them.
Option A: systemd service (cleanest)
Save your shaping script to /usr/local/bin/tc-shaping.sh, make it executable, then:
# /etc/systemd/system/tc-shaping.service
[Unit]
Description=Traffic shaping rules
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/tc-shaping.sh
RemainAfterExit=yes
# Flush on stop — optional but clean
ExecStop=/bin/bash -c 'tc qdisc del dev eth0 root 2>/dev/null; tc qdisc del dev eth0 ingress 2>/dev/null'
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now tc-shaping
Option B: NetworkManager dispatcher
If you’re on a desktop or a system where interfaces come and go:
# /etc/NetworkManager/dispatcher.d/99-tc-shaping
#!/bin/bash
IFACE=$1
ACTION=$2
if [ "$IFACE" = "eth0" ] && [ "$ACTION" = "up" ]; then
/usr/local/bin/tc-shaping.sh
fi
chmod +x /etc/NetworkManager/dispatcher.d/99-tc-shaping
Option C: /etc/network/interfaces (Debian classic)
iface eth0 inet static
address 192.168.1.10/24
post-up /usr/local/bin/tc-shaping.sh
pre-down tc qdisc del dev eth0 root
Gotchas Consolidated
Egress only by default. Ingress requires IFB. If your shaping script seems to have no effect on downloads, you forgot this.
tc rules are stateless. The kernel doesn’t know your intended bandwidth — only the rules currently attached. Running your script twice stacks rules, which causes bizarre behavior. Always start your script with tc qdisc del dev $IFACE root 2>/dev/null to flush before applying.
HTB is CPU-hungry at high packet rates. On a 10 Gbit interface with thousands of flows, HTB burns measurable CPU. On anything under 1 Gbit with normal traffic, you’ll never notice. For 10 Gbit+ use cases, look at HFSC or hardware offload.
ceil must be ≤ parent’s ceil. If you set a class ceil higher than its parent allows, you won’t get an error — tc will silently cap it at the parent’s limit. Always trace the class tree when debugging unexpectedly low throughput.
Filter order matters. Filters are evaluated in prio order, lowest first. If a broad filter (match u32 0 0) has prio 1 and a specific filter has prio 2, the specific one never fires. Structure your filters from most-specific to least-specific, with increasing prio values.
IPv6 needs separate filters. Everything above uses protocol ip (IPv4). If you have IPv6 traffic, add mirrored filters with protocol ipv6 and update the match syntax — u32 match ip6 src instead of ip src.
Production-Ready: A Full Home Router Script
Here’s a complete, commented script for a Linux router shaping both egress and ingress on a 200 Mbit symmetric link, with priority classes and per-IP limiting for one greedy device:
#!/bin/bash
# tc-shaping.sh — Full HTB shaping with ingress via IFB
# Tested on: Debian 12, kernel 6.x
WAN=eth0 # WAN-facing interface
IFB=ifb0 # Virtual IFB for ingress shaping
LINK=200mbit # Actual link speed (both directions)
GREEDY=192.168.1.42
GREEDY_UP=15mbit # Upload cap for greedy device
GREEDY_DOWN=20mbit
set -e
# ── EGRESS (outgoing from this host/router) ──────────────────────────────────
tc qdisc del dev $WAN root 2>/dev/null || true
tc qdisc add dev $WAN root handle 1: htb default 20
tc class add dev $WAN parent 1: classid 1:1 htb rate $LINK ceil $LINK
# High priority: SSH + DNS
tc class add dev $WAN parent 1:1 classid 1:10 htb rate 40mbit ceil $LINK prio 1
tc qdisc add dev $WAN parent 1:10 handle 10: sfq perturb 10
# Normal traffic (default bucket)
tc class add dev $WAN parent 1:1 classid 1:20 htb rate 140mbit ceil $LINK prio 2
tc qdisc add dev $WAN parent 1:20 handle 20: sfq perturb 10
# Greedy device upload cap
tc class add dev $WAN parent 1:1 classid 1:30 htb rate 5mbit ceil $GREEDY_UP prio 3
tc qdisc add dev $WAN parent 1:30 handle 30: sfq perturb 10
# Bulk background (backups, rsync)
tc class add dev $WAN parent 1:1 classid 1:40 htb rate 15mbit ceil 50mbit prio 4
tc qdisc add dev $WAN parent 1:40 handle 40: sfq perturb 10
# Filters — evaluated in prio order
tc filter add dev $WAN parent 1:0 protocol ip prio 1 u32 \
match ip dport 22 0xffff flowid 1:10
tc filter add dev $WAN parent 1:0 protocol ip prio 1 u32 \
match ip dport 53 0xffff flowid 1:10
tc filter add dev $WAN parent 1:0 protocol ip prio 2 u32 \
match ip src $GREEDY/32 flowid 1:30
tc filter add dev $WAN parent 1:0 protocol ip prio 3 u32 \
match ip dport 873 0xffff flowid 1:40 # rsync
# ── INGRESS (incoming, redirected through IFB) ───────────────────────────────
modprobe ifb numifbs=1 2>/dev/null || true
ip link set dev $IFB up
tc qdisc del dev $WAN ingress 2>/dev/null || true
tc qdisc add dev $WAN handle ffff: ingress
tc filter add dev $WAN parent ffff: protocol ip u32 \
match u32 0 0 action mirred egress redirect dev $IFB
tc qdisc del dev $IFB root 2>/dev/null || true
tc qdisc add dev $IFB root handle 2: htb default 20
tc class add dev $IFB parent 2: classid 2:1 htb rate $LINK ceil $LINK
tc class add dev $IFB parent 2:1 classid 2:10 htb rate 30mbit ceil $LINK prio 1
tc qdisc add dev $IFB parent 2:10 handle 210: sfq perturb 10
tc class add dev $IFB parent 2:1 classid 2:20 htb rate 150mbit ceil $LINK prio 2
tc qdisc add dev $IFB parent 2:20 handle 220: sfq perturb 10
# Greedy device download cap
tc class add dev $IFB parent 2:1 classid 2:30 htb rate 5mbit ceil $GREEDY_DOWN prio 3
tc qdisc add dev $IFB parent 2:30 handle 230: sfq perturb 10
# Ingress filters — match destination IP (packets arriving FOR the greedy device)
tc filter add dev $IFB parent 2:0 protocol ip prio 1 u32 \
match ip dport 22 0xffff flowid 2:10
tc filter add dev $IFB parent 2:0 protocol ip prio 2 u32 \
match ip dst $GREEDY/32 flowid 2:30
echo "tc shaping applied."
When tc Isn’t the Right Tool
tc is the right answer when you need sub-second scheduling, fine-grained class hierarchies, or kernel-level enforcement (users can’t bypass it). But sometimes you want something simpler:
nftableswithlimitstatements — good for hard rate limits on connections or packets, not bandwidth shaping.wondershaper— a wrapper around tc that handles common home router setups in one command. Useful for getting something working in five minutes; not suitable when you need per-IP control.vnstat/iftop/nethogs— monitoring tools, not shapers. Run them to understand what’s eating bandwidth before writing shaping rules.
tc with HTB remains the most powerful option when you need it. The learning curve is real, but once you’ve internalised the three-layer model — qdisc, class, filter — the rest is just syntax.