Linux Traffic Control (tc): a Practical Guide
12 min read - June 5, 2026

Control bandwidth, prioritise traffic, and shape ingress and egress on Linux with tc. Working HTB, IFB, DSCP, and fq_codel config for real servers.
Linux Traffic Control (tc): a Practical Guide
Linux's tc command gives you direct control over how your server handles network traffic. You can cap bandwidth per service, keep interactive sessions like SSH responsive when bulk transfers spike, and shape both outbound and inbound flows from a single tool. This guide covers the core concepts, a working HTB setup, ingress shaping with IFB, DSCP-based prioritisation, and how to debug it when something breaks.
How tc works
Every tc setup is built from four moving parts:
- qdisc (queuing discipline). The scheduler attached to a network interface. It decides how packets are enqueued and dequeued.
- Class. A subdivision inside a classful qdisc. Think of it as a lane with its own speed limit.
- Filter. Inspects packet headers (IPs, ports, marks) and assigns each packet to a class.
- Action. What happens to a packet once it matches: forward, drop, redirect.
These form a tree. Packets enter at the root qdisc, hit filters, get sorted into classes by a major:minor handle, and end up queued at a leaf qdisc for transmission.
For anything more complex than port-based matching, mark packets with iptables or nftables in the mangle table, then use the fw filter in tc to classify by mark. It scales far better than chaining raw u32 rules for every traffic type.
Egress vs ingress
Direction matters. The kernel can buffer and delay outbound packets, which is what enables real shaping. Inbound packets have already crossed the wire by the time you see them, so you can only police them (drop above a threshold) unless you redirect them to an IFB device first.
| Feature | Egress | Ingress |
|---|---|---|
| Direction | Outbound | Inbound |
| Shaping | Native | Requires IFB |
| Policing | Supported | Supported |
| Typical use | QoS, bandwidth sharing, pacing | Rate limiting, basic DDoS mitigation |
The qdiscs you'll actually use
- HTB (Hierarchical Token Bucket). Classful. Use it when you want guaranteed minimum bandwidth per service with the ability to borrow unused capacity from other classes.
- TBF (Token Bucket Filter). Classless. Use it when you just need to cap a whole interface at a single rate.
- fq_codel (Fair Queuing Controlled Delay). Combines per-flow fairness with active queue management to kill bufferbloat. It's been the default qdisc in most Linux distributions since systemd 217 and ships as default on RHEL 9. Always attach it as a leaf qdisc under HTB classes, otherwise a single greedy flow can hog an entire class.
Setting up tc on a Linux server
tc ships with the iproute2 package. On Debian and Ubuntu, install it with apt-get install iproute2. On RHEL and derivatives, yum install iproute. You'll need root or sudo.
Get the correct interface name first. Misnaming the interface is the most common reason a config silently does nothing:
ip link showInspect what's already on the interface, including live counters:
tc -s qdisc show dev eth0Wipe any existing root qdisc before applying a new config, to avoid RTNETLINK answers: File exists errors:
tc qdisc del dev eth0 root 2>/dev/null || trueIf you're updating an existing rule rather than starting from scratch, use replace instead of add for an atomic swap.
Hardware offloading like TSO and GSO bundles packets in ways that interfere with shaping. Turn them off on the shaped interface:
sudo ethtool -K eth0 tso off gso offSet fq_codel as the system-wide default qdisc for new interfaces:
sysctl -w net.core.default_qdisc=fq_codelFor busy servers, pair it with the BBR congestion control algorithm (kernel 4.9+). BBR keeps throughput high without growing queues:
sysctl -w net.ipv4.tcp_congestion_control=bbrOne safety habit if you're configuring a remote box over SSH: open a second session and have tc qdisc del dev eth0 root ready to paste. A bad filter rule can lock you out instantly.
Shaping outbound traffic with HTB
HTB lets you give each service a guaranteed minimum (rate) and a ceiling (ceil). Unused bandwidth flows to whoever needs it, in priority order. Here's a working three-tier setup for a 1 Gbps uplink.
Create the root HTB qdisc. The default 30 sends any unclassified packet to class 1:30 rather than letting it bypass your rules:
tc qdisc add dev eth0 root handle 1: htb default 30Cap total throughput at 900 Mbps. Always shape slightly below the actual link capacity, otherwise the queue forms on an upstream router or modem you don't control:
tc class add dev eth0 parent 1: classid 1:1 htb rate 900mbit ceil 900mbitDefine the service tiers. Lower prio values get unused bandwidth first:
# High priority: web and API traffic
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 500mbit ceil 900mbit prio 1
# Medium priority: database replication
tc class add dev eth0 parent 1:1 classid 1:20 htb rate 300mbit ceil 900mbit prio 2
# Low priority: bulk and backup traffic
tc class add dev eth0 parent 1:1 classid 1:30 htb rate 100mbit ceil 900mbit prio 3Attach fq_codel as the leaf qdisc on each class so a single flow can't dominate its tier:
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_codelNow classify the traffic. For simple port matching, u32 is fastest:
tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
match ip dport 443 0xffff flowid 1:10For anything stateful, mark in iptables and match the mark with fw:
iptables -t mangle -A OUTPUT -p tcp --dport 5432 -j MARK --set-mark 2
tc filter add dev eth0 protocol ip parent 1:0 prio 2 handle 2 fw flowid 1:20Shaping inbound traffic with IFB
You can't natively shape ingress because by the time a packet arrives it's already used your bandwidth. The workaround is to redirect ingress to an Intermediate Functional Block (IFB) virtual interface, where the kernel treats it as egress and lets you apply classful qdiscs.
Load the module and bring the interface up:
modprobe ifb numifbs=1
ip link set dev ifb0 upAdd an ingress qdisc to the physical interface and redirect everything to ifb0:
tc qdisc add dev eth0 ingress handle ffff:
tc filter add dev eth0 parent ffff: protocol all u32 \
match u32 0 0 action mirred egress redirect dev ifb0From here, ifb0 behaves like any other interface. Apply your HTB tree to it exactly as you would on egress:
tc qdisc add dev ifb0 root handle 1: htb default 30
tc class add dev ifb0 parent 1: classid 1:1 htb rate 900mbit ceil 900mbit
tc class add dev ifb0 parent 1:1 classid 1:10 htb rate 500mbit ceil 900mbit prio 1Prioritising traffic with DSCP
DSCP (Differentiated Services Code Point) tags packets with a 6-bit value in the TOS byte, so your tc filters can classify by tag rather than by chasing ports across the rule set. When matching DSCP in tc, shift the value left by 2 bits. DSCP EF (46) becomes 0xb8. The mask 0xfc isolates the 6 DSCP bits from the 2 ECN bits.
A sensible default mapping for server workloads:
| Traffic type | DSCP | TOS hex | Examples |
|---|---|---|---|
| Interactive | EF | 0xb8 | SSH, DNS, VoIP |
| Business | AF41 | 0x88 | HTTP, HTTPS, APIs |
| Bulk | CS1 | 0x20 | Backups, FTP, package updates |
| Best effort | CS0 | 0x00 | Everything else |
Tag outbound packets in iptables before they hit your tc filters:
iptables -t mangle -A OUTPUT -p tcp --dport 22 -j DSCP --set-dscp 46Then match the tag in tc and route it to the right HTB class:
# EF (SSH, VoIP) goes to the high-priority class
tc filter add dev eth0 protocol ip parent 1:0 prio 1 u32 \
match ip tos 0xb8 0xfc flowid 1:10
# AF41 (web traffic) goes to the medium class
tc filter add dev eth0 protocol ip parent 1:0 prio 2 u32 \
match ip tos 0x88 0xfc flowid 1:20
# CS1 (bulk) goes to the low-priority class
tc filter add dev eth0 protocol ip parent 1:0 prio 3 u32 \
match ip tos 0x20 0xfc flowid 1:30Monitoring and troubleshooting
The three commands you'll use constantly:
tc -s qdisc show dev eth0
tc -s class show dev eth0
tc -s filter show dev eth0Watch the dropped and overlimits counters. Dropped packets mean the queue saturated; overlimits mean you hit a class ceiling and the kernel had to delay or shed traffic. For a live view:
watch -n 1 'tc -s class show dev eth0'Add -d for internal parameters (target, interval, quantum) and -j for JSON output if you're piping into a metrics stack. Pair it with ss -tin to see RTT estimates and retransmits at the TCP layer.
Most failures fall into a short list:
| Symptom | Likely cause | Fix |
|---|---|---|
RTNETLINK answers: File exists | Root qdisc already configured | tc qdisc del dev eth0 root first |
| Rules apply but traffic isn't limited | Wrong interface, or TSO/GSO still on | Confirm with ip link show, disable offloads with ethtool -K |
| Filter never matches | Bad port/IP syntax or mask alignment | Add a counter action and check the hit count in tc -s filter show |
| Rules gone after reboot | Config lives in memory only | Wrap in a script and call from systemd or a NetworkManager dispatcher |
| High latency on priority traffic | No leaf qdisc, or burst too low | Attach fq_codel to leaf classes, raise burst |
If you ever lock yourself out with a misconfiguration, the reset is simple:
tc qdisc del dev eth0 roottc can't manufacture bandwidth you don't have, but on a well-provisioned uplink it makes the difference between predictable performance and a server that falls apart the moment one tenant starts a large transfer. If you need the raw bandwidth and the freedom to shape it however you like, take a look at FDC's dedicated servers.

Linux Traffic Control (tc): a Practical Guide
Control bandwidth, prioritise traffic, and shape ingress and egress on Linux with tc. Working HTB, IFB, DSCP, and fq_codel config for real servers.
12 min read - June 5, 2026
Why it's important to have a powerful and unmetered VPS
7 min read - May 9, 2025

Have questions or need a custom solution?
Flexible options
Global reach
Instant deployment
Flexible options
Global reach
Instant deployment