How to Build a Linux Router with nftables and CAKE Traffic Shaping

Yes, a standard Debian 12 or Fedora Server installation on cheap x86 hardware (or a Raspberry Pi 5) makes a better router than most consumer gear costing twice as much. You need two network interfaces, a handful of config files, and about two hours of setup time. The result is a gateway with a real stateful firewall via nftables , proper DNS with DHCP from dnsmasq , and traffic shaping that actually works through CAKE SQM - all managed through plain-text configs you can version-control with Git.
This guide walks through the full build: hardware selection, base system configuration, a complete nftables ruleset with NAT and IPv6, dnsmasq and DNS-over-TLS setup, CAKE-based bufferbloat elimination, and optional WireGuard VPN integration. Every config file is copy-pasteable.
Why Replace Your Consumer Router
Consumer routers ship with proprietary firmware that gets security updates for maybe two to three years. After that, you’re running a device with known CVEs facing the public internet. TP-Link, Netgear, and ASUS routers have all had critical vulnerabilities sit unpatched for months. A Debian box running unattended-upgrades gets security patches automatically for the full LTS cycle (Debian 12 Bookworm is supported through 2028).
Beyond security, consumer routers hit performance ceilings fast. Their SoCs are designed around Wi-Fi, not routing throughput. A Raspberry Pi 5 (8 GB, around $80) pushes 940 Mbps wire-speed with nftables NAT. A used Dell OptiPlex Micro 3050 ($60-90 on eBay) paired with a $25 Intel i225-V 2.5GbE PCIe card handles 2.5 Gbps at near-zero CPU load. Both options push more raw throughput than a $150 consumer Wi-Fi router while leaving headroom for firewall rules and traffic shaping.

Here is what you gain with a Linux router:
- Full nftables firewall with logging, rate limiting, and geo-blocking
- Real traffic shaping with CAKE SQM that eliminates bufferbloat
- DNS-over-TLS via Unbound for encrypted upstream DNS
- Built-in WireGuard VPN server
- Per-device bandwidth monitoring with vnStat or ntopng
Here is what you lose: the all-in-one box. You will manage two devices - a router and a separate Wi-Fi access point (something like a Ubiquiti U6-Lite or TP-Link EAP670). Separating routing from wireless is the single biggest reliability improvement you can make. Initial setup takes two to three hours vs. a 10-minute consumer router wizard.
| Option | Cost | Throughput | Firewall | SQM/QoS |
|---|---|---|---|---|
| Linux Router (Pi 5 or OptiPlex) | $60-130 | 1-2.5 Gbps | Full nftables | CAKE |
| Ubiquiti EdgeRouter | $100-300 | 1-2 Gbps | iptables-based | fq_codel |
| pfSense appliance | $200-600 | 1-10 Gbps | pf | ALTQ/Limiters |
| Consumer Router | $80-200 | 300-900 Mbps | Basic NAT | Minimal/none |
Base System Setup: Distro, NICs, and IP Forwarding
Start with a Debian 12 (Bookworm) minimal install - no desktop environment, no GUI. Debian is the recommended base because of its stability and long support window. Fedora Server 41 (kernel 6.12+) works if you want newer kernel features, but Debian’s conservatism is an asset for something that needs to run 24/7 without surprises.

Identify and Rename Network Interfaces
After installation, identify your two NICs:
ip link showYou will see interface names like enp1s0 and enp2s0. For sanity, rename them to wan0 and lan0 using a systemd .link file:
# /etc/systemd/network/10-wan0.link
[Match]
MACAddress=aa:bb:cc:dd:ee:01
[Link]
Name=wan0# /etc/systemd/network/10-lan0.link
[Match]
MACAddress=aa:bb:cc:dd:ee:02
[Link]
Name=lan0Configure Network Addresses with systemd-networkd
The WAN interface gets its address from your ISP via DHCP. The LAN interface gets a static IP that becomes the gateway for all your devices:
# /etc/systemd/network/10-wan0.network
[Match]
Name=wan0
[Network]
DHCP=yes
[DHCPv4]
UseDNS=no# /etc/systemd/network/20-lan0.network
[Match]
Name=lan0
[Network]
Address=192.168.1.1/24Disable NetworkManager entirely - it conflicts with systemd-networkd:
systemctl disable --now NetworkManager
systemctl enable --now systemd-networkdEnable IP Forwarding and Tune Kernel Parameters
The kernel needs explicit permission to route packets between interfaces. Create a sysctl config:
# /etc/sysctl.d/99-router.conf
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
# Increase conntrack table for heavy NAT use
net.netfilter.nf_conntrack_max=131072
net.netfilter.nf_conntrack_tcp_timeout_established=3600
# Enable BBR congestion control
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbrApply immediately:
sysctl --system
cat /proc/sys/net/ipv4/ip_forward # Should return 1For high-throughput setups, enable hardware offloading on both interfaces:
ethtool -K wan0 gro on gso on
ethtool -K lan0 gro on gso onCheck available offload features with ethtool -k wan0. Disable hardware checksum offload only if nftables logging shows garbled packet data.
nftables Firewall and NAT Configuration
nftables
replaced the legacy iptables/ip6tables/ebtables stack with a single unified framework. It has been the default in Debian since Buster (2019) and in RHEL since version 8. The iptables command still exists on modern distros but it translates to nftables behind the scenes via iptables-nft. For new installations, use native nftables syntax exclusively - the iptables-legacy backend is deprecated.
The following diagram shows the overall architecture: traffic enters through the WAN interface, passes through nftables filtering and CAKE shaping, and reaches LAN devices through a switch. WireGuard VPN clients connect through a separate tunnel interface.
Here is a complete /etc/nftables.conf for a home router. This handles IPv4 NAT, stateful packet filtering for both input and forwarding, ICMP rate limiting, and IPv6 rules:
#!/usr/sbin/nft -f
flush ruleset
define WAN = wan0
define LAN = lan0
define LAN_NET = 192.168.1.0/24
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
# Allow established/related, drop invalid
ct state established,related accept
ct state invalid drop
# Allow loopback
iif lo accept
# ICMP/ICMPv6 rate limited
ip protocol icmp limit rate 10/second accept
ip6 nexthdr icmpv6 limit rate 10/second accept
# Allow essential ICMPv6 for IPv6 to function
icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-solicit, nd-router-advert } accept
# LAN-facing services
iifname $LAN tcp dport 22 accept # SSH
iifname $LAN udp dport { 53, 67, 68 } accept # DNS, DHCP
iifname $LAN tcp dport 53 accept # DNS over TCP
# WireGuard (optional - remove if not using VPN)
udp dport 51820 accept
# Log and drop everything else
log prefix "nft-input-drop: " limit rate 5/minute
drop
}
chain forward {
type filter hook forward priority 0; policy drop;
# Allow established/related, drop invalid
ct state established,related accept
ct state invalid drop
# Allow LAN to WAN (internet access)
iifname $LAN oifname $WAN accept
# Allow WireGuard clients to reach LAN and WAN
iifname "wg0" oifname $LAN accept
iifname "wg0" oifname $WAN accept
iifname $LAN oifname "wg0" accept
# IPv6 forward rules - critical since IPv6 gives
# every device a public address
# Allow ICMPv6 for path MTU discovery and neighbor discovery
ip6 nexthdr icmpv6 icmpv6 type {
echo-request, echo-reply,
nd-neighbor-solicit, nd-neighbor-advert,
nd-router-solicit, nd-router-advert,
packet-too-big
} accept
# Example: port forward to internal server
# iifname $WAN oifname $LAN ip daddr 192.168.1.50 tcp dport 25565 accept
log prefix "nft-forward-drop: " limit rate 5/minute
drop
}
}
table inet nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# Example: port forward to Minecraft server
# iifname $WAN tcp dport 25565 dnat to 192.168.1.50:25565
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
# Masquerade all LAN traffic going out WAN
oifname $WAN masquerade
}
}Enable and start the nftables service so rules load at boot:
systemctl enable --now nftablesVerify the loaded ruleset:
nft list rulesetPort Forwarding
To forward external port 25565 to an internal Minecraft server at 192.168.1.50, uncomment the two lines in the config above - one in the prerouting chain (DNAT rule) and one in the forward chain (accept rule). Then reload:
nft -f /etc/nftables.confTest from an external machine with nmap -p 25565 <your-public-ip>.
DHCP, DNS, and dnsmasq Configuration
dnsmasq handles both DHCP and DNS in a single lightweight daemon, which is all a home router needs. Install it:
apt install dnsmasqEdit /etc/dnsmasq.conf:
# Bind only to the LAN interface
interface=lan0
bind-interfaces
# DHCP range and lease time
dhcp-range=192.168.1.100,192.168.1.250,24h
# Gateway and DNS server
dhcp-option=3,192.168.1.1
dhcp-option=6,192.168.1.1
# Domain for local resolution
local=/lan/
domain=lan
# Static DHCP leases (in separate directory for easy management)
dhcp-hostsdir=/etc/dnsmasq.d/hosts/
# Upstream DNS (points to local Unbound on port 5335)
server=127.0.0.1#5335
# Don't read /etc/resolv.conf for upstream
no-resolv
# Cache size
cache-size=1000Create the hosts directory and add static leases as needed:
mkdir -p /etc/dnsmasq.d/hosts/# /etc/dnsmasq.d/hosts/static-leases
dhcp-host=AA:BB:CC:DD:EE:FF,192.168.1.50,minecraft-server
dhcp-host=11:22:33:44:55:66,192.168.1.51,nasAdd local DNS entries to /etc/hosts:
192.168.1.1 router.lan gateway.lan
192.168.1.50 minecraft.lan
192.168.1.51 nas.lanDNS-over-TLS with Unbound
Running a local Unbound instance gives you encrypted DNS upstream without needing Pi-hole or external DoH services . Install it:
apt install unboundConfigure Unbound to listen on port 5335 (so it does not conflict with dnsmasq on port 53) and forward queries over TLS:
# /etc/unbound/unbound.conf.d/dns-over-tls.conf
server:
interface: 127.0.0.1
port: 5335
do-not-query-localhost: no
# DNSSEC validation
module-config: "validator iterator"
auto-trust-anchor-file: "/var/lib/unbound/root.key"
# Privacy
hide-identity: yes
hide-version: yes
qname-minimisation: yes
# Performance
num-threads: 2
msg-cache-size: 50m
rrset-cache-size: 100m
prefetch: yes
forward-zone:
name: "."
forward-tls-upstream: yes
# Cloudflare
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
# Quad9
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.netRestart both services:
systemctl restart unbound
systemctl restart dnsmasqVerify DNSSEC is working:
dig @127.0.0.1 -p 5335 sigfail.verteiltesysteme.net # Should return SERVFAIL
dig @127.0.0.1 -p 5335 sigok.verteiltesysteme.net # Should return NOERRORIPv6 DHCP and SLAAC
If your ISP provides a prefix delegation (typically /56 or /48 via DHCPv6-PD), configure dnsmasq to advertise a /64 on the LAN using SLAAC. Add to /etc/dnsmasq.conf:
enable-ra
dhcp-range=::,constructor:lan0,ra-statelessThis tells dnsmasq to send Router Advertisements so clients auto-configure their IPv6 addresses. Combined with the IPv6 forwarding rules in the nftables config above, your LAN devices get full IPv6 connectivity with proper firewall protection.
Traffic Shaping with CAKE SQM
Bufferbloat is the reason your video calls stutter and your game ping spikes to 300ms whenever someone on your network starts a large download. When a router’s outgoing queue fills up, every packet - including latency-sensitive VoIP and gaming traffic - has to wait behind megabytes of bulk transfer data. A 10ms base latency turns into 200-500ms under load.
Test your connection at the Waveform Bufferbloat Test before doing anything. If you get a grade of C or worse, you have bufferbloat.
CAKE
(Common Applications Kept Enhanced) is the modern Linux qdisc that fixes this. It combines Active Queue Management (AQM), Fair Queuing (FQ), and traffic shaping into a single qdisc. CAKE has been in the mainline kernel since 4.19, so no extra kernel modules are required on any modern distro. It replaced the older approach of combining fq_codel with htb shaping - CAKE does everything in one shot with less configuration.
Upload Shaping
Apply CAKE to your WAN interface with your upload bandwidth set to 90-95% of measured speed:
tc qdisc replace dev wan0 root cake bandwidth 450mbit besteffort wash nat ack-filter-aggressiveKey options:
bandwidth 450mbit- set to 90-95% of your actual upload speed (if your upload is 500 Mbps, use 450)nat- enables proper flow identification for traffic behind NAT (required for routers)wash- clears DSCP markings from upstream that might not match your local policyack-filter-aggressive- reduces TCP ACK congestion on asymmetric links (important if your download is much faster than upload)
Download Shaping with IFB
CAKE can only shape outgoing (egress) traffic. To shape incoming (ingress) traffic - which is where download bufferbloat lives - you redirect incoming packets through an Intermediate Functional Block (IFB) device and apply CAKE there:
# Create and bring up IFB device
ip link add ifb-wan0 type ifb
ip link set ifb-wan0 up
# Redirect incoming WAN traffic to IFB
tc qdisc add dev wan0 handle ffff: ingress
tc filter add dev wan0 parent ffff: u32 match u32 0 0 \
action mirred egress redirect dev ifb-wan0
# Apply CAKE on the IFB device
tc qdisc replace dev ifb-wan0 root cake bandwidth 900mbit besteffort washSet the IFB bandwidth to 90-95% of your measured download speed.
Persist Across Reboots
These tc commands do not survive a reboot on their own. Create a systemd service:
# /etc/systemd/system/sqm.service
[Unit]
Description=SQM (CAKE) Traffic Shaping
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
# Upload shaping
ExecStart=/sbin/tc qdisc replace dev wan0 root cake bandwidth 450mbit besteffort wash nat ack-filter-aggressive
# Download shaping via IFB
ExecStart=/sbin/ip link add ifb-wan0 type ifb
ExecStart=/sbin/ip link set ifb-wan0 up
ExecStart=/sbin/tc qdisc add dev wan0 handle ffff: ingress
ExecStart=/sbin/tc filter add dev wan0 parent ffff: u32 match u32 0 0 action mirred egress redirect dev ifb-wan0
ExecStart=/sbin/tc qdisc replace dev ifb-wan0 root cake bandwidth 900mbit besteffort wash
# Cleanup on stop
ExecStop=/sbin/tc qdisc del dev wan0 root
ExecStop=/sbin/tc qdisc del dev wan0 ingress
ExecStop=/sbin/ip link del ifb-wan0
[Install]
WantedBy=multi-user.targetEnable it:
systemctl enable --now sqm.serviceVerify It Works
After applying CAKE, rerun the Waveform Bufferbloat Test . You should see latency under load drop from 200-500ms down to 5-15ms. Video calls stop freezing mid-sentence and game ping stays flat even during large transfers.

Monitor CAKE statistics:
tc -s qdisc show dev wan0
tc -s qdisc show dev ifb-wan0This shows drops, ECN marks, and per-tin flow counts. If you see high drop rates, your bandwidth setting may be too close to the actual line speed. Lower it by another 5%.
WireGuard VPN Integration
Adding a WireGuard VPN server to your Linux router lets you access your home network securely from anywhere . Since WireGuard runs in the kernel, it adds negligible overhead.
Install and generate keys:
apt install wireguard
wg genkey | tee /etc/wireguard/server-private.key | wg pubkey > /etc/wireguard/server-public.key
chmod 600 /etc/wireguard/server-private.keyCreate the server config:
# /etc/wireguard/wg0.conf
[Interface]
PrivateKey = <contents of server-private.key>
Address = 10.10.10.1/24
ListenPort = 51820
[Peer]
# Phone
PublicKey = <client-public-key>
AllowedIPs = 10.10.10.2/32Start WireGuard:
systemctl enable --now wg-quick@wg0The nftables config shown earlier already includes rules for WireGuard: UDP port 51820 is open in the input chain, and the forward chain allows traffic between wg0 and both LAN and WAN interfaces. VPN clients get full access to the home network and internet through the router’s NAT. To extend this setup and connect two separate home networks together, see the guide on WireGuard site-to-site tunnels
.
What to Expect and Where to Go Next
After following this guide you will have a router that boots in under 30 seconds, forwards traffic at wire speed, keeps latency under 15ms even during heavy transfers, and gives you SSH-accessible configuration files for everything. All of it fits in a Git repository.
A few things this guide did not cover that you might want to add later:
- VLAN trunking for separate IoT and guest networks using 802.1Q tags on the LAN interface
- Failover WAN with a USB LTE modem as backup, switched automatically via systemd-networkd or a custom watchdog script
- Monitoring dashboards using vnStat for bandwidth graphs or ntopng for deep packet inspection and per-device traffic analysis
- UPS integration with
nut(Network UPS Tools) so the router shuts down cleanly during extended power outages
The Linux router approach scales well. The same nftables ruleset and CAKE config that works on a Raspberry Pi 5 works identically on a rack-mount server with 10GbE interfaces. The configs are text files in a Git repo, which puts them ahead of anything a consumer router’s web UI can offer.
Botmonster Tech