<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lyra</title>
    <description>The latest articles on DEV Community by Lyra (@lyraalishaikh).</description>
    <link>https://dev.to/lyraalishaikh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3755481%2F7174207e-67eb-4a72-9c1a-6fdad7505b9c.png</url>
      <title>DEV Community: Lyra</title>
      <link>https://dev.to/lyraalishaikh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lyraalishaikh"/>
    <language>en</language>
    <item>
      <title>Stop SYN Floods Exhausting Conntrack: Practical nftables SYNPROXY on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Wed, 19 Aug 2026 05:02:47 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-syn-floods-exhausting-conntrack-practical-nftables-synproxy-on-linux-2jb7</link>
      <guid>https://dev.to/lyraalishaikh/stop-syn-floods-exhausting-conntrack-practical-nftables-synproxy-on-linux-2jb7</guid>
      <description>&lt;h1&gt;
  
  
  Stop SYN Floods Exhausting Conntrack: Practical nftables SYNPROXY on Linux
&lt;/h1&gt;

&lt;p&gt;A SYN flood is still one of the cheapest ways to hurt a Linux edge host.&lt;/p&gt;

&lt;p&gt;Attackers blast &lt;code&gt;SYN&lt;/code&gt; packets at a public listener. The kernel allocates half-open state. Conntrack fills up. Real clients start failing long before your application logs anything useful.&lt;/p&gt;

&lt;p&gt;Linux already has a last-resort defense: TCP syncookies. That helps the local TCP stack survive backlog pressure. It does &lt;strong&gt;not&lt;/strong&gt; stop Netfilter connection tracking from paying the cost of every unauthenticated handshake attempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nftables &lt;code&gt;synproxy&lt;/code&gt;&lt;/strong&gt; moves the three-way handshake into Netfilter itself. Incomplete clients never create full conntrack entries. Only hosts that finish the cookie handshake get a real connection toward your service.&lt;/p&gt;

&lt;p&gt;This post is a practical setup for protecting local TCP services (HTTPS, reverse proxies, self-hosted APIs) with stock Linux tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are solving
&lt;/h2&gt;

&lt;p&gt;Without SYNPROXY, a flood can burn two scarce resources at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Listener backlog / SYN queue&lt;/strong&gt; on the socket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conntrack table slots&lt;/strong&gt; used by half-open or spoofed attempts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;net.ipv4.tcp_syncookies=1&lt;/code&gt; is the classic socket-level mitigation. Kernel docs are explicit: syncookies are a fallback when the SYN backlog overflows, not a general-purpose load feature.&lt;/p&gt;

&lt;p&gt;SYNPROXY is different. It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;intercepts new &lt;code&gt;SYN&lt;/code&gt; packets early&lt;/li&gt;
&lt;li&gt;answers with a cookie &lt;code&gt;SYN/ACK&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;validates the client’s final &lt;code&gt;ACK&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;only then establishes a tracked connection toward the real listener&lt;/li&gt;
&lt;li&gt;translates sequence numbers so the client and server still speak normal TCP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Patrick McHardy’s original netfilter SYN proxy design describes exactly this split: untracked cookie exchange first, real conntrack entry only after cookie validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Linux with Netfilter conntrack and SYNPROXY support (common on modern kernels)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nftables&lt;/code&gt; userspace recent enough for the &lt;code&gt;synproxy&lt;/code&gt; statement (anonymous objects since nftables 0.9.2; named objects since 0.9.3)&lt;/li&gt;
&lt;li&gt;root on the host that terminates or forwards the protected TCP ports&lt;/li&gt;
&lt;li&gt;a way to observe one real &lt;code&gt;SYN/ACK&lt;/code&gt; from the backend (tcpdump)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Packages (Debian/Ubuntu-style):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nftables tcpdump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How SYNPROXY fits the path
&lt;/h2&gt;

&lt;p&gt;A working ruleset has three moving parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mark initial SYNs as untracked&lt;/strong&gt; in a &lt;code&gt;prerouting&lt;/code&gt;/&lt;code&gt;raw&lt;/code&gt; chain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand &lt;code&gt;untracked&lt;/code&gt; + &lt;code&gt;invalid&lt;/code&gt; TCP packets to &lt;code&gt;synproxy&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;input&lt;/code&gt; (local service) or &lt;code&gt;forward&lt;/code&gt; (backend behind this box)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable loose TCP conntrack recovery&lt;/strong&gt; so the client’s final handshake ACK is seen as &lt;code&gt;INVALID&lt;/code&gt; and still reaches SYNPROXY&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third point is not optional. The nftables wiki and &lt;code&gt;nft(8)&lt;/code&gt; manpage both require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.netfilter.nf_conntrack_tcp_loose&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also enable the cookie/timestamp stack SYNPROXY relies on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.tcp_syncookies&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.tcp_timestamps&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it durable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/90-synproxy.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# Required for nftables/iptables SYNPROXY cookie handshakes
net.netfilter.nf_conntrack_tcp_loose = 0
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_timestamps = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why loose tracking must be off
&lt;/h3&gt;

&lt;p&gt;With loose TCP tracking enabled, conntrack may reconstruct state from out-of-flow packets. The final client &lt;code&gt;ACK&lt;/code&gt; of the cookie handshake can then create a normal conntrack entry too early and bypass the proxy logic. Turning loose mode off forces that ACK into &lt;code&gt;INVALID&lt;/code&gt;, which your SYNPROXY rule deliberately matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Measure the backend TCP options
&lt;/h2&gt;

&lt;p&gt;SYNPROXY does &lt;strong&gt;not&lt;/strong&gt; probe your server. You must announce the same MSS, window scale, timestamp, and SACK behavior the real listener uses.&lt;/p&gt;

&lt;p&gt;From a client machine (or another host that can reach the service), capture one server &lt;code&gt;SYN/ACK&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On a host that can see the reply path to the service&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;tcpdump &lt;span class="nt"&gt;-pni&lt;/span&gt; eth0 &lt;span class="nt"&gt;-c&lt;/span&gt; 1 &lt;span class="s1"&gt;'tcp[tcpflags] == (tcp-syn|tcp-ack) &amp;amp;&amp;amp; port 443'&lt;/span&gt; &amp;amp;
curl &lt;span class="nt"&gt;-vk&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 https://192.0.2.10/ &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example output shape from &lt;code&gt;nft(8)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Flags [S.], ..., options [mss 1460,sackOK, TS val ..., ecr ..., nop,wscale 9]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mss 1460&lt;/code&gt; → &lt;code&gt;mss 1460&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wscale 9&lt;/code&gt; → &lt;code&gt;wscale 9&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sackOK&lt;/code&gt; → include &lt;code&gt;sack-perm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TS val&lt;/code&gt; present → include &lt;code&gt;timestamp&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your listener options change after a kernel/sysctl/NIC MTU change, re-measure. Mismatched MSS/wscale is a classic “SYNPROXY works in theory, clients hang in practice” failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Protect local listeners with an anonymous synproxy
&lt;/h2&gt;

&lt;p&gt;This ruleset protects TCP/443 on the local host. Adjust ports to match your services.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/nftables.d/synproxy-local.nft &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
table inet synproxy_local {
  # Early: do not create conntrack entries for bare SYNs to protected ports
  chain raw_prerouting {
    type filter hook prerouting priority raw; policy accept;

    tcp dport { 80, 443 } tcp flags syn notrack
  }

  chain input_synproxy {
    type filter hook input priority filter; policy accept;

    # Cookie handshake for untracked SYNs and invalid final ACKs
    tcp dport { 80, 443 } ct state invalid,untracked &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
      synproxy mss 1460 wscale 9 timestamp sack-perm

    # Anything still invalid after synproxy is junk / failed cookies
    tcp dport { 80, 443 } ct state invalid drop
  }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load it without wiping your whole firewall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Validate first&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/synproxy-local.nft

&lt;span class="c"&gt;# Apply&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/synproxy-local.nft
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list table inet synproxy_local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your distribution already uses a monolithic &lt;code&gt;/etc/nftables.conf&lt;/code&gt;, include the file from there instead of loading ad hoc:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/nftables.conf&lt;/span&gt;
&lt;span class="c"&gt;#!/usr/sbin/nft -f&lt;/span&gt;
flush ruleset

include &lt;span class="s2"&gt;"/etc/nftables.d/*.nft"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nftables
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nftables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What each rule is doing
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Match&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;prerouting&lt;/code&gt; / &lt;code&gt;raw&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tcp flags syn&lt;/code&gt; to protected ports&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;notrack&lt;/code&gt; — no conntrack allocation yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;input&lt;/code&gt; / &lt;code&gt;filter&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ct state invalid,untracked&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;synproxy ...&lt;/code&gt; answers cookies / validates ACKs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;input&lt;/code&gt; / &lt;code&gt;filter&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;remaining &lt;code&gt;ct state invalid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;drop failed or out-of-flow junk&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Established flows after a successful handshake are ordinary conntrack-managed TCP. Your normal &lt;code&gt;ct state established,related accept&lt;/code&gt; rules continue to apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Optional named synproxy objects
&lt;/h2&gt;

&lt;p&gt;If several ports or source ranges need different TCP option profiles, use named objects (nftables 0.9.3+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table ip synproxy_named {
  synproxy https_profile {
    mss 1460
    wscale 9
    timestamp
    sack-perm
  }

  chain raw_prerouting {
    type filter hook prerouting priority raw; policy accept;
    tcp dport 443 tcp flags syn notrack
  }

  chain input_synproxy {
    type filter hook input priority filter; policy accept;
    tcp dport 443 ct state invalid,untracked synproxy name "https_profile"
    tcp dport 443 ct state invalid drop
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Named objects are easier to reuse when one box fronts multiple backends with different MSS/wscale values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Forward-path protection for a backend
&lt;/h2&gt;

&lt;p&gt;If this Linux box is a firewall/load-balancer in front of another server, put SYNPROXY on &lt;code&gt;forward&lt;/code&gt; instead of (or in addition to) &lt;code&gt;input&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table inet synproxy_forward {
  chain raw_prerouting {
    type filter hook prerouting priority raw; policy accept;
    iifname "eth0" tcp dport { 80, 443 } tcp flags syn notrack
  }

  chain forward_synproxy {
    type filter hook forward priority filter; policy accept;

    iifname "eth0" tcp dport { 80, 443 } ct state invalid,untracked \
      synproxy mss 1460 wscale 9 timestamp sack-perm

    iifname "eth0" tcp dport { 80, 443 } ct state invalid drop
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important: measure MSS/wscale from the &lt;strong&gt;real backend’s&lt;/strong&gt; &lt;code&gt;SYN/ACK&lt;/code&gt;, not from an unrelated local socket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Size conntrack for the traffic you still accept
&lt;/h2&gt;

&lt;p&gt;SYNPROXY reduces garbage half-open pressure, but legitimate concurrent connections still need table headroom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Current usage&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl net.netfilter.nf_conntrack_count &lt;span class="se"&gt;\&lt;/span&gt;
             net.netfilter.nf_conntrack_max

&lt;span class="c"&gt;# Example bump — pick values from real peaks, not vibes&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/sysctl.d/90-synproxy.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
net.netfilter.nf_conntrack_max = 524288
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# On many kernels hashsize is a module parameter&lt;/span&gt;
&lt;span class="c"&gt;# example only — confirm path exists on your host first&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /sys/module/nf_conntrack/parameters/hashsize &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;131072 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/module/nf_conntrack/parameters/hashsize
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nftables wiki explicitly calls out raising &lt;code&gt;nf_conntrack_max&lt;/code&gt; and conntrack hash size alongside SYNPROXY.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Sysctls landed
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl net.netfilter.nf_conntrack_tcp_loose &lt;span class="se"&gt;\&lt;/span&gt;
       net.ipv4.tcp_syncookies &lt;span class="se"&gt;\&lt;/span&gt;
       net.ipv4.tcp_timestamps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;net.netfilter.nf_conntrack_tcp_loose = 0
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_timestamps = 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Rules are attached where you think
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list hooks 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;protected ports hit &lt;code&gt;notrack&lt;/code&gt; on &lt;code&gt;prerouting&lt;/code&gt;/&lt;code&gt;raw&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;synproxy&lt;/code&gt; sits on &lt;code&gt;input&lt;/code&gt; and/or &lt;code&gt;forward&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Real clients still complete
&lt;/h3&gt;

&lt;p&gt;From an external client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 https://your.example
openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; your.example:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; your.example &amp;lt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server, a good connection should show up as normal established TCP after the handshake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-Htn&lt;/span&gt; state established &lt;span class="s1"&gt;'sport = :443'&lt;/span&gt; | &lt;span class="nb"&gt;head
sudo &lt;/span&gt;conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 443 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Cookie path is active under SYN pressure
&lt;/h3&gt;

&lt;p&gt;Generate controlled SYN-only noise in a lab (not against third-party networks):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Lab-only example using hping3 if installed&lt;/span&gt;
&lt;span class="c"&gt;# sudo hping3 -S -p 443 --flood 192.0.2.10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While that runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;protected service should keep answering real completed handshakes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nf_conntrack_count&lt;/code&gt; should not climb 1:1 with spoofed SYN rate&lt;/li&gt;
&lt;li&gt;invalid/failed cookies should be dropped by the final &lt;code&gt;ct state invalid drop&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If legitimate clients break immediately, re-check MSS/wscale/timestamp/SACK against a fresh tcpdump of the backend &lt;code&gt;SYN/ACK&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational pitfalls
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wrong MSS/wscale&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Clients stall after &lt;code&gt;SYN/ACK&lt;/code&gt;. Re-capture options after MTU or listener changes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Leaving &lt;code&gt;nf_conntrack_tcp_loose=1&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Final ACKs may bypass SYNPROXY. Cookie mode becomes inconsistent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Protecting ports that need exotic TCP options&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If the backend relies on options you did not enable in the &lt;code&gt;synproxy&lt;/code&gt; statement, feature mismatch follows. Stick to the measured set.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Applying SYNPROXY globally to every TCP port&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Start with the public listeners that matter (80/443/API). Broad &lt;code&gt;notrack&lt;/code&gt; on all SYNs makes debugging harder and can surprise internal health checks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confusing this with application rate limits&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
SYNPROXY authenticates the TCP handshake. It does not replace HTTP auth, API quotas, Fail2ban-style abuse controls, or upstream DDoS scrubbing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expecting miracles under asymmetric routing&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Sequence translation and conntrack both assume the firewall sees both directions of the flow.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remove just the synproxy table(s)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft delete table inet synproxy_local 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;nft delete table inet synproxy_forward 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;nft delete table ip synproxy_named 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Or restore your previous full ruleset&lt;/span&gt;
&lt;span class="c"&gt;# sudo nft -f /etc/nftables.conf.bak&lt;/span&gt;

&lt;span class="c"&gt;# Restore loose tracking if you intentionally want the old behavior&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.netfilter.nf_conntrack_tcp_loose&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="c"&gt;# and edit/remove /etc/sysctl.d/90-synproxy.conf as needed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep a known-good &lt;code&gt;nft list ruleset&lt;/code&gt; dump before the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/root/nftables-before-synproxy-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.nft"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How this differs from nearby defenses
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Main job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tcp_syncookies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;local TCP stack&lt;/td&gt;
&lt;td&gt;survive SYN backlog overflow on a socket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;nftables &lt;code&gt;synproxy&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Netfilter&lt;/td&gt;
&lt;td&gt;complete handshake with cookies &lt;strong&gt;before&lt;/strong&gt; spending full conntrack/backend state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fail2ban + nftables sets&lt;/td&gt;
&lt;td&gt;auth/log abuse&lt;/td&gt;
&lt;td&gt;ban sources after application or log signals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nftables flowtables&lt;/td&gt;
&lt;td&gt;established forward path&lt;/td&gt;
&lt;td&gt;skip classic Netfilter for already-good flows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tc HTB / CAKE&lt;/td&gt;
&lt;td&gt;queueing/bandwidth&lt;/td&gt;
&lt;td&gt;shape or fair-queue traffic, not authenticate SYNs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use SYNPROXY when the failure mode is “unauthenticated TCP handshakes are cheap for the attacker and expensive for conntrack/backends.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal production recipe
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) sysctls&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/90-synproxy.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
net.netfilter.nf_conntrack_tcp_loose = 0
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_timestamps = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;

&lt;span class="c"&gt;# 2) measure backend options with tcpdump (replace NIC/port/IP)&lt;/span&gt;
&lt;span class="c"&gt;# sudo tcpdump -pni eth0 -c 1 'tcp[tcpflags] == (tcp-syn|tcp-ack) &amp;amp;&amp;amp; port 443'&lt;/span&gt;

&lt;span class="c"&gt;# 3) load rules with the measured mss/wscale/flags&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/synproxy-local.nft

&lt;span class="c"&gt;# 4) verify&lt;/span&gt;
sysctl net.netfilter.nf_conntrack_tcp_loose
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list table inet synproxy_local
curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://127.0.0.1/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;If your public Linux host still treats every bare &lt;code&gt;SYN&lt;/code&gt; as worth a full conntrack entry, you are letting the cheapest packets buy the most expensive state.&lt;/p&gt;

&lt;p&gt;nftables SYNPROXY is not a full anti-DDoS platform. It is a precise kernel feature with a clear contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;measure the backend TCP options&lt;/li&gt;
&lt;li&gt;untrack bare SYNs&lt;/li&gt;
&lt;li&gt;proxy the handshake with cookies&lt;/li&gt;
&lt;li&gt;drop what still looks invalid&lt;/li&gt;
&lt;li&gt;keep loose conntrack off&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do that, and SYN floods stop converting directly into conntrack exhaustion on the ports you chose to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources and references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;nftables wiki — Synproxy: &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Synproxy" rel="noopener noreferrer"&gt;https://wiki.nftables.org/wiki-nftables/index.php/Synproxy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nft(8)&lt;/code&gt; manpage — SYNPROXY statement and example ruleset: &lt;a href="https://www.netfilter.org/projects/nftables/manpage.html" rel="noopener noreferrer"&gt;https://www.netfilter.org/projects/nftables/manpage.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Debian &lt;code&gt;nft(8)&lt;/code&gt; manpage mirror: &lt;a href="https://manpages.debian.org/bookworm/nftables/nft.8.en.html" rel="noopener noreferrer"&gt;https://manpages.debian.org/bookworm/nftables/nft.8.en.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Patrick McHardy — netfilter SYN proxy design notes (LWN): &lt;a href="https://lwn.net/Articles/563151/" rel="noopener noreferrer"&gt;https://lwn.net/Articles/563151/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Linux kernel &lt;code&gt;ip-sysctl&lt;/code&gt; docs — &lt;code&gt;tcp_syncookies&lt;/code&gt;, &lt;code&gt;tcp_timestamps&lt;/code&gt;: &lt;a href="https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt" rel="noopener noreferrer"&gt;https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Betting on One Path: Practical Multipath TCP (MPTCP) on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Tue, 18 Aug 2026 05:03:02 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-betting-on-one-path-practical-multipath-tcp-mptcp-on-linux-133j</link>
      <guid>https://dev.to/lyraalishaikh/stop-betting-on-one-path-practical-multipath-tcp-mptcp-on-linux-133j</guid>
      <description>&lt;h1&gt;
  
  
  Stop Betting on One Path: Practical Multipath TCP (MPTCP) on Linux
&lt;/h1&gt;

&lt;p&gt;Most Linux TCP sessions still die with the path they started on. Cable unplugged, LTE handoff, or a flaky ISP hop later, the socket is gone — even if another NIC still has a working route to the peer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multipath TCP (MPTCP)&lt;/strong&gt; fixes that at the transport layer. One logical connection can use several TCP &lt;em&gt;subflows&lt;/em&gt; over different addresses or interfaces, aggregate bandwidth when paths are healthy, and fail over when one path dies. Upstream Linux implements MPTCPv1 (&lt;a href="https://www.rfc-editor.org/rfc/rfc8684.html" rel="noopener noreferrer"&gt;RFC 8684&lt;/a&gt;). This post is a practical operator guide: enable it, configure the in-kernel path manager, wrap legacy services, verify with &lt;code&gt;ss&lt;/code&gt;, and avoid the usual traps.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Not covered here: Ethernet bonding/LACP (link aggregation), Device Mapper multipath (storage paths), TCP BBR (congestion control), or tc HTB shaping. Those solve different layers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What you get (and what you do not)
&lt;/h2&gt;

&lt;p&gt;MPTCP helps when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A host has &lt;strong&gt;two or more usable L3 paths&lt;/strong&gt; (wired + Wi‑Fi, dual ISP, lab dual-homed servers).&lt;/li&gt;
&lt;li&gt;You want &lt;strong&gt;session continuity&lt;/strong&gt; across path loss (handover / backup path).&lt;/li&gt;
&lt;li&gt;You want optional &lt;strong&gt;aggregation&lt;/strong&gt; when both paths carry data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MPTCP does &lt;strong&gt;not&lt;/strong&gt; magically help when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only one path exists end-to-end.&lt;/li&gt;
&lt;li&gt;The peer or a middlebox strips MPTCP options (connection falls back to plain TCP).&lt;/li&gt;
&lt;li&gt;Apps never open MPTCP sockets and nothing wraps them (&lt;code&gt;mptcpize&lt;/code&gt;, eBPF helpers, or native &lt;code&gt;IPPROTO_MPTCP&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Strict reverse-path filtering drops asymmetric subflow replies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kernel docs summarize the model cleanly: path manager (which subflows/addresses exist) + packet scheduler (which subflow sends next). See the &lt;a href="https://www.kernel.org/doc/html/latest/networking/mptcp.html" rel="noopener noreferrer"&gt;kernel MPTCP overview&lt;/a&gt; and &lt;a href="https://www.mptcp.dev/" rel="noopener noreferrer"&gt;mptcp.dev&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux &lt;strong&gt;5.6+&lt;/strong&gt; for basic MPTCP sockets; multi-subflow usefulness really lands from &lt;strong&gt;5.10+&lt;/strong&gt; onward (mainline timeline is documented on the &lt;a href="https://github.com/multipath-tcp/mptcp_net-next/wiki" rel="noopener noreferrer"&gt;mptcp_net-next wiki&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iproute2&lt;/strong&gt; with &lt;code&gt;ip mptcp&lt;/code&gt; (full iproute2 — BusyBox &lt;code&gt;ip&lt;/code&gt; is not enough).&lt;/li&gt;
&lt;li&gt;Optional: &lt;strong&gt;mptcpd&lt;/strong&gt; package for &lt;code&gt;mptcpize&lt;/code&gt; (wrap legacy TCP binaries / systemd units).&lt;/li&gt;
&lt;li&gt;A peer that speaks MPTCP, or a lab peer you control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check whether the stack is present and enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Kernel feature present?&lt;/span&gt;
sysctl net.mptcp.enabled

&lt;span class="c"&gt;# Path manager + scheduler (names vary slightly by kernel age)&lt;/span&gt;
sysctl net.mptcp.path_manager net.mptcp.scheduler 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;sysctl net.mptcp.pm_type 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;   &lt;span class="c"&gt;# deprecated since v6.15; use path_manager&lt;/span&gt;

&lt;span class="c"&gt;# iproute2 supports MPTCP?&lt;/span&gt;
ip mptcp &lt;span class="nb"&gt;help&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ip mptcp OK"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default on current kernels: &lt;code&gt;net.mptcp.enabled=1&lt;/code&gt; and in-kernel path manager (&lt;code&gt;path_manager=kernel&lt;/code&gt; / historically &lt;code&gt;pm_type=0&lt;/code&gt;). Sysctl reference: &lt;a href="https://www.kernel.org/doc/html/latest/networking/mptcp-sysctl.html" rel="noopener noreferrer"&gt;MPTCP Sysfs variables&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mental model in one diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App socket (IPPROTO_MPTCP)
        │
        ▼
   MPTCP connection  ── scheduler picks subflow(s)
        │
   ┌────┴────┐
   ▼         ▼
 TCP subflow A     TCP subflow B
 (eth0 / ISP1)     (wwan0 / ISP2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client&lt;/strong&gt; typically creates extra subflows (&lt;code&gt;subflow&lt;/code&gt; endpoints).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server&lt;/strong&gt; typically announces extra addresses (&lt;code&gt;signal&lt;/code&gt; endpoints / ADD_ADDR).&lt;/li&gt;
&lt;li&gt;Limits cap how many extra subflows and ADD_ADDR acceptances are allowed &lt;strong&gt;per connection&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1 — Raise path-manager limits
&lt;/h2&gt;

&lt;p&gt;Defaults are conservative. On many systems &lt;code&gt;add_addr_accepted&lt;/code&gt; starts at &lt;strong&gt;0&lt;/strong&gt;, which means a client will &lt;strong&gt;not&lt;/strong&gt; open subflows toward peer-advertised addresses until you raise it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Show current limits&lt;/span&gt;
ip mptcp limits

&lt;span class="c"&gt;# Allow additional subflows + accept ADD_ADDR from peers&lt;/span&gt;
&lt;span class="c"&gt;# Values are per MPTCP connection (see ip-mptcp(8))&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp limits &lt;span class="nb"&gt;set &lt;/span&gt;subflow 2 add_addr_accepted 2

ip mptcp limits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From &lt;a href="https://manpages.debian.org/bookworm/iproute2/ip-mptcp.8.en.html" rel="noopener noreferrer"&gt;ip-mptcp(8)&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;subflow&lt;/code&gt; — max &lt;strong&gt;additional&lt;/strong&gt; subflows (created or accepted) per connection.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;add_addr_accepted&lt;/code&gt; — max incoming ADD_ADDR options that may trigger new subflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Persist with a oneshot unit (example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/system/mptcp-limits.service &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Unit]
Description=Set MPTCP path-manager limits
After=network-pre.target
Before=network.target

[Service]
Type=oneshot
ExecStart=/usr/sbin/ip mptcp limits set subflow 2 add_addr_accepted 2
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; mptcp-limits.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — Add endpoints (the part people skip)
&lt;/h2&gt;

&lt;p&gt;Endpoints tell the in-kernel path manager which local addresses participate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always pass &lt;code&gt;dev &amp;lt;ifname&amp;gt;&lt;/code&gt;.&lt;/strong&gt; Without it, source selection/routing often surprises you (&lt;a href="https://www.mptcp.dev/pm.html" rel="noopener noreferrer"&gt;mptcp.dev path manager notes&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Client-style: create subflows from extra NICs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Primary path is whatever the initial connect uses.&lt;/span&gt;
&lt;span class="c"&gt;# Tell MPTCP it may also originate subflows from a second NIC:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp endpoint add 192.0.2.20 dev eth1 subflow

&lt;span class="c"&gt;# Optional: treat a cellular/USB path as backup only&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp endpoint add 100.64.1.134 dev wwan0 subflow backup

ip mptcp endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flags that matter day-to-day (&lt;a href="https://www.mankier.com/8/ip-mptcp" rel="noopener noreferrer"&gt;ip-mptcp(8)&lt;/a&gt;, &lt;a href="https://www.mptcp.dev/pm.html" rel="noopener noreferrer"&gt;pm.html&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Typical role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subflow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Use this local address to &lt;strong&gt;create&lt;/strong&gt; extra subflows (client-ish).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;signal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Announce&lt;/strong&gt; this address to peers via ADD_ADDR (server-ish).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prefer non-backup subflows; use this path when others are unavailable.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fullmesh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pair this source with each known peer address (mesh topology).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;laminar&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Newer kernels: use this source toward peer ADD_ADDR targets, once per connection (see mptcp.dev / recent man pages).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Server-style: advertise an extra address
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Announce a second server address clients may join&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp endpoint add 198.51.100.10 dev eth0 signal

ip mptcp endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flush or delete when testing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp endpoint delete &lt;span class="nb"&gt;id &lt;/span&gt;1
&lt;span class="c"&gt;# or&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp endpoint flush
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist endpoints the same way as limits (oneshot &lt;code&gt;ExecStart=&lt;/code&gt; lines), or let &lt;strong&gt;NetworkManager ≥ 1.40&lt;/strong&gt; auto-configure &lt;code&gt;subflow&lt;/code&gt; endpoints — and do &lt;strong&gt;not&lt;/strong&gt; fight it with mptcpd at the same time (&lt;a href="https://www.mptcp.dev/pm.html" rel="noopener noreferrer"&gt;pm.html automatic configuration&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Open MPTCP sockets (apps are opt-in)
&lt;/h2&gt;

&lt;p&gt;MPTCP is &lt;strong&gt;opt-in&lt;/strong&gt; at the socket API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AF_INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SOCK_STREAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IPPROTO_MPTCP&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="cm"&gt;/* IPPROTO_MPTCP == 262 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If MPTCP is disabled or unavailable, you get &lt;code&gt;ENOPROTOOPT&lt;/code&gt; / &lt;code&gt;EPROTONOSUPPORT&lt;/code&gt; / &lt;code&gt;EINVAL&lt;/code&gt; depending on kernel age (&lt;a href="https://www.kernel.org/doc/html/latest/networking/mptcp.html" rel="noopener noreferrer"&gt;kernel docs&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Force legacy TCP programs with mptcpize
&lt;/h3&gt;

&lt;p&gt;From &lt;a href="https://www.mankier.com/8/mptcpize" rel="noopener noreferrer"&gt;mptcpize(8)&lt;/a&gt; (mptcpd package):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One-shot wrap&lt;/span&gt;
mptcpize run curl &lt;span class="nt"&gt;-sS&lt;/span&gt; https://example.com/ &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null

&lt;span class="c"&gt;# Debug when a TCP socket is rewritten&lt;/span&gt;
mptcpize run &lt;span class="nt"&gt;-d&lt;/span&gt; my-client &lt;span class="nt"&gt;--flags&lt;/span&gt;

&lt;span class="c"&gt;# systemd service wrap (updates unit to launch under mptcpize)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mptcpize &lt;span class="nb"&gt;enable &lt;/span&gt;nginx.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart nginx.service

&lt;span class="c"&gt;# Undo&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mptcpize disable nginx.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Native language support also exists in various stacks (for example Go via &lt;code&gt;GODEBUG=multipathtcp=1&lt;/code&gt; on supported versions — treat as app-specific and verify on your runtime). Kernel docs also mention eBPF-based forcing approaches for advanced setups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tiny Python lab listener (native)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;

&lt;span class="n"&gt;IPPROTO_MPTCP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;262&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AF_INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOCK_STREAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IPPROTO_MPTCP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setsockopt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOL_SOCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SO_REUSEADDR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MPTCP listen on :9000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello-mptcp&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair with &lt;code&gt;mptcpize run nc ...&lt;/code&gt; or another MPTCP-capable client from a second path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Routing and rp_filter (silent killers)
&lt;/h2&gt;

&lt;p&gt;Subflows are still TCP connections with their own source addresses. If reverse-path filtering is strict, return traffic on the “wrong” NIC gets dropped.&lt;/p&gt;

&lt;p&gt;From &lt;a href="https://www.mptcp.dev/pm.html" rel="noopener noreferrer"&gt;mptcp.dev path manager notes&lt;/a&gt;: prefer &lt;strong&gt;loose&lt;/strong&gt; rp_filter when MPTCP is in play:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Per-interface example — use loose mode (2) instead of strict (1)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.conf.eth1.rp_filter&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.conf.wwan0.rp_filter&lt;span class="o"&gt;=&lt;/span&gt;2

&lt;span class="c"&gt;# Persist&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/70-mptcp-rpfilter.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
net.ipv4.conf.eth1.rp_filter = 2
net.ipv4.conf.wwan0.rp_filter = 2
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also ensure each source address has a sensible route out its own interface (policy routing if needed). Dual-default-route hosts without source-based routing will mis-send subflows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Verify with ss, monitor, and nstat
&lt;/h2&gt;

&lt;h3&gt;
  
  
  List MPTCP sockets
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# MPTCP socket table&lt;/span&gt;
ss &lt;span class="nt"&gt;-Mni&lt;/span&gt;

&lt;span class="c"&gt;# Listening MPTCP sockets&lt;/span&gt;
ss &lt;span class="nt"&gt;-Mln&lt;/span&gt;

&lt;span class="c"&gt;# TCP sockets including MPTCP ULP/subflow detail on TCP rows&lt;/span&gt;
ss &lt;span class="nt"&gt;-ti&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'1,80p'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ss&lt;/code&gt; documents &lt;code&gt;-M, --mptcp&lt;/code&gt; for the MPTCP socket table, and TCP info may show &lt;code&gt;tcp-ulp-mptcp ...&lt;/code&gt; on subflows (&lt;a href="https://manpages.debian.org/testing/iproute2/ss.8.en.html" rel="noopener noreferrer"&gt;ss(8)&lt;/a&gt;). Community docs also use &lt;code&gt;ss -Mai&lt;/code&gt; when diagnosing limit counters (&lt;a href="https://www.mptcp.dev/pm.html" rel="noopener noreferrer"&gt;pm.html&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Live path-manager events
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Another terminal while you connect&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp monitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see connection creation and address/subflow events as endpoints join.&lt;/p&gt;

&lt;h3&gt;
  
  
  MIB counters
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nstat &lt;span class="nt"&gt;-az&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; mptcp
&lt;span class="c"&gt;# or&lt;/span&gt;
nstat | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; mptcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful when hunting blackholes/fallbacks. Related sysctls include &lt;code&gt;net.mptcp.blackhole_timeout&lt;/code&gt; (default 3600s) and &lt;code&gt;net.mptcp.syn_retrans_before_tcp_fallback&lt;/code&gt; (&lt;a href="https://www.kernel.org/doc/html/latest/networking/mptcp-sysctl.html" rel="noopener noreferrer"&gt;sysctl docs&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Failover smoke test (lab)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Start an MPTCP server on dual-homed host A (&lt;code&gt;signal&lt;/code&gt; endpoints + limits).&lt;/li&gt;
&lt;li&gt;From dual-homed client B, set &lt;code&gt;subflow&lt;/code&gt; endpoints + &lt;code&gt;add_addr_accepted&lt;/code&gt;, then open a long transfer (&lt;code&gt;mptcpize run iperf3 ...&lt;/code&gt; or a large &lt;code&gt;curl&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Confirm multiple subflows via &lt;code&gt;ss&lt;/code&gt; / &lt;code&gt;ip mptcp monitor&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Administratively down one client path (&lt;code&gt;ip link set eth1 down&lt;/code&gt;) and confirm the transfer continues on the remaining subflow instead of resetting like single-path TCP.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If it immediately falls back to single-path TCP, capture a SYN handshake and check for missing MPTCP options (peer or middlebox).&lt;/p&gt;

&lt;h2&gt;
  
  
  systemd pattern: durable client wrapper
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/backup-sync.service&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Unit]
&lt;span class="nv"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;MPTCP-backed &lt;span class="nb"&gt;sync &lt;/span&gt;client
&lt;span class="nv"&gt;After&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;network-online.target mptcp-limits.service
&lt;span class="nv"&gt;Wants&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;network-online.target

&lt;span class="o"&gt;[&lt;/span&gt;Service]
&lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;simple
&lt;span class="c"&gt;# Prefer mptcpize enable on the unit, or wrap ExecStart:&lt;/span&gt;
&lt;span class="nv"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin/mptcpize run /usr/local/bin/backup-sync &lt;span class="nt"&gt;--target&lt;/span&gt; dual.example.net
&lt;span class="nv"&gt;Restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on-failure

&lt;span class="o"&gt;[&lt;/span&gt;Install]
&lt;span class="nv"&gt;WantedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep endpoint programming in &lt;code&gt;mptcp-limits.service&lt;/code&gt; (or a dedicated &lt;code&gt;mptcp-endpoints.service&lt;/code&gt;) ordered &lt;strong&gt;before&lt;/strong&gt; the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational pitfalls checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Limits left at defaults&lt;/strong&gt; — especially &lt;code&gt;add_addr_accepted=0&lt;/code&gt; on clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;dev&lt;/code&gt; on endpoints&lt;/strong&gt; — broken source routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strict &lt;code&gt;rp_filter=1&lt;/code&gt;&lt;/strong&gt; — subflow replies blackholed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only one side MPTCP-aware&lt;/strong&gt; — safe fallback to TCP, but no multipath benefit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleboxes&lt;/strong&gt; — some PEPs/firewalls interfere; blackhole detection may temporarily disable MPTCP on affected sockets (&lt;code&gt;blackhole_timeout&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NM + mptcpd both owning endpoints&lt;/strong&gt; — pick one automation path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confusing layers&lt;/strong&gt; — bonding aggregates L2 links into one interface; MPTCP spreads one TCP session across multiple L3 paths. You can use both, but they are not substitutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security policy&lt;/strong&gt; — more subflows mean more allowed 4-tuples; update nftables/conntrack expectations and logging.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stop wrapping services&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;mptcpize disable nginx.service 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Clear endpoints and tighten limits&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp endpoint flush
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip mptcp limits &lt;span class="nb"&gt;set &lt;/span&gt;subflow 0 add_addr_accepted 0

&lt;span class="c"&gt;# Optional: disable new MPTCP sockets entirely&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.mptcp.enabled&lt;span class="o"&gt;=&lt;/span&gt;0

&lt;span class="c"&gt;# Remove persistence you added&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; mptcp-limits.service 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/system/mptcp-limits.service /etc/sysctl.d/70-mptcp-rpfilter.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Existing plain TCP sockets are unaffected either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  When MPTCP is the right tool
&lt;/h2&gt;

&lt;p&gt;Choose MPTCP when session continuity or multi-path TCP throughput matters across &lt;strong&gt;independent L3 paths&lt;/strong&gt;, and you can control (or at least test) both ends.&lt;/p&gt;

&lt;p&gt;Prefer other tools when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You only need NIC failover on one switch — &lt;strong&gt;bonding&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You need SAN path redundancy — &lt;strong&gt;DM-Multipath&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You need fair sharing / AQM — &lt;strong&gt;CAKE/fq_codel&lt;/strong&gt; or classful &lt;strong&gt;tc&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You need sender congestion behavior on a single path — &lt;strong&gt;BBR/CUBIC&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RFC 8684 — Multipath TCP (MPTCP) v1: &lt;a href="https://www.rfc-editor.org/rfc/rfc8684.html" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc8684.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Kernel MPTCP overview: &lt;a href="https://www.kernel.org/doc/html/latest/networking/mptcp.html" rel="noopener noreferrer"&gt;https://www.kernel.org/doc/html/latest/networking/mptcp.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Kernel MPTCP sysctl: &lt;a href="https://www.kernel.org/doc/html/latest/networking/mptcp-sysctl.html" rel="noopener noreferrer"&gt;https://www.kernel.org/doc/html/latest/networking/mptcp-sysctl.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Official site &amp;amp; path manager guide: &lt;a href="https://www.mptcp.dev/" rel="noopener noreferrer"&gt;https://www.mptcp.dev/&lt;/a&gt; and &lt;a href="https://www.mptcp.dev/pm.html" rel="noopener noreferrer"&gt;https://www.mptcp.dev/pm.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ip-mptcp(8): &lt;a href="https://manpages.debian.org/bookworm/iproute2/ip-mptcp.8.en.html" rel="noopener noreferrer"&gt;https://manpages.debian.org/bookworm/iproute2/ip-mptcp.8.en.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ss(8) (&lt;code&gt;-M&lt;/code&gt; / MPTCP): &lt;a href="https://manpages.debian.org/testing/iproute2/ss.8.en.html" rel="noopener noreferrer"&gt;https://manpages.debian.org/testing/iproute2/ss.8.en.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;mptcpize(8): &lt;a href="https://www.mankier.com/8/mptcpize" rel="noopener noreferrer"&gt;https://www.mankier.com/8/mptcpize&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Upstream feature timeline: &lt;a href="https://github.com/multipath-tcp/mptcp_net-next/wiki" rel="noopener noreferrer"&gt;https://github.com/multipath-tcp/mptcp_net-next/wiki&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Ship dual-homed hosts with endpoints + limits + one wrapped critical client first. Prove failover with a deliberate link down before you chase aggregation benchmarks. Multipath is only real when the second subflow carries traffic while the first is on fire.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop One Noisy Tenant Starving the Rest: Practical Hierarchical Traffic Shaping with tc HTB on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Mon, 17 Aug 2026 05:02:53 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-one-noisy-tenant-starving-the-rest-practical-hierarchical-traffic-shaping-with-tc-htb-on-linux-3ggo</link>
      <guid>https://dev.to/lyraalishaikh/stop-one-noisy-tenant-starving-the-rest-practical-hierarchical-traffic-shaping-with-tc-htb-on-linux-3ggo</guid>
      <description>&lt;h1&gt;
  
  
  Stop One Noisy Tenant Starving the Rest: Practical Hierarchical Traffic Shaping with tc HTB on Linux
&lt;/h1&gt;

&lt;p&gt;CAKE and fq_codel are excellent at fighting &lt;strong&gt;bufferbloat&lt;/strong&gt; — they keep latency low when a link is saturated. TCP BBR helps a &lt;strong&gt;sender&lt;/strong&gt; estimate path capacity. Neither answers a different ops question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“This uplink is 100 Mbit. Give interactive and API traffic a guaranteed floor, let backups borrow leftover capacity up to a hard ceiling, and stop one bulk flow from eating the whole pipe.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is &lt;strong&gt;classful hierarchical shaping&lt;/strong&gt;. On Linux, the workhorse is still &lt;strong&gt;HTB&lt;/strong&gt; (Hierarchy Token Bucket), configured with &lt;code&gt;tc&lt;/code&gt; from iproute2.&lt;/p&gt;

&lt;p&gt;This post is a practical recipe: root HTB, parent and leaf classes with &lt;code&gt;rate&lt;/code&gt;/&lt;code&gt;ceil&lt;/code&gt;, filters that classify traffic, &lt;code&gt;fq_codel&lt;/code&gt; under each leaf, live verification, and rollback. No custom kernel required.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is (and is not)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hierarchical &lt;strong&gt;bandwidth allocation&lt;/strong&gt; and borrowing&lt;/td&gt;
&lt;td&gt;Tenant/share caps, “API gets 20 Mbit guaranteed”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CAKE / fq_codel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;AQM&lt;/strong&gt; — control queue delay under load&lt;/td&gt;
&lt;td&gt;WAN edge bufferbloat control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BBR / CUBIC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per-connection &lt;strong&gt;TCP congestion control&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;How fast one TCP sender ramps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RSS/RPS/XPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Which &lt;strong&gt;CPU&lt;/strong&gt; handles packet work&lt;/td&gt;
&lt;td&gt;Multi-queue NIC scaling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use HTB when you need &lt;strong&gt;explicit class shares and ceilings&lt;/strong&gt;. Keep CAKE or fq_codel for latency under congestion (often as the &lt;strong&gt;leaf&lt;/strong&gt; qdisc under HTB, or on a pure edge shaper). Do not treat HTB as a replacement for BBR or packet steering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mental model in one page
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;tc(8)&lt;/code&gt; and &lt;code&gt;tc-htb(8)&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shaping is egress.&lt;/strong&gt; You control how fast the host &lt;em&gt;sends&lt;/em&gt; on a device.&lt;/li&gt;
&lt;li&gt;HTB builds a &lt;strong&gt;tree of classes&lt;/strong&gt;. Each class has:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rate&lt;/code&gt;&lt;/strong&gt; — guaranteed bandwidth (tokens replenished at this rate)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ceil&lt;/code&gt;&lt;/strong&gt; — hard maximum when borrowing spare capacity from the parent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prio&lt;/code&gt;&lt;/strong&gt; — lower number is preferred when classes compete for leftover bandwidth&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only leaf classes shape packets.&lt;/strong&gt; Inner/parent classes define how tokens are shared and borrowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filters&lt;/strong&gt; attached to the HTB qdisc (or classes) decide which leaf gets each packet (&lt;code&gt;u32&lt;/code&gt;, &lt;code&gt;fw&lt;/code&gt;, &lt;code&gt;flower&lt;/code&gt;, …).&lt;/li&gt;
&lt;li&gt;Unclassified traffic goes to the HTB &lt;strong&gt;&lt;code&gt;default&lt;/code&gt;&lt;/strong&gt; minor class id.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Borrowing (from the classic HTB / TLDP model):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Below &lt;code&gt;rate&lt;/code&gt; → class may send (uses its own tokens).&lt;/li&gt;
&lt;li&gt;Between &lt;code&gt;rate&lt;/code&gt; and &lt;code&gt;ceil&lt;/code&gt; → class may &lt;strong&gt;borrow&lt;/strong&gt; from the parent if the parent has spare capacity.&lt;/li&gt;
&lt;li&gt;At/above &lt;code&gt;ceil&lt;/code&gt; → class cannot send more until tokens return (packets queue / delay).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb from long-standing HTB guidance: &lt;strong&gt;sum of child &lt;code&gt;rate&lt;/code&gt; values should not exceed the parent’s &lt;code&gt;rate&lt;/code&gt;&lt;/strong&gt; (ideally they match), while children may set higher &lt;code&gt;ceil&lt;/code&gt; values up to the parent’s &lt;code&gt;ceil&lt;/code&gt; so they can borrow leftover capacity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux with HTB in the kernel (stock for many years; any current LTS is fine)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iproute2&lt;/code&gt; (&lt;code&gt;tc&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Root (or &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Optional: &lt;code&gt;nftables&lt;/code&gt; if you prefer fwmark classification&lt;/li&gt;
&lt;li&gt;Optional: &lt;code&gt;iperf3&lt;/code&gt; for a controlled soak test
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
tc &lt;span class="nt"&gt;-V&lt;/span&gt;
ip &lt;span class="nt"&gt;-br&lt;/span&gt; &lt;span class="nb"&gt;link&lt;/span&gt;
&lt;span class="c"&gt;# Pick the egress NIC you will shape (example: eth0 / ens18 / enp1s0)&lt;/span&gt;
&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Shape the real bottleneck.&lt;/strong&gt; If the host’s NIC is 1 Gbit but the ISP uplink is 100 Mbit, set HTB’s root/&lt;code&gt;ceil&lt;/code&gt; to ~&lt;strong&gt;95–98% of the true bottleneck&lt;/strong&gt; (slightly under physical rate so the &lt;em&gt;Linux&lt;/em&gt; queue is the controlled one, not a dumb ISP buffer).&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab topology (example numbers)
&lt;/h2&gt;

&lt;p&gt;Assume uplink &lt;code&gt;eth0&lt;/code&gt; is effectively &lt;strong&gt;100 Mbit&lt;/strong&gt; outbound. You want:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Guaranteed (&lt;code&gt;rate&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Ceiling (&lt;code&gt;ceil&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1:10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Interactive / low-latency (SSH, DNS, small control)&lt;/td&gt;
&lt;td&gt;10 Mbit&lt;/td&gt;
&lt;td&gt;100 Mbit&lt;/td&gt;
&lt;td&gt;1 (best)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1:20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Default / general web &amp;amp; API&lt;/td&gt;
&lt;td&gt;40 Mbit&lt;/td&gt;
&lt;td&gt;100 Mbit&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1:30&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bulk / backups / media&lt;/td&gt;
&lt;td&gt;50 Mbit&lt;/td&gt;
&lt;td&gt;80 Mbit&lt;/td&gt;
&lt;td&gt;3 (worst)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Parent class &lt;code&gt;1:1&lt;/code&gt; holds the full link budget. Leaf rates sum to 100 Mbit. Bulk is capped at 80 Mbit even when the pipe is idle-ish of interactive traffic — adjust to taste.&lt;/p&gt;

&lt;p&gt;Replace &lt;code&gt;eth0&lt;/code&gt; everywhere with your interface name.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install the HTB root and classes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0

&lt;span class="c"&gt;# Clean slate on this NIC (destructive to existing root qdisc)&lt;/span&gt;
tc qdisc del dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; root 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Root HTB. default 20 =&amp;gt; unclassified traffic goes to class 1:20&lt;/span&gt;
tc qdisc add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; root handle 1: htb default 20

&lt;span class="c"&gt;# Parent: full shaped uplink budget&lt;/span&gt;
tc class add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: classid 1:1 htb &lt;span class="se"&gt;\&lt;/span&gt;
  rate 100mbit ceil 100mbit burst 32k cburst 32k

&lt;span class="c"&gt;# Leaves&lt;/span&gt;
tc class add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:1 classid 1:10 htb &lt;span class="se"&gt;\&lt;/span&gt;
  rate 10mbit ceil 100mbit prio 1 burst 15k cburst 15k

tc class add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:1 classid 1:20 htb &lt;span class="se"&gt;\&lt;/span&gt;
  rate 40mbit ceil 100mbit prio 2 burst 20k cburst 20k

tc class add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:1 classid 1:30 htb &lt;span class="se"&gt;\&lt;/span&gt;
  rate 50mbit ceil 80mbit prio 3 burst 20k cburst 20k
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why &lt;code&gt;burst&lt;/code&gt; / &lt;code&gt;cburst&lt;/code&gt; matter
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;tc-htb(8)&lt;/code&gt; notes that timer granularity limits how large a rate you can express without a big enough bucket. If bursts are tiny relative to rate, you get needless throttling and odd latency. Start with the values above (or let &lt;code&gt;tc&lt;/code&gt; compute defaults by omitting them once you understand the tree), then raise modestly if high-rate classes under-deliver on small packets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attach an AQM leaf under each class
&lt;/h3&gt;

&lt;p&gt;Bare HTB leaves default to a simple FIFO (&lt;code&gt;pfifo&lt;/code&gt;). Under load that reintroduces bufferbloat &lt;em&gt;inside&lt;/em&gt; each class. Attach &lt;strong&gt;fq_codel&lt;/strong&gt; (or CAKE on a single-class edge) under every leaf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;10 20 30&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;tc qdisc add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:&lt;span class="nv"&gt;$id&lt;/span&gt; handle &lt;span class="nv"&gt;$id&lt;/span&gt;: fq_codel &lt;span class="se"&gt;\&lt;/span&gt;
    limit 10240 target 5ms interval 100ms ecn
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now each share gets hierarchical bandwidth &lt;strong&gt;and&lt;/strong&gt; flow-fair low-latency queuing inside the share.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Classify traffic into classes
&lt;/h2&gt;

&lt;p&gt;You need filters so packets land in &lt;code&gt;1:10&lt;/code&gt; / &lt;code&gt;1:20&lt;/code&gt; / &lt;code&gt;1:30&lt;/code&gt;. Two practical patterns:&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern A — pure &lt;code&gt;tc u32&lt;/code&gt; (no firewall marks)
&lt;/h3&gt;

&lt;p&gt;Good for simple port/subnet rules kept next to the qdisc:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Interactive: SSH (22), DNS (53)&lt;/span&gt;
tc filter add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: protocol ip prio 1 u32 &lt;span class="se"&gt;\&lt;/span&gt;
  match ip dport 22 0xffff flowid 1:10
tc filter add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: protocol ip prio 1 u32 &lt;span class="se"&gt;\&lt;/span&gt;
  match ip sport 22 0xffff flowid 1:10
tc filter add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: protocol ip prio 1 u32 &lt;span class="se"&gt;\&lt;/span&gt;
  match ip dport 53 0xffff flowid 1:10
tc filter add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: protocol ip prio 1 u32 &lt;span class="se"&gt;\&lt;/span&gt;
  match ip sport 53 0xffff flowid 1:10

&lt;span class="c"&gt;# Bulk example: traffic to a backup subnet 10.20.30.0/24&lt;/span&gt;
tc filter add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: protocol ip prio 5 u32 &lt;span class="se"&gt;\&lt;/span&gt;
  match ip dst 10.20.30.0/24 flowid 1:30

&lt;span class="c"&gt;# Everything else falls through to HTB default 1:20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from &lt;code&gt;tc-u32(8)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;match ip dport&lt;/code&gt; / &lt;code&gt;sport&lt;/code&gt; assume a normal L4 header layout (careful with fragments / unusual encapsulation).&lt;/li&gt;
&lt;li&gt;Lower &lt;code&gt;prio&lt;/code&gt; number is consulted earlier.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flowid&lt;/code&gt; / &lt;code&gt;classid&lt;/code&gt; send the packet to that HTB class.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pattern B — nftables mark + &lt;code&gt;tc fw&lt;/code&gt; filter (recommended for real policy)
&lt;/h3&gt;

&lt;p&gt;Mark once in Netfilter (where you already express policy), then map marks to classes. From the nftables wiki, packet marks are set with &lt;code&gt;meta mark set …&lt;/code&gt;. From &lt;code&gt;tc-fw(8)&lt;/code&gt;, the fw classifier matches that mark.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# --- nftables: mark bulk vs interactive (example table) ---&lt;/span&gt;
nft &lt;span class="nt"&gt;-f&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
flush table inet qos 2&amp;gt;/dev/null || true
table inet qos {
  chain output {
    type filter hook output priority -150; policy accept;
    # Interactive control plane
    tcp dport { 22 } meta mark set 10
    udp dport { 53 } meta mark set 10
    tcp sport { 22 } meta mark set 10

    # Bulk: backup host or high ports used by your sync tool
    ip daddr 10.20.30.0/24 meta mark set 30
    tcp dport { 873, 2222 } meta mark set 30   # rsync / custom bulk
  }

  chain postrouting {
    type filter hook postrouting priority -150; policy accept;
    # Forwarded traffic (router/gateway use-case)
    tcp dport { 22 } meta mark set 10
    ip daddr 10.20.30.0/24 meta mark set 30
  }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# --- tc: map marks to HTB classes (handle == mark) ---&lt;/span&gt;
tc filter add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: protocol ip prio 1 handle 10 fw flowid 1:10
tc filter add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: protocol ip prio 1 handle 30 fw flowid 1:30
&lt;span class="c"&gt;# mark 0 / unmarked → HTB default 1:20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this scales better than a giant &lt;code&gt;u32&lt;/code&gt; forest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One place for complex matches (sets, interfaces, conntrack state).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tc-fw(8)&lt;/code&gt; stays a thin mark→class map.&lt;/li&gt;
&lt;li&gt;You can persist marks across related packets with conntrack mark save/restore (&lt;code&gt;ct mark set mark&lt;/code&gt; / &lt;code&gt;meta mark set ct mark&lt;/code&gt;) when you need whole-flow consistency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Verify the tree
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tc &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; qdisc show dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
tc &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; class show dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
tc &lt;span class="nt"&gt;-s&lt;/span&gt; filter show dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you want to see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Root &lt;code&gt;qdisc htb 1:&lt;/code&gt; with &lt;code&gt;default 0x20&lt;/code&gt; (hex for minor 20)&lt;/li&gt;
&lt;li&gt;Classes &lt;code&gt;1:1&lt;/code&gt;, &lt;code&gt;1:10&lt;/code&gt;, &lt;code&gt;1:20&lt;/code&gt;, &lt;code&gt;1:30&lt;/code&gt; with the rates you set&lt;/li&gt;
&lt;li&gt;Leaf qdiscs &lt;code&gt;fq_codel&lt;/code&gt; under each leaf&lt;/li&gt;
&lt;li&gt;Filters with non-zero match counts after traffic flows&lt;/li&gt;
&lt;li&gt;Under load, bulk class &lt;code&gt;1:30&lt;/code&gt; shows sends near its &lt;code&gt;ceil&lt;/code&gt; while &lt;code&gt;1:10&lt;/code&gt; still gets airtime&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Watch live counters while generating traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;watch &lt;span class="nt"&gt;-n1&lt;/span&gt; &lt;span class="s2"&gt;"tc -s class show dev &lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt; | sed -n '1,120p'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Controlled proof with iperf3
&lt;/h2&gt;

&lt;p&gt;On a receiver beyond the shaped path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# receiver&lt;/span&gt;
iperf3 &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the shaped host (or a client behind it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) Bulk-class push (mark 30 path or dst that maps to 1:30)&lt;/span&gt;
iperf3 &lt;span class="nt"&gt;-c&lt;/span&gt; RECEIVER &lt;span class="nt"&gt;-t&lt;/span&gt; 30 &lt;span class="nt"&gt;-P&lt;/span&gt; 4

&lt;span class="c"&gt;# 2) While bulk runs, start an interactive-class flow (SSH tunnel / small iperf to port classified as 1:10)&lt;/span&gt;
&lt;span class="c"&gt;# You should still see responsive SSH and class 1:10 counters moving.&lt;/span&gt;

&lt;span class="c"&gt;# 3) Compare: without HTB, bulk often starves latency; with HTB, bulk sticks near ceil and interactive keeps rate tokens.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interpret honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTB guarantees &lt;strong&gt;link shares&lt;/strong&gt;, not application SLOs end-to-end.&lt;/li&gt;
&lt;li&gt;If the true bottleneck is downstream of this host, shape there or accept that local HTB only protects &lt;em&gt;this&lt;/em&gt; egress queue.&lt;/li&gt;
&lt;li&gt;TCP still does congestion control inside each class; fq_codel leaves keep per-flow fairness inside a share.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Persist with a systemd oneshot
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tc&lt;/code&gt; rules are not durable across reboot unless you install them. A small oneshot is explicit and easy to audit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /usr/local/sbin/tc-htb-wan.sh&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/usr/local/sbin/tc-htb-wan.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
set -euo pipefail
IFACE="&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;eth0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"

tc qdisc del dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" root 2&amp;gt;/dev/null || true

tc qdisc add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" root handle 1: htb default 20
tc class add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" parent 1:  classid 1:1  htb rate 100mbit ceil 100mbit burst 32k cburst 32k
tc class add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" parent 1:1 classid 1:10 htb rate 10mbit  ceil 100mbit prio 1 burst 15k cburst 15k
tc class add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" parent 1:1 classid 1:20 htb rate 40mbit  ceil 100mbit prio 2 burst 20k cburst 20k
tc class add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" parent 1:1 classid 1:30 htb rate 50mbit  ceil 80mbit  prio 3 burst 20k cburst 20k

for id in 10 20 30; do
  tc qdisc add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" parent 1:&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="sh"&gt; handle &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="sh"&gt;: fq_codel limit 10240 target 5ms interval 100ms ecn
done

# Example u32 bulk + interactive; replace with fw filters if you use nft marks
tc filter add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" parent 1: protocol ip prio 1 u32 match ip dport 22 0xffff flowid 1:10
tc filter add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" parent 1: protocol ip prio 1 u32 match ip sport 22 0xffff flowid 1:10
tc filter add dev "&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;" parent 1: protocol ip prio 5 u32 match ip dst 10.20.30.0/24 flowid 1:30
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;755 /usr/local/sbin/tc-htb-wan.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/tc-htb-wan.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;HTB hierarchical egress shaping on WAN NIC&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-pre.target&lt;/span&gt;
&lt;span class="py"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-pre.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;IFACE=eth0&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/sbin/tc-htb-wan.sh&lt;/span&gt;
&lt;span class="py"&gt;ExecStop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/sbin/tc qdisc del dev ${IFACE} root&lt;/span&gt;
&lt;span class="py"&gt;ExecReload&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/sbin/tc-htb-wan.sh&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; tc-htb-wan.service
systemctl status tc-htb-wan.service &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
tc &lt;span class="nt"&gt;-s&lt;/span&gt; class show dev eth0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the interface name is renamed by udev/systemd-networkd, bind the unit with &lt;code&gt;BindsTo=sys-subsystem-net-devices-eth0.device&lt;/code&gt; (adjust the netdev unit name) or drive interface selection from a &lt;code&gt;.network&lt;/code&gt; drop-in environment file.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Operational pitfalls
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wrong direction.&lt;/strong&gt; HTB on egress shapes &lt;em&gt;transmit&lt;/em&gt;. Download-heavy home WAN bottlenecks often need shaping on the &lt;strong&gt;ISP-facing egress of the router&lt;/strong&gt;, and sometimes IFB/mirred redirect tricks for ingress — do not expect host-only HTB to fix a dumb downstream buffer you do not own.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;default 0&lt;/code&gt; trap.&lt;/strong&gt; HTB’s default &lt;code&gt;default&lt;/code&gt; of &lt;code&gt;0&lt;/code&gt; can send unclassified traffic in ways that bypass your carefully built leaves. Always set &lt;code&gt;default&lt;/code&gt; to a real leaf minor id.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Leaf rate sum &amp;gt; parent rate.&lt;/strong&gt; Over-subscribing guarantees means the “guarantee” is fiction under full load. Keep Σ leaf &lt;code&gt;rate&lt;/code&gt; ≤ parent &lt;code&gt;rate&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Classification misses.&lt;/strong&gt; If filters never match, everything piles into &lt;code&gt;default&lt;/code&gt;. Check &lt;code&gt;tc -s filter&lt;/code&gt; hit counts before tuning rates.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Offload / TSO surprises.&lt;/strong&gt; Aggressive NIC offloads rarely break HTB outright, but when rates look “soft,” compare with offloads temporarily simplified for debugging (&lt;code&gt;ethtool -K … gso off tso off&lt;/code&gt; on a lab NIC — measure, then restore).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hardware HTB offload.&lt;/strong&gt; &lt;code&gt;tc-htb(8)&lt;/code&gt; documents an &lt;code&gt;offload&lt;/code&gt; flag when driver and device support it. Treat it as optional acceleration; verify with the same class counters, and keep a software fallback path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HTB is not multi-tenant security isolation.&lt;/strong&gt; It allocates bandwidth. It does not replace VRF, netns, firewall policy, or auth.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  7. Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remove shaping entirely (kernel returns to the previous default qdisc behavior on new setup;&lt;/span&gt;
&lt;span class="c"&gt;# often fq_codel/cake/pfifo_fast depending on distro defaults)&lt;/span&gt;
tc qdisc del dev eth0 root

&lt;span class="c"&gt;# If you used the systemd unit:&lt;/span&gt;
systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; tc-htb-wan.service

&lt;span class="c"&gt;# If you added the nft qos table:&lt;/span&gt;
nft delete table inet qos
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When to prefer something else
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Prefer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fix bufferbloat on a single WAN uplink with minimal classes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;CAKE&lt;/strong&gt; (&lt;code&gt;bandwidth&lt;/code&gt; + nat/docsis/overhead knobs) or fq_codel at the bottleneck&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make &lt;em&gt;this host’s TCP sends&lt;/em&gt; cope with long/lossy paths&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;BBR + fq&lt;/strong&gt; on the sender&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spread softirq across CPUs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;RSS/RPS/RFS/XPS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L4 virtual services&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;IPVS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hierarchical multi-share bandwidth with borrow/ceil&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;HTB&lt;/strong&gt; (this post)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;HTB and CAKE are complementary on many gateways: CAKE (or fq_codel) for latency discipline at the true bottleneck, HTB when you must express &lt;strong&gt;policy shares&lt;/strong&gt; between tenants or traffic classes. Some designs use HTB parents with fq_codel leaves exactly for that split of concerns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick copy-paste checklist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
&lt;span class="nv"&gt;RATE_UP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100mbit   &lt;span class="c"&gt;# set to ~95% of real bottleneck&lt;/span&gt;

tc qdisc del dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; root 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;tc qdisc add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; root handle 1: htb default 20
tc class add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:  classid 1:1  htb rate &lt;span class="nv"&gt;$RATE_UP&lt;/span&gt; ceil &lt;span class="nv"&gt;$RATE_UP&lt;/span&gt;
tc class add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:1 classid 1:10 htb rate 10mbit ceil &lt;span class="nv"&gt;$RATE_UP&lt;/span&gt; prio 1
tc class add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:1 classid 1:20 htb rate 40mbit ceil &lt;span class="nv"&gt;$RATE_UP&lt;/span&gt; prio 2
tc class add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:1 classid 1:30 htb rate 50mbit ceil 80mbit  prio 3
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;10 20 30&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;tc qdisc add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1:&lt;span class="nv"&gt;$id&lt;/span&gt; handle &lt;span class="nv"&gt;$id&lt;/span&gt;: fq_codel
&lt;span class="k"&gt;done
&lt;/span&gt;tc filter add dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; parent 1: protocol ip prio 1 u32 match ip dport 22 0xffff flowid 1:10
tc &lt;span class="nt"&gt;-s&lt;/span&gt; class show dev &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add real classification (more &lt;code&gt;u32&lt;/code&gt; matches or nft marks + &lt;code&gt;fw&lt;/code&gt; filters), soak-test with bulk + interactive traffic, and only then enable the systemd oneshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tc-htb(8)&lt;/code&gt; — Hierarchy Token Bucket qdisc and class parameters (&lt;code&gt;rate&lt;/code&gt;, &lt;code&gt;ceil&lt;/code&gt;, &lt;code&gt;burst&lt;/code&gt;, &lt;code&gt;prio&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;offload&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tc(8)&lt;/code&gt; — Traffic control overview: qdiscs, classes, filters; shaping vs policing&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tc-fw(8)&lt;/code&gt; — fwmark classifier (&lt;code&gt;handle&lt;/code&gt; matches mark)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tc-u32(8)&lt;/code&gt; — Universal 32-bit classifier (&lt;code&gt;match ip …&lt;/code&gt;, &lt;code&gt;flowid&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tc-fq_codel(8)&lt;/code&gt; — Fair queuing + CoDel leaf AQM&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tldp.org/HOWTO/Traffic-Control-HOWTO/classful-qdiscs.html" rel="noopener noreferrer"&gt;TLDP Traffic Control HOWTO — Classful qdiscs / HTB borrowing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Setting_packet_metainformation" rel="noopener noreferrer"&gt;nftables wiki — Setting packet metainformation (&lt;code&gt;meta mark set&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Martin Devera’s HTB site (historical design notes): &lt;a href="http://luxik.cdi.cz/%7Edevik/qos/htb/" rel="noopener noreferrer"&gt;http://luxik.cdi.cz/~devik/qos/htb/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Hierarchical shaping is old Linux technology — and still the right hammer when one backup job keeps making SSH feel like dial-up. Set the root to the &lt;strong&gt;real&lt;/strong&gt; bottleneck, give each class an honest &lt;code&gt;rate&lt;/code&gt;, let them borrow up to &lt;code&gt;ceil&lt;/code&gt;, classify on purpose, and put fq_codel under the leaves so shares stay fast &lt;em&gt;and&lt;/em&gt; fair.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Leaving Throughput on the Table: Practical TCP BBR Congestion Control on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Sun, 16 Aug 2026 07:02:45 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-leaving-throughput-on-the-table-practical-tcp-bbr-congestion-control-on-linux-20le</link>
      <guid>https://dev.to/lyraalishaikh/stop-leaving-throughput-on-the-table-practical-tcp-bbr-congestion-control-on-linux-20le</guid>
      <description>&lt;h1&gt;
  
  
  Stop Leaving Throughput on the Table: Practical TCP BBR Congestion Control on Linux
&lt;/h1&gt;

&lt;p&gt;Most Linux hosts still ship &lt;strong&gt;CUBIC&lt;/strong&gt; as the default TCP congestion controller. That is a solid loss-based algorithm — until the path has meaningful RTT, shallow buffers, or a little random loss. Then CUBIC backs off on drops that were never a clean "the pipe is full" signal, and bulk transfers crawl while the link still has headroom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BBR&lt;/strong&gt; (Bottleneck Bandwidth and Round-trip propagation time) takes a different approach: it estimates the path's available bandwidth and minimum RTT, then paces sends around that model instead of treating every loss as congestion. Stock mainline Linux has shipped BBR (v1) since kernel &lt;strong&gt;4.9&lt;/strong&gt;. You do not need a custom kernel to try it.&lt;/p&gt;

&lt;p&gt;This post is the operational checklist: load it, pair it with the right qdisc, verify live sockets, A/B test against CUBIC, and understand the fairness and BBRv3 caveats before you make it permanent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is (and is not)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Typical tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Congestion control&lt;/td&gt;
&lt;td&gt;How fast &lt;em&gt;this TCP sender&lt;/em&gt; ramps and reacts&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cubic&lt;/code&gt;, &lt;code&gt;bbr&lt;/code&gt;, &lt;code&gt;reno&lt;/code&gt; via &lt;code&gt;tcp_congestion_control&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queueing / AQM&lt;/td&gt;
&lt;td&gt;How the &lt;em&gt;host egress queue&lt;/em&gt; shares and drops/marks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fq&lt;/code&gt;, &lt;code&gt;fq_codel&lt;/code&gt;, &lt;code&gt;cake&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packet steering&lt;/td&gt;
&lt;td&gt;Which CPU handles RX/TX work&lt;/td&gt;
&lt;td&gt;RSS, RPS, RFS, XPS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;BBR is &lt;strong&gt;sender congestion control&lt;/strong&gt;. It is not a replacement for bufferbloat fixes (CAKE / fq_codel on the WAN edge), L4 load balancing (IPVS), or multi-queue IRQ steering. Fix those problems with the right tool; use BBR when the &lt;em&gt;TCP sender&lt;/em&gt; is the bottleneck on long or lossy paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux &lt;strong&gt;4.9+&lt;/strong&gt; (ideally a current LTS: 6.1/6.6/6.12-class)&lt;/li&gt;
&lt;li&gt;Root (or equivalent) for sysctl, modules, and &lt;code&gt;tc&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iproute2&lt;/code&gt; (&lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;tc&lt;/code&gt;, &lt;code&gt;ip&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Optional: &lt;code&gt;iperf3&lt;/code&gt; for controlled comparisons
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control
sysctl net.core.default_qdisc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On many distros the default looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;net.ipv4.tcp_congestion_control = cubic
net.ipv4.tcp_available_congestion_control = reno cubic
net.core.default_qdisc = fq_codel   # or pfifo_fast / cake, depending on distro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tcp_available_congestion_control&lt;/code&gt; only lists &lt;strong&gt;registered&lt;/strong&gt; algorithms. If &lt;code&gt;bbr&lt;/code&gt; is built as a module and not loaded yet, it may be absent until you load it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Load BBR and set it for new connections
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Load the module when BBR is not built-in&lt;/span&gt;
modprobe tcp_bbr

&lt;span class="c"&gt;# Confirm it registered&lt;/span&gt;
sysctl net.ipv4.tcp_available_congestion_control
&lt;span class="c"&gt;# expect something like: reno cubic bbr&lt;/span&gt;

&lt;span class="c"&gt;# Switch the default for *new* connections&lt;/span&gt;
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.tcp_congestion_control&lt;span class="o"&gt;=&lt;/span&gt;bbr
sysctl net.ipv4.tcp_congestion_control
&lt;span class="c"&gt;# net.ipv4.tcp_congestion_control = bbr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from the kernel IP sysctl docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tcp_congestion_control&lt;/code&gt; applies to &lt;strong&gt;new&lt;/strong&gt; connections.&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;passive&lt;/strong&gt; (accepted) connections, the listener's congestion-control choice is inherited.&lt;/li&gt;
&lt;li&gt;Apps can still override per socket with &lt;code&gt;setsockopt(..., TCP_CONGESTION, ...)&lt;/code&gt; when the name is allowed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tcp_allowed_congestion_control&lt;/code&gt; restricts which algorithms unprivileged processes may select (default is a small subset including the system default).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make the module stick across reboots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# systemd-style module load&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'tcp_bbr\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/modules-load.d/tcp_bbr.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Pair BBR with &lt;code&gt;fq&lt;/code&gt; (Fair Queue), not only with a wish
&lt;/h2&gt;

&lt;p&gt;BBR paces. The classic pairing is &lt;strong&gt;&lt;code&gt;fq&lt;/code&gt;&lt;/strong&gt; (Fair Queue), which does per-flow separation and respects TCP pacing / EDT departure times for locally generated traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Default qdisc for *new* device setups after this point&lt;/span&gt;
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.core.default_qdisc&lt;span class="o"&gt;=&lt;/span&gt;fq

&lt;span class="c"&gt;# Apply fq on the live egress NIC now (replace eth0)&lt;/span&gt;
tc qdisc replace dev eth0 root fq
tc &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; qdisc show dev eth0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From &lt;code&gt;tc-fq(8)&lt;/code&gt;: FQ is meant mostly for &lt;strong&gt;locally generated&lt;/strong&gt; traffic, separates flows, and honors pacing set by the TCP stack (including EDT after Linux 4.20). That is exactly the host-as-sender case for origin servers, backup nodes, and build hosts.&lt;/p&gt;

&lt;h3&gt;
  
  
  How this relates to CAKE / fq_codel
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Prefer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host &lt;em&gt;originates&lt;/em&gt; bulk TCP and you want BBR pacing to work cleanly&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fq&lt;/code&gt; + BBR on that host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host is a &lt;strong&gt;router/gateway&lt;/strong&gt; fighting bufferbloat for many flows&lt;/td&gt;
&lt;td&gt;CAKE or fq_codel on the bottleneck egress (often the WAN uplink)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You already run CAKE on the gateway&lt;/td&gt;
&lt;td&gt;Keep CAKE there; BBR still helps &lt;em&gt;endpoints&lt;/em&gt; that originate long transfers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can run BBR on servers behind a CAKE-shaped edge. Congestion control and AQM solve different layers. Just do not expect &lt;code&gt;default_qdisc=fq&lt;/code&gt; on a pure router to replace proper bottleneck AQM.&lt;/p&gt;

&lt;h3&gt;
  
  
  systemd-networkd optional qdisc
&lt;/h3&gt;

&lt;p&gt;If you manage interfaces with networkd and want the qdisc declared next to the NIC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/network/20-wan.network
&lt;/span&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;eth0&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;DHCP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[CAKE]&lt;/span&gt;
&lt;span class="c"&gt;# Only if you intentionally want CAKE here instead of fq.
# Bandwidth=300M
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For BBR endpoints, prefer an explicit &lt;code&gt;tc&lt;/code&gt; oneshot (below) or your distro's documented qdisc hook rather than forcing CAKE onto every origin NIC "because bufferbloat articles said so."&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Persist with sysctl.d
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/etc/sysctl.d/99-tcp-bbr.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# TCP BBR + pacing-friendly default qdisc
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;span class="c"&gt;# or: sysctl -p /etc/sysctl.d/99-tcp-bbr.conf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember: &lt;code&gt;default_qdisc&lt;/code&gt; affects qdiscs created &lt;strong&gt;after&lt;/strong&gt; the setting is applied. Existing interfaces may still show the old root qdisc until you &lt;code&gt;tc qdisc replace&lt;/code&gt; them or reboot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optional: apply &lt;code&gt;fq&lt;/code&gt; at boot with a oneshot
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/fq-wan.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Install fq qdisc on WAN NIC for BBR pacing&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-pre.target&lt;/span&gt;
&lt;span class="py"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-pre.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="c"&gt;# Adjust interface name
&lt;/span&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/sbin/tc qdisc replace dev eth0 root fq&lt;/span&gt;
&lt;span class="py"&gt;ExecReload&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/sbin/tc qdisc replace dev eth0 root fq&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; fq-wan.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Verify on live sockets (not only sysctl)
&lt;/h2&gt;

&lt;p&gt;Sysctl only proves the &lt;em&gt;default&lt;/em&gt;. Prove the &lt;em&gt;flows&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Idle check&lt;/span&gt;
sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc

&lt;span class="c"&gt;# While a transfer runs (curl, iperf3, restic, apt, etc.)&lt;/span&gt;
ss &lt;span class="nt"&gt;-tin&lt;/span&gt;

&lt;span class="c"&gt;# Narrow to one peer&lt;/span&gt;
ss &lt;span class="nt"&gt;-tin&lt;/span&gt; dst 203.0.113.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for &lt;code&gt;bbr&lt;/code&gt; in the TCP info block. On a modern &lt;code&gt;ss&lt;/code&gt;, BBR also exposes diagnostics similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bbr wscale:8,7 rto:216 rtt:15.924/4.256 ...
bbr:(bw:2.0Mbps,mrtt:14.451,pacing_gain:2.88672,cwnd_gain:2.88672)
pacing_rate 22.7Mbps delivery_rate 2.0Mbps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those &lt;code&gt;bbr:(bw:...,mrtt:...)&lt;/code&gt; fields are exactly what the &lt;a href="https://github.com/google/bbr/blob/master/Documentation/bbr-faq.md" rel="noopener noreferrer"&gt;BBR FAQ&lt;/a&gt; recommends for operational inspection.&lt;/p&gt;

&lt;p&gt;Also confirm the qdisc:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tc qdisc show dev eth0
&lt;span class="c"&gt;# qdisc fq ... root ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Existing connections keep their old CC
&lt;/h3&gt;

&lt;p&gt;Changing the sysctl does &lt;strong&gt;not&lt;/strong&gt; rewrite congestion control on sockets that already exist. Restart long-lived proxies, database pools, or VPN daemons if you need them on BBR immediately — or wait for natural reconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Fair A/B test: CUBIC vs BBR
&lt;/h2&gt;

&lt;p&gt;Use a path that resembles production (real WAN RTT, not only localhost).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terminal A (server):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iperf3 &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Terminal B (client), CUBIC baseline:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.tcp_congestion_control&lt;span class="o"&gt;=&lt;/span&gt;cubic
&lt;span class="c"&gt;# optional multi-stream bulk&lt;/span&gt;
iperf3 &lt;span class="nt"&gt;-c&lt;/span&gt; SERVER_IP &lt;span class="nt"&gt;-t&lt;/span&gt; 30 &lt;span class="nt"&gt;-P&lt;/span&gt; 4
&lt;span class="c"&gt;# reverse direction (server sends)&lt;/span&gt;
iperf3 &lt;span class="nt"&gt;-c&lt;/span&gt; SERVER_IP &lt;span class="nt"&gt;-t&lt;/span&gt; 30 &lt;span class="nt"&gt;-P&lt;/span&gt; 4 &lt;span class="nt"&gt;-R&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Same client, BBR:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;modprobe tcp_bbr
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.tcp_congestion_control&lt;span class="o"&gt;=&lt;/span&gt;bbr
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.core.default_qdisc&lt;span class="o"&gt;=&lt;/span&gt;fq
tc qdisc replace dev eth0 root fq

iperf3 &lt;span class="nt"&gt;-c&lt;/span&gt; SERVER_IP &lt;span class="nt"&gt;-t&lt;/span&gt; 30 &lt;span class="nt"&gt;-P&lt;/span&gt; 4
iperf3 &lt;span class="nt"&gt;-c&lt;/span&gt; SERVER_IP &lt;span class="nt"&gt;-t&lt;/span&gt; 30 &lt;span class="nt"&gt;-P&lt;/span&gt; 4 &lt;span class="nt"&gt;-R&lt;/span&gt;

ss &lt;span class="nt"&gt;-tin&lt;/span&gt; dst SERVER_IP | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Record for each run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;throughput (sender and receiver lines)&lt;/li&gt;
&lt;li&gt;RTT under load (&lt;code&gt;ping -c 20&lt;/code&gt; in parallel, or &lt;code&gt;ss&lt;/code&gt; rtt fields)&lt;/li&gt;
&lt;li&gt;retransmits (&lt;code&gt;ss -ti&lt;/code&gt; &lt;code&gt;retrans&lt;/code&gt;, or iperf retransmit counters when available)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where BBR usually wins
&lt;/h3&gt;

&lt;p&gt;ESnet and others report large gains on paths with &lt;strong&gt;higher RTT&lt;/strong&gt;, &lt;strong&gt;shallow buffers&lt;/strong&gt;, or &lt;strong&gt;loss that is not pure congestion&lt;/strong&gt;. Double-digit speedups on some science/WAN paths are common in published testing; your mileage depends on the bottleneck.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where CUBIC may be fine (or preferable)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Ultra-low-RTT datacenter LAN with deep, well-managed queues&lt;/li&gt;
&lt;li&gt;Environments that must share fairly with a large population of loss-based senders and cannot tolerate BBRv1 aggressiveness&lt;/li&gt;
&lt;li&gt;Paths where the real fix is loss elimination (bad optics, duplex mismatch, oversized buffers elsewhere), not a smarter sender&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ESnet explicitly notes that BBRv1 can &lt;strong&gt;compete unfairly&lt;/strong&gt; with CUBIC/HTCP on some shared bottlenecks, and that BBR is not a substitute for good network design or reducing loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Scoped overrides: per-route and per-socket
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Per-destination with &lt;code&gt;ip route … congctl&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;From &lt;code&gt;ip-route(8)&lt;/code&gt; (Linux 3.20+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Suggest BBR only toward a prefix (apps may still override unless locked)&lt;/span&gt;
ip route change default via 192.0.2.1 dev eth0 congctl bbr

&lt;span class="c"&gt;# Or lock it so applications cannot override&lt;/span&gt;
ip route replace 198.51.100.0/24 via 192.0.2.1 dev eth0 congctl lock bbr

&lt;span class="c"&gt;# Prefer CUBIC toward a sensitive internal prefix while global default is BBR&lt;/span&gt;
ip route replace 10.0.0.0/8 via 10.0.0.1 dev eth1 congctl lock cubic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this when only some destinations benefit (cross-region object storage, backup targets) and you want LAN defaults left alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-listener with &lt;code&gt;TCP_CONGESTION&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Kernel docs point at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;setsockopt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listenfd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SOL_TCP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TCP_CONGESTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bbr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Accepted connections inherit the listener's choice. Handy for a single bulk-transfer service without flipping the whole host.&lt;/p&gt;

&lt;p&gt;Unprivileged processes may only choose names listed in &lt;code&gt;tcp_allowed_congestion_control&lt;/code&gt;. Expand that list deliberately if apps need to self-select:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl net.ipv4.tcp_allowed_congestion_control
&lt;span class="c"&gt;# example expansion (review security/ops policy first):&lt;/span&gt;
&lt;span class="c"&gt;# sysctl -w net.ipv4.tcp_allowed_congestion_control="reno cubic bbr"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. BBRv1 vs BBRv2/v3 — be honest about mainline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;th&gt;Ops reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BBRv1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Mainline &lt;code&gt;tcp_bbr&lt;/code&gt; since Linux 4.9&lt;/td&gt;
&lt;td&gt;What &lt;code&gt;modprobe tcp_bbr&lt;/code&gt; gives you on stock distros&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BBRv2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Research / older previews&lt;/td&gt;
&lt;td&gt;Largely superseded by v3 work for new testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BBRv3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google’s &lt;a href="https://github.com/google/bbr/blob/v3/README.md" rel="noopener noreferrer"&gt;&lt;code&gt;google/bbr&lt;/code&gt; &lt;code&gt;v3&lt;/code&gt; branch&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not&lt;/strong&gt; merged as of mid-2026 mainline; custom kernel + testing required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Google’s v3 README is explicit: clone/build their tree (or patch), reboot into that kernel, then &lt;code&gt;sysctl net.ipv4.tcp_available_congestion_control&lt;/code&gt; should still show &lt;code&gt;bbr&lt;/code&gt; — but the &lt;em&gt;code behind the name&lt;/em&gt; is v3. They also ship iproute2 patches for richer &lt;code&gt;ss&lt;/code&gt; diagnostics and an &lt;code&gt;ecn_low&lt;/code&gt; per-route feature for L4S-style ECN environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommendation for production fleets today:&lt;/strong&gt; run &lt;strong&gt;stock BBRv1 + fq&lt;/strong&gt;, measure, and keep a rollback sysctl. Treat BBRv3 as a lab/custom-kernel project unless you already maintain kernel patches and have path-specific evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Emulation pitfalls (so your lab does not lie)
&lt;/h2&gt;

&lt;p&gt;From the BBR FAQ:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do &lt;strong&gt;not&lt;/strong&gt; put &lt;code&gt;netem&lt;/code&gt; loss/delay on the &lt;strong&gt;sending&lt;/strong&gt; machine if you want realistic TCP results (interaction with TSQ and related mechanisms). Put netem on an intermediate router namespace/host or on the &lt;strong&gt;receiver ingress&lt;/strong&gt; (IFB pattern).&lt;/li&gt;
&lt;li&gt;netem loss decisions can apply to whole TSO bursts, which is harsher/burstier than per-MTU loss — disable GRO/LRO when you need finer loss models.&lt;/li&gt;
&lt;li&gt;For serious CC comparison matrices, consider tools like &lt;a href="https://github.com/google/transperf" rel="noopener noreferrer"&gt;transperf&lt;/a&gt; rather than a single noisy iperf run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.tcp_congestion_control&lt;span class="o"&gt;=&lt;/span&gt;cubic
&lt;span class="c"&gt;# optional: restore previous qdisc policy&lt;/span&gt;
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.core.default_qdisc&lt;span class="o"&gt;=&lt;/span&gt;fq_codel
tc qdisc replace dev eth0 root fq_codel   &lt;span class="c"&gt;# or cake / distro default&lt;/span&gt;

&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/sysctl.d/99-tcp-bbr.conf
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/modules-load.d/tcp_bbr.conf
systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; fq-wan.service 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Long-lived sockets still need recycle to leave BBR.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical defaults I use
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Origin / backup / artifact hosts&lt;/strong&gt; that push over WAN: &lt;code&gt;bbr&lt;/code&gt; + &lt;code&gt;fq&lt;/code&gt;, verified with &lt;code&gt;ss -tin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge routers&lt;/strong&gt; fighting bufferbloat: keep &lt;strong&gt;CAKE/fq_codel&lt;/strong&gt; on the bottleneck; do not pretend BBR replaces AQM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency-sensitive internal fabrics&lt;/strong&gt; with excellent loss metrics: stay on CUBIC until measurements say otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed policy&lt;/strong&gt;: global CUBIC, &lt;code&gt;congctl lock bbr&lt;/code&gt; only toward known high-BDP prefixes — or the inverse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; flip production without a timed iperf/restic/apt mirror before/after and a one-line rollback.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;modprobe tcp_bbr&lt;/code&gt; and &lt;code&gt;bbr&lt;/code&gt; appears in &lt;code&gt;tcp_available_congestion_control&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;net.ipv4.tcp_congestion_control=bbr&lt;/code&gt; persisted under &lt;code&gt;/etc/sysctl.d/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;net.core.default_qdisc=fq&lt;/code&gt; persisted&lt;/li&gt;
&lt;li&gt;[ ] Live NIC root qdisc is &lt;code&gt;fq&lt;/code&gt; (&lt;code&gt;tc qdisc show&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Active flows show &lt;code&gt;bbr&lt;/code&gt; in &lt;code&gt;ss -tin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] A/B iperf (or real workload) recorded vs CUBIC on the real path&lt;/li&gt;
&lt;li&gt;[ ] Rollback sysctl documented&lt;/li&gt;
&lt;li&gt;[ ] BBRv3 custom kernels isolated to lab unless intentionally adopted&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Linux IP sysctl — &lt;code&gt;tcp_congestion_control&lt;/code&gt;, &lt;code&gt;tcp_available_congestion_control&lt;/code&gt;, &lt;code&gt;tcp_allowed_congestion_control&lt;/code&gt;: &lt;a href="https://docs.kernel.org/networking/ip-sysctl.html" rel="noopener noreferrer"&gt;docs.kernel.org/networking/ip-sysctl.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tc-fq(8)&lt;/code&gt; Fair Queue scheduler: &lt;a href="https://man7.org/linux/man-pages/man8/tc-fq.8.html" rel="noopener noreferrer"&gt;man7.org/linux/man-pages/man8/tc-fq.8.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ip-route(8)&lt;/code&gt; — &lt;code&gt;congctl&lt;/code&gt; / &lt;code&gt;congctl lock&lt;/code&gt;: &lt;a href="https://man7.org/linux/man-pages/man8/ip-route.8.html" rel="noopener noreferrer"&gt;man7.org/linux/man-pages/man8/ip-route.8.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google BBR FAQ (ss diagnostics, netem caveats, TCP_CC_INFO): &lt;a href="https://github.com/google/bbr/blob/master/Documentation/bbr-faq.md" rel="noopener noreferrer"&gt;github.com/google/bbr/blob/master/Documentation/bbr-faq.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google BBRv3 preview README: &lt;a href="https://github.com/google/bbr/blob/v3/README.md" rel="noopener noreferrer"&gt;github.com/google/bbr/blob/v3/README.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ESnet Fasterdata — BBR TCP notes and fairness cautions: &lt;a href="https://fasterdata.es.net/host-tuning/linux/recent-tcp-enhancements/bbr-tcp/" rel="noopener noreferrer"&gt;fasterdata.es.net/host-tuning/linux/recent-tcp-enhancements/bbr-tcp/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ACM Queue — &lt;em&gt;BBR: Congestion-Based Congestion Control&lt;/em&gt;: &lt;a href="http://queue.acm.org/detail.cfm?id=3022184" rel="noopener noreferrer"&gt;queue.acm.org/detail.cfm?id=3022184&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Mainline &lt;code&gt;tcp_bbr.c&lt;/code&gt; (net-next): &lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/davem/net-next.git/tree/net/ipv4/tcp_bbr.c" rel="noopener noreferrer"&gt;git.kernel.org/.../tcp_bbr.c&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;CUBIC is not "wrong." It is the wrong default for &lt;em&gt;some&lt;/em&gt; paths. Measure your RTT, loss, and bulk flows, put BBR where the sender model matches the path, keep AQM on the real bottleneck, and keep the rollback sysctl one file away.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Saturating One CPU Core: Practical RSS, RPS, RFS, and XPS Packet Steering on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Sat, 15 Aug 2026 05:02:41 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-saturating-one-cpu-core-practical-rss-rps-rfs-and-xps-packet-steering-on-linux-mij</link>
      <guid>https://dev.to/lyraalishaikh/stop-saturating-one-cpu-core-practical-rss-rps-rfs-and-xps-packet-steering-on-linux-mij</guid>
      <description>&lt;h1&gt;
  
  
  Stop Saturating One CPU Core: Practical RSS, RPS, RFS, and XPS Packet Steering on Linux
&lt;/h1&gt;

&lt;p&gt;You upgraded the NIC. Throughput still plateaus. &lt;code&gt;top&lt;/code&gt; shows one core stuck near 100% in softirq, while the rest of the machine looks bored.&lt;/p&gt;

&lt;p&gt;That is usually not “the kernel is slow.” It is &lt;strong&gt;serial receive processing&lt;/strong&gt;: one hardware queue, one IRQ affinity, one CPU doing most of the protocol work.&lt;/p&gt;

&lt;p&gt;Linux already has the tools to spread that work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Where you configure it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RSS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hardware spreads flows across RX queues&lt;/td&gt;
&lt;td&gt;driver channels + &lt;code&gt;ethtool&lt;/code&gt; RXFH + IRQ affinity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software spreads protocol processing across CPUs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rps_cpus&lt;/code&gt; per RX queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RFS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Steers a flow toward the CPU running the app&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rps_sock_flow_entries&lt;/code&gt; + &lt;code&gt;rps_flow_cnt&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;aRFS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hardware-assisted RFS&lt;/td&gt;
&lt;td&gt;needs ntuple + driver support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;XPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Picks TX queue from CPU (or RX queue) map&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;xps_cpus&lt;/code&gt; / &lt;code&gt;xps_rxqs&lt;/code&gt; per TX queue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This post is a practical operator path: diagnose the hot core, enable the right steering layer, verify with &lt;code&gt;/proc&lt;/code&gt; counters, and make the settings survive reboot. It is &lt;strong&gt;not&lt;/strong&gt; nftables flowtable offload, not IPVS load balancing, and not CAKE/AQM shaping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “one core is dying” looks like
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Softirq imbalance
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Live view of network softirqs&lt;/span&gt;
watch &lt;span class="nt"&gt;-n1&lt;/span&gt; &lt;span class="s1"&gt;'grep -E "CPU|NET_RX|NET_TX" /proc/softirqs'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;NET_RX&lt;/code&gt; climbs mostly on one CPU, receive work is concentrated.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) softnet backlog pressure
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/proc/net/softnet_stat&lt;/code&gt; is one line per CPU. Values are hex. A readable dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{
  for (i = 1; i &amp;lt;= NF; i++)
    printf strtonum("0x" $i) (i == NF ? "\n" : " ")
}'&lt;/span&gt; /proc/net/softnet_stat | column &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful columns (from kernel/RHEL operational docs):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;total frames processed
&lt;/li&gt;
&lt;li&gt;dropped because the CPU backlog was full
&lt;/li&gt;
&lt;li&gt;times &lt;code&gt;softirqd&lt;/code&gt; could not drain everything in one NAPI poll budget
…
last: CPU index
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rising column 2 → backlog too small or CPUs too hot.&lt;br&gt;&lt;br&gt;
Rising column 3 → NAPI budget/time may be too tight for the NIC rate.&lt;/p&gt;
&lt;h3&gt;
  
  
  3) Interrupt vectors stuck on one CPU
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0   &lt;span class="c"&gt;# change me&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"CPU|&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /proc/interrupts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Multi-queue NICs expose names like &lt;code&gt;eth0-rx-0&lt;/code&gt;, &lt;code&gt;eth0-TxRx-1&lt;/code&gt;. If one vector’s counters race while others barely move, RSS/IRQ placement needs work before you chase userspace.&lt;/p&gt;
&lt;h3&gt;
  
  
  4) How many queues exist right now?
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
ethtool &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;          &lt;span class="c"&gt;# channel max vs current&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-&lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-&lt;span class="k"&gt;*&lt;/span&gt;
ethtool &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;          &lt;span class="c"&gt;# driver / firmware&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Mental model (keep this straight)
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NIC RX DMA
   |
   v
 hardware RSS hash  --&amp;gt;  RX queue N  --&amp;gt;  hard IRQ on CPU affinity(N)
                              |
                              v
                     NAPI / softirq bottom half
                              |
              +---------------+----------------+
              |                                |
         plain path                      RPS/RFS path
     (same interrupting CPU)      (enqueue other CPU backlog + IPI)
              |                                |
              v                                v
        protocol stack                  protocol stack
              |                                |
              v                                v
           socket / app                   socket / app
              |
              v
     XPS picks TX queue from CPU map (or RXQ map)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RSS&lt;/strong&gt; chooses the &lt;strong&gt;queue&lt;/strong&gt; (and therefore which CPU takes the hard IRQ).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RPS&lt;/strong&gt; chooses which CPU does &lt;strong&gt;stack processing after&lt;/strong&gt; the IRQ.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFS&lt;/strong&gt; prefers the CPU where the &lt;strong&gt;receiving thread&lt;/strong&gt; runs.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XPS&lt;/strong&gt; chooses the &lt;strong&gt;transmit queue&lt;/strong&gt; so TX completions stay local and queue locks stay uncontended.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kernel docs are explicit: RPS is disabled until you write &lt;code&gt;rps_cpus&lt;/code&gt;; a zero mask means “process on the interrupting CPU.”&lt;/p&gt;
&lt;h2&gt;
  
  
  Path A — Fix hardware RSS first (preferred when the NIC can do it)
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1) Raise combined/RX/TX channels toward core count
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
ethtool &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# Example: ask for one combined queue per core (driver-dependent)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ethtool &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; combined 8
ethtool &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Not every driver exposes &lt;code&gt;combined&lt;/code&gt;. Some want separate &lt;code&gt;rx&lt;/code&gt;/&lt;code&gt;tx&lt;/code&gt;. Stay within &lt;strong&gt;Pre-set maximums&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Kernel guidance worth internalizing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For &lt;strong&gt;latency&lt;/strong&gt;, more queues (up to cores or NIC max) help.
&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;raw high-rate efficiency&lt;/strong&gt;, the smallest queue count that avoids a saturated CPU can win, because each extra queue can raise interrupt work when coalescing is on.
&lt;/li&gt;
&lt;li&gt;Hyperthreads often do not help IRQ handling; prefer &lt;strong&gt;physical cores&lt;/strong&gt; when sizing queues.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  2) Confirm / tune the RSS indirection table
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ethtool &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;                 &lt;span class="c"&gt;# show RX flow hash / indir table&lt;/span&gt;
&lt;span class="c"&gt;# equal spread across N queues (ethtool syntax):&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ethtool &lt;span class="nt"&gt;-X&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; equal 8
ethtool &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Advanced NICs also support n-tuple steering (&lt;code&gt;ethtool --config-ntuple&lt;/code&gt; / &lt;code&gt;-N&lt;/code&gt;) for “TCP/80 always on queue 2” style rules. That is optional; get plain RSS healthy first.&lt;/p&gt;
&lt;h3&gt;
  
  
  3) Pin each queue IRQ to a sensible CPU
&lt;/h3&gt;

&lt;p&gt;Find IRQs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /proc/interrupts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin with the list form (easier than huge hex masks):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Example: IRQ 105 -&amp;gt; CPU 0, IRQ 106 -&amp;gt; CPU 1, ...&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;0 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /proc/irq/105/smp_affinity_list
&lt;span class="nb"&gt;echo &lt;/span&gt;1 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /proc/irq/106/smp_affinity_list
&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"CPU|&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /proc/interrupts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/proc/irq/IRQ#/smp_affinity&lt;/code&gt; is the bitmask form; &lt;code&gt;smp_affinity_list&lt;/code&gt; accepts ranges like &lt;code&gt;0-3&lt;/code&gt; or &lt;code&gt;4,5,6,7&lt;/code&gt; (kernel IRQ-affinity docs).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;irqbalance note:&lt;/strong&gt; many distros run &lt;code&gt;irqbalance&lt;/code&gt;, which can move affinities out from under you. For a hand-tuned multi-queue NIC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status irqbalance 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="c"&gt;# Either stop it on dedicated packet engines, or teach it your policy.&lt;/span&gt;
&lt;span class="c"&gt;# sudo systemctl disable --now irqbalance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RHEL’s performance guide still recommends irqbalance for general-purpose hosts; dedicated gateways often do better with &lt;strong&gt;static&lt;/strong&gt; 1:1 queue↔CPU maps.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) When is RSS “enough”?
&lt;/h3&gt;

&lt;p&gt;If you already have roughly one RX queue per CPU, IRQ affinities are spread, and softirqs look balanced, &lt;strong&gt;RPS is often redundant&lt;/strong&gt; (kernel scaling doc). Stop here and measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path B — Add RPS when queues &amp;lt; CPUs (or the NIC is single-queue)
&lt;/h2&gt;

&lt;p&gt;RPS is the software cousin of RSS. It hashes the flow and enqueues the packet on another CPU’s backlog, then sends an IPI.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) CPU bitmaps in sysfs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
&lt;span class="c"&gt;# Show current masks (0 = RPS off)&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-0/rps_cpus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file is a &lt;strong&gt;hex CPU bitmask&lt;/strong&gt;, same idea as IRQ affinity masks.&lt;/p&gt;

&lt;p&gt;Examples on an 8-CPU host:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Mask&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;all CPUs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CPUs 0–7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPUs 0–3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;low half&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPUs 4–7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;high half&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU 2 only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;04&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bit 2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Single-queue NIC: spread protocol work across all CPUs&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;ff | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-0/rps_cpus

&lt;span class="c"&gt;# Multi-queue with 2 RX queues on an 8-CPU box:&lt;/span&gt;
&lt;span class="c"&gt;# queue 0 -&amp;gt; CPUs 0-3, queue 1 -&amp;gt; CPUs 4-7 (NUMA/cache-friendly split)&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;0f | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-0/rps_cpus
&lt;span class="nb"&gt;echo &lt;/span&gt;f0 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-1/rps_cpus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kernel suggestions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prefer CPUs in the &lt;strong&gt;same memory domain&lt;/strong&gt; as the interrupting CPU.
&lt;/li&gt;
&lt;li&gt;At very high IRQ rates, &lt;strong&gt;exclude&lt;/strong&gt; the interrupting CPU from &lt;code&gt;rps_cpus&lt;/code&gt; so it is not double-loaded.
&lt;/li&gt;
&lt;li&gt;If RSS already maps one queue per CPU, leave RPS at &lt;code&gt;0&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) Optional: RPS flow limit (many small flows + one elephant)
&lt;/h3&gt;

&lt;p&gt;Flow limit drops packets from oversized flows slightly earlier when a CPU backlog is under pressure, protecting mice flows. Off by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enable on all CPUs that handle network work (example: 8 CPUs)&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;ff | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /proc/sys/net/core/flow_limit_cpu_bitmap
&lt;span class="c"&gt;# Default table length is 4096 buckets&lt;/span&gt;
sysctl net.core.flow_limit_table_len

&lt;span class="c"&gt;# Experiments in the kernel doc used backlog 1000 or 10000&lt;/span&gt;
&lt;span class="c"&gt;# sudo sysctl -w net.core.netdev_max_backlog=10000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only engages when a CPU’s input queue exceeds half of &lt;code&gt;netdev_max_backlog&lt;/code&gt;, and it counts over a recent window (256 packets). Large flows are not hard-killed; they still get through when the queue is healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path C — RFS when the app CPU matters (cache locality)
&lt;/h2&gt;

&lt;p&gt;RPS balances by hash. &lt;strong&gt;RFS&lt;/strong&gt; steers kernel processing toward the CPU where the userspace thread last called &lt;code&gt;recvmsg&lt;/code&gt;/&lt;code&gt;sendmsg&lt;/code&gt; (and friends), improving cache hit rates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configure tables, then RPS masks
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Global desired-CPU table (rounded up to power of two)&lt;/span&gt;
&lt;span class="c"&gt;# Kernel doc: 32768–65536 is a common moderate-server starting point;&lt;/span&gt;
&lt;span class="c"&gt;# large hosts may want 1048576+.&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;32768 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /proc/sys/net/core/rps_sock_flow_entries

&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
&lt;span class="c"&gt;# Per-queue flow table. Single queue: match global.&lt;/span&gt;
&lt;span class="c"&gt;# Multi-queue: often global / Nqueues&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;q &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;32768 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;/rps_flow_cnt"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# RFS reuses the RPS enqueue path — rps_cpus must be non-zero&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;q &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;ff | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;/rps_cpus"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On big NUMA boxes, the kernel doc shows interleaving the global table allocation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# optional on large NUMA hosts&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;numactl &lt;span class="nt"&gt;--interleave&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'echo 1048576 &amp;gt; /proc/sys/net/core/rps_sock_flow_entries'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RFS keeps a second per-queue table so a flow only migrates to a new CPU when the old CPU has no outstanding packets for that flow — that is the out-of-order guard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Accelerated RFS (aRFS)
&lt;/h3&gt;

&lt;p&gt;If the NIC + driver support it (&lt;code&gt;CONFIG_RFS_ACCEL&lt;/code&gt;), enable ntuple and let the stack program hardware flow steering from the RFS tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ethtool &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ntuple on
ethtool &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'ntuple|receive-hashing'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No extra sysfs knobs beyond healthy RFS + IRQ affinities: the driver builds a CPU→queue reverse map from IRQ affinity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path D — XPS so transmit does not undo your work
&lt;/h2&gt;

&lt;p&gt;Multi-queue TX without XPS often means many CPUs fighting one TX ring lock, or TX completions landing far from the sender.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
&lt;span class="nb"&gt;ls&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-&lt;span class="k"&gt;*&lt;/span&gt;/xps_cpus

&lt;span class="c"&gt;# 1:1 CPU→TX queue map on an 8-queue device (exclusive pairings)&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;01 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-0/xps_cpus
&lt;span class="nb"&gt;echo &lt;/span&gt;02 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-1/xps_cpus
&lt;span class="nb"&gt;echo &lt;/span&gt;04 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-2/xps_cpus
&lt;span class="nb"&gt;echo &lt;/span&gt;08 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-3/xps_cpus
&lt;span class="nb"&gt;echo &lt;/span&gt;10 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-4/xps_cpus
&lt;span class="nb"&gt;echo &lt;/span&gt;20 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-5/xps_cpus
&lt;span class="nb"&gt;echo &lt;/span&gt;40 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-6/xps_cpus
&lt;span class="nb"&gt;echo &lt;/span&gt;80 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-7/xps_cpus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Busy-polling / queue-affine apps sometimes prefer &lt;strong&gt;RX-queue → TX-queue&lt;/strong&gt; maps via &lt;code&gt;xps_rxqs&lt;/code&gt; instead of &lt;code&gt;xps_cpus&lt;/code&gt;. The common case is still CPU maps with 1:1 pairings when queue count ≈ CPU count.&lt;/p&gt;

&lt;p&gt;The stack records the chosen TX queue on the socket so a flow does not bounce queues mid-flight (TCP sets &lt;code&gt;ooo_okay&lt;/code&gt; only when it is safe to change).&lt;/p&gt;

&lt;h2&gt;
  
  
  Support knobs that pair with steering
&lt;/h2&gt;

&lt;p&gt;These do not replace RSS/RPS, but they stop false “steering failed” diagnoses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Backlog drops (softnet column 2)&lt;/span&gt;
sysctl net.core.netdev_max_backlog
&lt;span class="c"&gt;# sudo sysctl -w net.core.netdev_max_backlog=5000&lt;/span&gt;

&lt;span class="c"&gt;# NAPI poll budget (softnet column 3 pressure)&lt;/span&gt;
sysctl net.core.netdev_budget net.core.netdev_budget_usecs
&lt;span class="c"&gt;# conservative doubling when time-starved:&lt;/span&gt;
&lt;span class="c"&gt;# sudo sysctl -w net.core.netdev_budget=600&lt;/span&gt;
&lt;span class="c"&gt;# sudo sysctl -w net.core.netdev_budget_usecs=4000&lt;/span&gt;

&lt;span class="c"&gt;# Ring buffers if ethtool -S shows rx_queue_*_drops / discards&lt;/span&gt;
ethtool &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
ethtool &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Ei&lt;/span&gt; &lt;span class="s1"&gt;'drop|discard|fifo|miss'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="c"&gt;# sudo ethtool -G "$IFACE" rx 4096 tx 4096&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Make it survive reboot
&lt;/h2&gt;

&lt;p&gt;Sysfs RPS/RFS/XPS settings are &lt;strong&gt;runtime&lt;/strong&gt;. Persist with a oneshot service (works with systemd-networkd, NetworkManager, or plain ip).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/usr/local/sbin/net-steering-apply&lt;/code&gt; (example for &lt;code&gt;eth0&lt;/code&gt;, 8 CPUs, 4 combined queues — edit before use):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;eth0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# hex mask for CPUs 0-7&lt;/span&gt;
&lt;span class="nv"&gt;ALL_CPUS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ff

&lt;span class="c"&gt;# Optional: set channels if the driver allows (ignore failures)&lt;/span&gt;
ethtool &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; combined 4 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# RFS global table&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;32768 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /proc/sys/net/core/rps_sock_flow_entries

&lt;span class="nv"&gt;nrx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; /sys/class/net/&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/queues/rx-&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$nrx&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 1 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no rx queues for &lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# per-queue flow cnt ≈ global / nrx (power-of-two friendly enough for ops)&lt;/span&gt;
&lt;span class="nv"&gt;per&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="m"&gt;32768&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; nrx &lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$per&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 1 &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;per&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="k"&gt;for &lt;/span&gt;q &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/net/&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/queues/rx-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$per&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;/rps_flow_cnt"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ALL_CPUS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;/rps_cpus"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# XPS: simple round-robin exclusive bits when queue count &amp;lt;= 8&lt;/span&gt;
&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;for &lt;/span&gt;q &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/net/&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/queues/tx-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c"&gt;# 1 &amp;lt;&amp;lt; i  in hex for i=0..7&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%x\n'&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;i&lt;/span&gt;&lt;span class="sh"&gt;)) &amp;gt; "&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="sh"&gt;/xps_cpus"
  i=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;i &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="sh"&gt;
  [[ "&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="sh"&gt;" -ge 8 ]] &amp;amp;&amp;amp; i=0
done

# Optional flow limit on all CPUs
echo "&lt;/span&gt;&lt;span class="nv"&gt;$ALL_CPUS&lt;/span&gt;&lt;span class="sh"&gt;" &amp;gt; /proc/sys/net/core/flow_limit_cpu_bitmap

# Show result
echo "== rps_cpus =="
grep -H . /sys/class/net/"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;"/queues/rx-*/rps_cpus
echo "== xps_cpus =="
grep -H . /sys/class/net/"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="sh"&gt;"/queues/tx-*/xps_cpus 2&amp;gt;/dev/null || true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 /usr/local/sbin/net-steering-apply /usr/local/sbin/net-steering-apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/system/net-steering@.service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Apply RPS/RFS/XPS steering for %i&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-pre.target sys-subsystem-net-devices-%i.device&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sys-subsystem-net-devices-%i.device&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;IFACE=%i&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/sbin/net-steering-apply&lt;/span&gt;
&lt;span class="py"&gt;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; net-steering@eth0.service
systemctl status net-steering@eth0.service &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist sysctls separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/90-net-steering.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
net.core.rps_sock_flow_entries = 32768
# net.core.netdev_max_backlog = 5000
# net.core.flow_limit_cpu_bitmap = ff
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;IRQ affinity persistence is driver/udev territory; either disable irqbalance on that host or use a small udev/&lt;code&gt;systemd&lt;/code&gt; oneshot that rewrites &lt;code&gt;/proc/irq/*/smp_affinity_list&lt;/code&gt; after the NIC registers MSI-X vectors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0

&lt;span class="c"&gt;# 1. Queues and RSS table&lt;/span&gt;
ethtool &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
ethtool &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 2. RPS/RFS/XPS sysfs&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-&lt;span class="k"&gt;*&lt;/span&gt;/rps_cpus
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-&lt;span class="k"&gt;*&lt;/span&gt;/rps_flow_cnt
sysctl net.core.rps_sock_flow_entries
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-&lt;span class="k"&gt;*&lt;/span&gt;/xps_cpus

&lt;span class="c"&gt;# 3. IRQ spread under load&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"CPU|&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /proc/interrupts
watch &lt;span class="nt"&gt;-n1&lt;/span&gt; &lt;span class="s1"&gt;'grep -E "CPU|NET_RX" /proc/softirqs'&lt;/span&gt;

&lt;span class="c"&gt;# 4. Backlog / budget pressure&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{for (i=1;i&amp;lt;=NF;i++) printf strtonum("0x"$i) (i==NF?"\n":" ")}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /proc/net/softnet_stat | column &lt;span class="nt"&gt;-t&lt;/span&gt;

&lt;span class="c"&gt;# 5. Application-level proof&lt;/span&gt;
&lt;span class="c"&gt;# Generate many concurrent flows (not one elephant TCP) and compare:&lt;/span&gt;
&lt;span class="c"&gt;#   - mpstat -P ALL 1&lt;/span&gt;
&lt;span class="c"&gt;#   - CPU distribution of NET_RX&lt;/span&gt;
&lt;span class="c"&gt;#   - app p99 latency / throughput&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fair test tip:&lt;/strong&gt; one fat TCP flow will still pin to one RX queue/CPU by design (flow hashing preserves order). Use many parallel flows (&lt;code&gt;iperf3 -P&lt;/code&gt;, real reverse-proxy traffic, etc.) when you judge balance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision tree
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;softnet drops on one CPU, IRQ counters also one-sided&lt;/strong&gt; → multi-queue RSS + IRQ affinity first.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NIC is single-queue or queues ≪ cores&lt;/strong&gt; → RPS masks on those RX queues.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU-bound userspace receiver, cache misses high&lt;/strong&gt; → add RFS tables; enable aRFS if ntuple works.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TX lock contention / TX softirq clump&lt;/strong&gt; → XPS 1:1 maps.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many mice + one elephant under load&lt;/strong&gt; → consider flow limit + sane &lt;code&gt;netdev_max_backlog&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Still dropping with balanced CPUs&lt;/strong&gt; → rings, NAPI budget, offloads (&lt;code&gt;ethtool -k&lt;/code&gt;), or an application/cgroup limit — not more bitmasks.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;nftables flowtables&lt;/strong&gt; — fast-path established forward flows past classic Netfilter hooks.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IPVS/LVS&lt;/strong&gt; — Layer-4 virtual services across real servers.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CAKE/fq_codel&lt;/strong&gt; — queue discipline / bufferbloat control.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XDP/eBPF&lt;/strong&gt; — programmable early drop/redirect; complementary, different tool.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;irqbalance alone&lt;/strong&gt; — helpful default for mixed hosts; not a substitute for understanding RSS vs RPS.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0
&lt;span class="c"&gt;# Disable RPS/RFS path&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;q &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/rx-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;0 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;/rps_cpus"&lt;/span&gt;
  &lt;span class="nb"&gt;echo &lt;/span&gt;0 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;/rps_flow_cnt"&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;0 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /proc/sys/net/core/rps_sock_flow_entries
&lt;span class="nb"&gt;echo &lt;/span&gt;0 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /proc/sys/net/core/flow_limit_cpu_bitmap

&lt;span class="c"&gt;# Clear XPS maps (driver may re-init defaults on rebind)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;q &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/net/&lt;span class="nv"&gt;$IFACE&lt;/span&gt;/queues/tx-&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;0 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;/xps_cpus"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; net-steering@eth0.service 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="c"&gt;# re-enable irqbalance if you stopped it&lt;/span&gt;
&lt;span class="c"&gt;# sudo systemctl enable --now irqbalance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;High-speed Linux networking is less about a single sysctl silver bullet and more about &lt;strong&gt;matching parallelism to hardware&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Let the NIC split flows (&lt;strong&gt;RSS&lt;/strong&gt;).
&lt;/li&gt;
&lt;li&gt;Let IRQs land on different cores.
&lt;/li&gt;
&lt;li&gt;If the NIC cannot split enough, let software finish the job (&lt;strong&gt;RPS&lt;/strong&gt;).
&lt;/li&gt;
&lt;li&gt;Prefer the app’s CPU when locality matters (&lt;strong&gt;RFS&lt;/strong&gt; / aRFS).
&lt;/li&gt;
&lt;li&gt;Keep transmit on matching queues (&lt;strong&gt;XPS&lt;/strong&gt;).
&lt;/li&gt;
&lt;li&gt;Prove it with &lt;code&gt;softnet_stat&lt;/code&gt;, &lt;code&gt;/proc/interrupts&lt;/code&gt;, and multi-flow load — not vibes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do that, and the “one core at 100% softirq” box often turns into a boring, evenly busy machine — which is exactly what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel documentation: &lt;a href="https://docs.kernel.org/networking/scaling.html" rel="noopener noreferrer"&gt;Scaling in the Linux Networking Stack&lt;/a&gt; (RSS, RPS, RFS, aRFS, XPS)
&lt;/li&gt;
&lt;li&gt;Linux kernel documentation: &lt;a href="https://docs.kernel.org/core-api/irq/irq-affinity.html" rel="noopener noreferrer"&gt;SMP IRQ affinity&lt;/a&gt; (&lt;code&gt;smp_affinity&lt;/code&gt; / &lt;code&gt;smp_affinity_list&lt;/code&gt;)
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ethtool(8)&lt;/code&gt; — channels (&lt;code&gt;-L&lt;/code&gt;), RXFH (&lt;code&gt;-x&lt;/code&gt;/&lt;code&gt;-X&lt;/code&gt;), ntuple (&lt;code&gt;-K ntuple&lt;/code&gt;, &lt;code&gt;-N&lt;/code&gt;), rings (&lt;code&gt;-g&lt;/code&gt;/&lt;code&gt;-G&lt;/code&gt;), stats (&lt;code&gt;-S&lt;/code&gt;)
&lt;/li&gt;
&lt;li&gt;Red Hat Enterprise Linux 9 docs: &lt;a href="https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/9/html/monitoring_and_managing_system_status_and_performance/tuning-the-network-performance_monitoring-and-managing-system-status-and-performance" rel="noopener noreferrer"&gt;Tuning the network performance&lt;/a&gt; (&lt;code&gt;softnet_stat&lt;/code&gt;, backlog, NAPI budget, irqbalance, rings)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>performance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Sending All Traffic to One Backend: Practical IPVS Load Balancing with keepalived on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Fri, 14 Aug 2026 05:02:58 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-sending-all-traffic-to-one-backend-practical-ipvs-load-balancing-with-keepalived-on-linux-5cdm</link>
      <guid>https://dev.to/lyraalishaikh/stop-sending-all-traffic-to-one-backend-practical-ipvs-load-balancing-with-keepalived-on-linux-5cdm</guid>
      <description>&lt;h1&gt;
  
  
  Stop Sending All Traffic to One Backend: Practical IPVS Load Balancing with keepalived on Linux
&lt;/h1&gt;

&lt;p&gt;A floating VIP keeps the &lt;em&gt;front door&lt;/em&gt; available. That is not the same as spreading work across backends.&lt;/p&gt;

&lt;p&gt;If one Linux box owns &lt;code&gt;203.0.113.10&lt;/code&gt; and every HTTP request still lands on a single app host, you have high availability of an IP — not load balancing of a service. IPVS (IP Virtual Server) is the kernel's Layer-4 load balancer. keepalived is the userspace control plane that programs IPVS, health-checks real servers, and can own the VIP with VRRP when you want director failover too.&lt;/p&gt;

&lt;p&gt;This post is the LVS/IPVS half of the stack: virtual services, NAT vs Direct Routing, health checks, ARP-safe real-server VIP binding, and verification with &lt;code&gt;ipvsadm&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are building
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; clients
    |
    v
 VIP :80  (director / LinuxDirector)
    |
    +-- RS1 10.0.0.11:80
    +-- RS2 10.0.0.12:80
    +-- RS3 10.0.0.13:80  (weight 0 / inhibited when unhealthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;IPVS schedules &lt;strong&gt;new&lt;/strong&gt; connections. Established flows stick to the real server chosen for that connection until they expire or the destination is removed. That is why health checks, weights, and optional connection sync matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install the tools
&lt;/h2&gt;

&lt;p&gt;Debian/Ubuntu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; keepalived ipvsadm iproute2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fedora/RHEL-family:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; keepalived ipvsadm iproute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load the common scheduler/forwarding modules once so the first service create does not surprise you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe ip_vs
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe ip_vs_rr
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe ip_vs_wrr
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe ip_vs_wlc
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe nf_conntrack
&lt;span class="c"&gt;# useful on NAT setups that also use stateful firewall rules:&lt;/span&gt;
&lt;span class="c"&gt;# echo 1 | sudo tee /proc/sys/net/ipv4/vs/conntrack&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the kernel side is present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsmod | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^ip_vs'&lt;/span&gt;
ipvsadm &lt;span class="nt"&gt;-Ln&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Choose a forwarding method first
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;keepalived / ipvsadm&lt;/th&gt;
&lt;th&gt;Request path&lt;/th&gt;
&lt;th&gt;Reply path&lt;/th&gt;
&lt;th&gt;Topology constraint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;NAT&lt;/strong&gt; (masquerading)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lvs_method NAT&lt;/code&gt; / &lt;code&gt;-m&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;client → director DNAT → RS&lt;/td&gt;
&lt;td&gt;RS → director SNAT → client&lt;/td&gt;
&lt;td&gt;RS default route usually via director; private RS nets OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;DR&lt;/strong&gt; (direct routing / gatewaying)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lvs_method DR&lt;/code&gt; / &lt;code&gt;-g&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;client → director rewrites L2 dest → RS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;RS → client directly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;director + RS on same L2 for the VIP path; VIP must exist on RS without answering ARP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TUN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lvs_method TUN&lt;/code&gt; / &lt;code&gt;-i&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;director encapsulates to RS&lt;/td&gt;
&lt;td&gt;RS → client directly&lt;/td&gt;
&lt;td&gt;RS must decap IPIP/GUE/GRE; more moving parts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start with &lt;strong&gt;NAT&lt;/strong&gt; if you want the fewest network surprises. Use &lt;strong&gt;DR&lt;/strong&gt; when return bandwidth should leave the director (common for high-throughput HTTP/TCP farms on one LAN).&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab addressing used below
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;VIP: &lt;code&gt;203.0.113.10&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Director real IP: &lt;code&gt;203.0.113.2&lt;/code&gt; (and optionally a second director &lt;code&gt;203.0.113.3&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Real servers: &lt;code&gt;10.0.0.11&lt;/code&gt;, &lt;code&gt;10.0.0.12&lt;/code&gt; (NAT example) or &lt;code&gt;203.0.113.11&lt;/code&gt;, &lt;code&gt;203.0.113.12&lt;/code&gt; (DR example)&lt;/li&gt;
&lt;li&gt;Service: TCP/80&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Replace with your ranges. Do not paste these public documentation addresses into production routing without thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path A — VS/NAT (simplest correct farm)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Director sysctl
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-ipvs-nat-director.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
net.ipv4.ip_forward = 1
# Only if nftables/iptables stateful rules must see IPVS flows:
# net.ipv4.vs.conntrack = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kernel docs: &lt;code&gt;conntrack&lt;/code&gt; under &lt;code&gt;/proc/sys/net/ipv4/vs/*&lt;/code&gt; is off by default for performance; enable it when IPVS-handled connections must also match conntrack-based firewall policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) keepalived virtual server (NAT)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/keepalived/keepalived.conf&lt;/code&gt; on the director:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;global_defs&lt;/span&gt; {
    &lt;span class="n"&gt;router_id&lt;/span&gt; &lt;span class="n"&gt;lvs&lt;/span&gt;-&lt;span class="n"&gt;nat&lt;/span&gt;-&lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="c"&gt;# Drop stale LVS objects left from earlier experiments
&lt;/span&gt;    &lt;span class="n"&gt;lvs_flush&lt;/span&gt;
    &lt;span class="n"&gt;lvs_flush_on_stop&lt;/span&gt;
}

&lt;span class="c"&gt;# Optional: own the VIP with VRRP (director HA).
# If you already publish 203.0.113.10 another way, omit this block
# and point virtual_server at that address.
&lt;/span&gt;&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_HTTP&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;
    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;authentication&lt;/span&gt; {
        &lt;span class="n"&gt;auth_type&lt;/span&gt; &lt;span class="n"&gt;PASS&lt;/span&gt;
        &lt;span class="n"&gt;auth_pass&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;-&lt;span class="n"&gt;me&lt;/span&gt;
    }
    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt; &lt;span class="n"&gt;dev&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    }
}

&lt;span class="n"&gt;virtual_server&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt; {
    &lt;span class="n"&gt;delay_loop&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;
    &lt;span class="n"&gt;lvs_sched&lt;/span&gt; &lt;span class="n"&gt;wlc&lt;/span&gt;
    &lt;span class="n"&gt;lvs_method&lt;/span&gt; &lt;span class="n"&gt;NAT&lt;/span&gt;
    &lt;span class="n"&gt;protocol&lt;/span&gt; &lt;span class="n"&gt;TCP&lt;/span&gt;
    &lt;span class="c"&gt;# persistence_timeout 300   # sticky client-&amp;gt;RS mapping when needed (TLS/session apps)
&lt;/span&gt;    &lt;span class="n"&gt;alpha&lt;/span&gt;                  &lt;span class="c"&gt;# assume RS down until checks pass (avoids false-up at boot)
&lt;/span&gt;    &lt;span class="n"&gt;omega&lt;/span&gt;
    &lt;span class="n"&gt;quorum&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="c"&gt;# sorry_server 10.0.0.99 80
&lt;/span&gt;    &lt;span class="n"&gt;inhibit_on_failure&lt;/span&gt;     &lt;span class="c"&gt;# weight 0 on failure instead of deleting the RS (drain-friendly)
&lt;/span&gt;
    &lt;span class="n"&gt;real_server&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;11&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt; {
        &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;TCP_CHECK&lt;/span&gt; {
            &lt;span class="n"&gt;connect_timeout&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
            &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
            &lt;span class="n"&gt;delay_before_retry&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
        }
        &lt;span class="c"&gt;# Or application-aware:
&lt;/span&gt;        &lt;span class="c"&gt;# HTTP_GET {
&lt;/span&gt;        &lt;span class="c"&gt;#     url {
&lt;/span&gt;        &lt;span class="c"&gt;#         path /healthz
&lt;/span&gt;        &lt;span class="c"&gt;#         status_code 200-299
&lt;/span&gt;        &lt;span class="c"&gt;#     }
&lt;/span&gt;        &lt;span class="c"&gt;#     connect_timeout 3
&lt;/span&gt;        &lt;span class="c"&gt;#     retry 2
&lt;/span&gt;        &lt;span class="c"&gt;# }
&lt;/span&gt;    }

    &lt;span class="n"&gt;real_server&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;12&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt; {
        &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;TCP_CHECK&lt;/span&gt; {
            &lt;span class="n"&gt;connect_timeout&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
            &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
            &lt;span class="n"&gt;delay_before_retry&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from &lt;code&gt;keepalived.conf(5)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scheduler and forwarding method are &lt;code&gt;lvs_sched&lt;/code&gt; and &lt;code&gt;lvs_method&lt;/code&gt; on current keepalived man pages (values such as &lt;code&gt;wlc&lt;/code&gt; and &lt;code&gt;NAT&lt;/code&gt;/&lt;code&gt;DR&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inhibit_on_failure&lt;/code&gt; sets weight to &lt;code&gt;0&lt;/code&gt; on failed checks. IPVS treats weight &lt;code&gt;0&lt;/code&gt; as &lt;strong&gt;quiescent&lt;/strong&gt;: no &lt;em&gt;new&lt;/em&gt; jobs, existing jobs can finish.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;alpha&lt;/code&gt; starts checkers pessimistic so a restart does not briefly advertise dead backends.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sorry_server&lt;/code&gt; is the overflow/fallback RS when quorum is not met.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Enable and start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;keepalived &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;    &lt;span class="c"&gt;# config test where supported&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; keepalived
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status keepalived &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3) Real-server side for NAT
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;App listens on &lt;code&gt;0.0.0.0:80&lt;/code&gt; or the RS IP.&lt;/li&gt;
&lt;li&gt;Default route points at the director (or at a gateway that returns through the director) so replies are SNATed correctly.&lt;/li&gt;
&lt;li&gt;No VIP on the real servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4) Manual IPVS equivalent (debug only)
&lt;/h3&gt;

&lt;p&gt;keepalived should own the table. For learning, the same NAT service looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 203.0.113.10:80 &lt;span class="nt"&gt;-s&lt;/span&gt; wlc
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 203.0.113.10:80 &lt;span class="nt"&gt;-r&lt;/span&gt; 10.0.0.11:80 &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; 1
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 203.0.113.10:80 &lt;span class="nt"&gt;-r&lt;/span&gt; 10.0.0.12:80 &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; 1
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-Ln&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-m&lt;/code&gt; is masquerading/NAT. Default scheduler without &lt;code&gt;-s&lt;/code&gt; is &lt;code&gt;wlc&lt;/code&gt; (weighted least-connection).&lt;/p&gt;

&lt;h2&gt;
  
  
  Path B — VS/DR (director stays thin on the return path)
&lt;/h2&gt;

&lt;p&gt;Direct Routing rewrites the destination MAC toward the chosen real server and leaves the IP VIP untouched. The real server must accept packets for the VIP &lt;strong&gt;locally&lt;/strong&gt; and must &lt;strong&gt;not&lt;/strong&gt; win ARP for that VIP on the LAN.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Real-server VIP on &lt;code&gt;lo&lt;/code&gt; + modern ARP policy
&lt;/h3&gt;

&lt;p&gt;On each real server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-ipvs-dr-realserver.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# Only answer ARP for addresses configured on the incoming interface
net.ipv4.conf.all.arp_ignore = 1
net.ipv4.conf.lo.arp_ignore = 1
# Prefer announcing source IPs that belong on the egress interface
net.ipv4.conf.all.arp_announce = 2
net.ipv4.conf.lo.arp_announce = 2
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;

&lt;span class="c"&gt;# Host route / local VIP on loopback (do not put VIP on eth0)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 203.0.113.10/32 dev lo
&lt;span class="c"&gt;# persist with your network manager; example systemd-networkd snippet:&lt;/span&gt;
&lt;span class="c"&gt;# /etc/systemd/network/lo.network.d/vip.conf&lt;/span&gt;
&lt;span class="c"&gt;# [Address]&lt;/span&gt;
&lt;span class="c"&gt;# Address=203.0.113.10/32&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this works (kernel &lt;code&gt;ip-sysctl&lt;/code&gt; semantics):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;arp_ignore = 1&lt;/code&gt; — reply only if the target IP is configured on the &lt;em&gt;incoming&lt;/em&gt; interface. VIP on &lt;code&gt;lo&lt;/code&gt; therefore does not answer ARP arriving on &lt;code&gt;eth0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;arp_announce = 2&lt;/code&gt; — always use the best local address for the target when forming ARP requests, reducing “VIP as ARP source on eth0” surprises.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the modern replacement for the old &lt;code&gt;hidden&lt;/code&gt; sysctl / patches discussed in classic LVS ARP docs.&lt;/p&gt;

&lt;p&gt;App requirement for DR: the daemon must accept connections destined to the VIP (listen on &lt;code&gt;0.0.0.0&lt;/code&gt; or explicitly on the VIP).&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Director keepalived for DR
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;global_defs&lt;/span&gt; {
    &lt;span class="n"&gt;router_id&lt;/span&gt; &lt;span class="n"&gt;lvs&lt;/span&gt;-&lt;span class="n"&gt;dr&lt;/span&gt;-&lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;lvs_flush&lt;/span&gt;
    &lt;span class="n"&gt;lvs_flush_on_stop&lt;/span&gt;
}

&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_HTTP&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;
    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;32&lt;/span&gt; &lt;span class="n"&gt;dev&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    }
}

&lt;span class="n"&gt;virtual_server&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt; {
    &lt;span class="n"&gt;delay_loop&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="n"&gt;lvs_sched&lt;/span&gt; &lt;span class="n"&gt;wrr&lt;/span&gt;
    &lt;span class="n"&gt;lvs_method&lt;/span&gt; &lt;span class="n"&gt;DR&lt;/span&gt;
    &lt;span class="n"&gt;protocol&lt;/span&gt; &lt;span class="n"&gt;TCP&lt;/span&gt;
    &lt;span class="n"&gt;alpha&lt;/span&gt;
    &lt;span class="n"&gt;inhibit_on_failure&lt;/span&gt;

    &lt;span class="n"&gt;real_server&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;11&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt; {
        &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
        &lt;span class="n"&gt;HTTP_GET&lt;/span&gt; {
            &lt;span class="n"&gt;url&lt;/span&gt; {
                &lt;span class="n"&gt;path&lt;/span&gt; /&lt;span class="n"&gt;healthz&lt;/span&gt;
                &lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;-&lt;span class="m"&gt;299&lt;/span&gt;
            }
            &lt;span class="n"&gt;connect_ip&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;11&lt;/span&gt;
            &lt;span class="n"&gt;connect_port&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
            &lt;span class="n"&gt;connect_timeout&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
            &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
            &lt;span class="n"&gt;delay_before_retry&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
        }
    }

    &lt;span class="n"&gt;real_server&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;12&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt; {
        &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;HTTP_GET&lt;/span&gt; {
            &lt;span class="n"&gt;url&lt;/span&gt; {
                &lt;span class="n"&gt;path&lt;/span&gt; /&lt;span class="n"&gt;healthz&lt;/span&gt;
                &lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;-&lt;span class="m"&gt;299&lt;/span&gt;
            }
            &lt;span class="n"&gt;connect_timeout&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
            &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DR constraints that bite people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Director and real servers need a shared L2 path for the VIP delivery method described by LVS DR (director rewrites L2 destination).&lt;/li&gt;
&lt;li&gt;Port on the real server equals the virtual service port for DR/TUN.&lt;/li&gt;
&lt;li&gt;Clients on the same LAN as the VIP can behave oddly if ARP policy is incomplete — verify with &lt;code&gt;ip neigh&lt;/code&gt; / packet captures before blaming the scheduler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Manual DR add for debugging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 203.0.113.10:80 &lt;span class="nt"&gt;-s&lt;/span&gt; wrr
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 203.0.113.10:80 &lt;span class="nt"&gt;-r&lt;/span&gt; 203.0.113.11:80 &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; 2
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 203.0.113.10:80 &lt;span class="nt"&gt;-r&lt;/span&gt; 203.0.113.12:80 &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-g&lt;/code&gt; is gatewaying/DR (default if you omit the method flag).&lt;/p&gt;

&lt;h2&gt;
  
  
  Scheduler cheat sheet (pick deliberately)
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;ipvsadm(8)&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scheduler&lt;/th&gt;
&lt;th&gt;Good default when…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wlc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mixed connection lengths; default IPVS choice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wrr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Roughly equal request cost; simple capacity weights&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rr&lt;/code&gt; / &lt;code&gt;lc&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Unweighted variants of the above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Source-hash stickiness without full persistence templates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maglev-style consistent hashing; minimal disruption when RS set changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Active/standby by weight, not spreading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ovf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fill highest weight first, then overflow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Persistence (&lt;code&gt;persistence_timeout&lt;/code&gt; / &lt;code&gt;ipvsadm -p&lt;/code&gt;) pins a client (or masked client net) to one RS for SSL/sessionful apps. Prefer app-level shared session stores when you can; use IPVS persistence when you cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify like an operator
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Table keepalived programmed&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-Ln&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-Ln&lt;/span&gt; &lt;span class="nt"&gt;--stats&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-Ln&lt;/span&gt; &lt;span class="nt"&gt;--rate&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-Lnc&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# Timeouts&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-Ln&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt;

&lt;span class="c"&gt;# Kernel counters / presence&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/net/ip_vs
&lt;span class="nb"&gt;ls&lt;/span&gt; /proc/sys/net/ipv4/vs/

&lt;span class="c"&gt;# keepalived health / VRRP&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; keepalived &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 80
ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Healthy NAT/DR service looks roughly like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TCP  203.0.113.10:80 wlc
  -&amp;gt; 10.0.0.11:80             Masq   1      0          0
  -&amp;gt; 10.0.0.12:80             Masq   1      0          0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or &lt;code&gt;Route&lt;/code&gt; instead of &lt;code&gt;Masq&lt;/code&gt; for DR.&lt;/p&gt;

&lt;p&gt;Controlled failure test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;curl -sS -o /dev/null -w '%{http_code}\n' http://203.0.113.10/healthz&lt;/code&gt; in a loop.&lt;/li&gt;
&lt;li&gt;Stop the app or firewall the checker path on RS1.&lt;/li&gt;
&lt;li&gt;Watch keepalived log the down transition and &lt;code&gt;ipvsadm -Ln&lt;/code&gt; show weight &lt;code&gt;0&lt;/code&gt; (inhibit) or RS removal.&lt;/li&gt;
&lt;li&gt;Confirm new curls still succeed via RS2.&lt;/li&gt;
&lt;li&gt;Restore RS1; weight returns after successful checks.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Optional: two directors without dropping L4 state
&lt;/h2&gt;

&lt;p&gt;VRRP moves the VIP. IPVS connection entries are separate. For director pairs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;global_defs&lt;/span&gt; {
    &lt;span class="n"&gt;router_id&lt;/span&gt; &lt;span class="n"&gt;lvs&lt;/span&gt;-&lt;span class="n"&gt;a&lt;/span&gt;
    &lt;span class="c"&gt;# Bind IPVS sync to VRRP state on the dedicated sync NIC/path
&lt;/span&gt;    &lt;span class="n"&gt;lvs_sync_daemon&lt;/span&gt; &lt;span class="n"&gt;eth1&lt;/span&gt; &lt;span class="n"&gt;inst&lt;/span&gt; &lt;span class="n"&gt;VI_HTTP&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;
    &lt;span class="n"&gt;lvs_timeouts&lt;/span&gt; &lt;span class="n"&gt;tcp&lt;/span&gt; &lt;span class="m"&gt;900&lt;/span&gt; &lt;span class="n"&gt;tcpfin&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt; &lt;span class="n"&gt;udp&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lvs_sync_daemon&lt;/code&gt; starts the kernel IPVS sync daemons and can track a VRRP instance so only the master sends and the backup receives. Details and socket options are in &lt;code&gt;keepalived.conf(5)&lt;/code&gt; and &lt;code&gt;ipvsadm --start-daemon&lt;/code&gt;. Related knobs live under &lt;code&gt;/proc/sys/net/ipv4/vs/sync_*&lt;/code&gt; (see kernel &lt;code&gt;ipvs-sysctl&lt;/code&gt; docs).&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;IPVS connection sync&lt;/strong&gt;, not Netfilter conntrack sync. If your firewall policy depends on conntrack for the same flows, that is a different mechanism.&lt;/p&gt;

&lt;p&gt;Also consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On backup-only nodes that should never forward as director while backup (DR/TUN loop guard)&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;1 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /proc/sys/net/ipv4/vs/backup_only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Firewall notes (short, practical)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Allow client → VIP service ports on the director.&lt;/li&gt;
&lt;li&gt;Allow director health checks → RS IPs/ports.&lt;/li&gt;
&lt;li&gt;For NAT, allow forwarded traffic director ↔ RS and enable the MASQUERADE/SNAT path your design needs.&lt;/li&gt;
&lt;li&gt;If you filter with conntrack matches against IPVS traffic, set &lt;code&gt;net.ipv4.vs.conntrack=1&lt;/code&gt; (requires &lt;code&gt;CONFIG_IP_VS_NFCT&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;VRRP (if used) is IP protocol 112 / multicast &lt;code&gt;224.0.0.18&lt;/code&gt; unless you run unicast peers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not L7 reverse proxying.&lt;/strong&gt; IPVS does not terminate TLS or route on Host headers. Put HAProxy/nginx/Caddy &lt;em&gt;behind&lt;/em&gt; or &lt;em&gt;in front&lt;/em&gt; when you need application logic; use IPVS when you want kernel L4 fan-out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not only VRRP.&lt;/strong&gt; Floating VIP ownership without &lt;code&gt;virtual_server&lt;/code&gt; blocks is gateway HA, not a server farm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not conntrackd.&lt;/strong&gt; Session pickup for stateful firewall pairs is a different problem from IPVS sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not kube-proxy replacement guidance.&lt;/strong&gt; Kubernetes may use IPVS mode internally; this article is host/LVS operations with keepalived.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Minimal rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop keepalived
&lt;span class="nb"&gt;sudo &lt;/span&gt;ipvsadm &lt;span class="nt"&gt;-C&lt;/span&gt;
&lt;span class="c"&gt;# DR real servers:&lt;/span&gt;
&lt;span class="c"&gt;# sudo ip addr del 203.0.113.10/32 dev lo&lt;/span&gt;
&lt;span class="c"&gt;# sudo rm /etc/sysctl.d/99-ipvs-dr-realserver.conf &amp;amp;&amp;amp; sudo sysctl --system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pick &lt;strong&gt;NAT&lt;/strong&gt; or &lt;strong&gt;DR&lt;/strong&gt; from topology, not habit.&lt;/li&gt;
&lt;li&gt;Put health checks on something the &lt;em&gt;user&lt;/em&gt; needs (&lt;code&gt;/healthz&lt;/code&gt;), not only &lt;code&gt;TCP_CHECK&lt;/code&gt; to a port that accepts SYNs while the app is wedged.&lt;/li&gt;
&lt;li&gt;Prefer &lt;code&gt;inhibit_on_failure&lt;/code&gt; + nonzero &lt;code&gt;quorum&lt;/code&gt; over silent empty farms.&lt;/li&gt;
&lt;li&gt;On DR real servers, VIP on &lt;code&gt;lo&lt;/code&gt; + &lt;code&gt;arp_ignore&lt;/code&gt;/&lt;code&gt;arp_announce&lt;/code&gt; before you open the VIP to clients.&lt;/li&gt;
&lt;li&gt;Verify with &lt;code&gt;ipvsadm -Ln&lt;/code&gt;, &lt;code&gt;--stats&lt;/code&gt;, and a deliberate RS failure — not only a green &lt;code&gt;systemctl status&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you add a second director, plan VIP failover &lt;strong&gt;and&lt;/strong&gt; IPVS sync (and firewall implications) explicitly.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/bookworm/ipvsadm/ipvsadm.8.en.html" rel="noopener noreferrer"&gt;ipvsadm(8) — Debian man page&lt;/a&gt; — virtual services, schedulers, NAT/DR/TUN flags, sync daemon&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/bookworm/keepalived/keepalived.conf.5.en.html" rel="noopener noreferrer"&gt;keepalived.conf(5) — Debian man page&lt;/a&gt; — &lt;code&gt;virtual_server&lt;/code&gt;, checkers, &lt;code&gt;lvs_sync_daemon&lt;/code&gt;, quorum/sorry server&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.kernel.org/networking/ipvs-sysctl.html" rel="noopener noreferrer"&gt;Kernel IPVS sysctl docs&lt;/a&gt; — &lt;code&gt;conntrack&lt;/code&gt;, &lt;code&gt;backup_only&lt;/code&gt;, &lt;code&gt;expire_nodest_conn&lt;/code&gt;, sync tunables&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt" rel="noopener noreferrer"&gt;Kernel ip-sysctl ARP knobs&lt;/a&gt; — &lt;code&gt;arp_ignore&lt;/code&gt;, &lt;code&gt;arp_announce&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.linuxvirtualserver.org/VS-DRouting.html" rel="noopener noreferrer"&gt;LVS Direct Routing overview&lt;/a&gt; — DR packet path and same-LAN assumptions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.linuxvirtualserver.org/docs/arp.html" rel="noopener noreferrer"&gt;LVS ARP problem notes&lt;/a&gt; — historical context; prefer modern &lt;code&gt;arp_ignore&lt;/code&gt;/&lt;code&gt;arp_announce&lt;/code&gt; on current kernels&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.linuxvirtualserver.org/software/ipvs.html" rel="noopener noreferrer"&gt;IPVS project overview&lt;/a&gt; — L4 switching model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kernel-space L4 balancing is boring in the best way: small config surface, predictable failure modes, and tools (&lt;code&gt;ipvsadm&lt;/code&gt;, keepalived checkers) that show you exactly which backend should get the next SYN. Wire the health checks to reality, keep ARP policy honest on DR, and the farm stops being a single point of overload disguised as a VIP.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Burning CPU on Established Flows: Practical nftables Flowtables on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Thu, 13 Aug 2026 05:02:21 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-burning-cpu-on-established-flows-practical-nftables-flowtables-on-linux-5cpn</link>
      <guid>https://dev.to/lyraalishaikh/stop-burning-cpu-on-established-flows-practical-nftables-flowtables-on-linux-5cpn</guid>
      <description>&lt;h1&gt;
  
  
  Stop Burning CPU on Established Flows: Practical nftables Flowtables on Linux
&lt;/h1&gt;

&lt;p&gt;If your Linux box is a router, gateway, or homelab edge, most packets are not “new.” They are established TCP and UDP flows that already survived conntrack, NAT, and your forward policy.&lt;/p&gt;

&lt;p&gt;Sending every one of those packets back through prerouting → routing decision → forward → postrouting is correct — and expensive once traffic climbs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nftables flowtables&lt;/strong&gt; give you a deliberate fastpath: after the first packets create state the normal way, later packets can bypass the classic forwarding path from ingress straight toward transmission. NAT is still applied from the cached conntrack entry. TTL/hop limit is still decremented. You keep policy control over &lt;em&gt;which&lt;/em&gt; flows are eligible.&lt;/p&gt;

&lt;p&gt;This post is a practical setup for a two-interface Linux router. No magic “turn on turbo mode” toggle — just a flowtable, a selective &lt;code&gt;flow add&lt;/code&gt; rule, and verification you can trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a flowtable actually does
&lt;/h2&gt;

&lt;p&gt;From the kernel’s Netfilter flowtable docs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The first packet(s) of a connection still walk the classic IP forwarding path and create conntrack state.&lt;/li&gt;
&lt;li&gt;A rule in the &lt;strong&gt;forward&lt;/strong&gt; chain can run &lt;strong&gt;&lt;code&gt;flow add @table&lt;/code&gt;&lt;/strong&gt; (also documented historically as flow offload) to insert that flow into a flowtable.&lt;/li&gt;
&lt;li&gt;Later packets that &lt;strong&gt;hit&lt;/strong&gt; the flowtable at the &lt;strong&gt;ingress&lt;/strong&gt; hook skip the rest of the classic path and go out via &lt;code&gt;neigh_xmit()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Misses still take the normal path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lookup key is roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;L2 encapsulation where relevant (VLAN / PPPoE since kernel 5.13)&lt;/li&gt;
&lt;li&gt;L3 source/destination&lt;/li&gt;
&lt;li&gt;L4 ports&lt;/li&gt;
&lt;li&gt;input interface&lt;/li&gt;
&lt;li&gt;L3/L4 protocol (IPv4/IPv6 + TCP/UDP)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cached entry also stores the egress device, gateway/neigh info, and NAT mangling so the fastpath stays consistent with the slow path that created it.&lt;/p&gt;

&lt;p&gt;Important edge cases the kernel documents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fragments&lt;/strong&gt; cannot be looked up in the flowtable (transport header missing) → classic path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP FIN/RST&lt;/strong&gt; go classic so the flow can tear down cleanly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-MTU&lt;/strong&gt; packets go classic so ICMP too-big can be generated&lt;/li&gt;
&lt;li&gt;Flowtable entries can go &lt;strong&gt;stale&lt;/strong&gt; if the egress device or destination MAC changes out from under them (bridge + IP forwarding mixes and HW offload need extra care)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux with nftables and flowtable support (widely available on modern Debian/Ubuntu/Fedora kernels; VLAN/PPPoE/bridge discovery needs &lt;strong&gt;5.13+&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;Host acting as a &lt;strong&gt;router&lt;/strong&gt; (forwarding enabled)&lt;/li&gt;
&lt;li&gt;Packages:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nftables conntrack

&lt;span class="c"&gt;# Fedora&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nftables conntrack-tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;\nEnable IPv4 forwarding persistently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'net.ipv4.ip_forward = 1'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-forward.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace interface names below with yours. Example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;eth0&lt;/code&gt; — LAN / private&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eth1&lt;/code&gt; — WAN / upstream&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Minimal working ruleset
&lt;/h2&gt;

&lt;p&gt;Save as &lt;code&gt;/etc/nftables.d/flowtable-router.nft&lt;/code&gt; (or fold into your main &lt;code&gt;/etc/nftables.conf&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/usr/sbin/nft -f
flush ruleset

define DEV_LAN = eth0
define DEV_WAN = eth1
define NET_LAN = 192.168.10.0/24

table inet filter {
        # Fastpath table: hooks ingress on BOTH directions' devices.
        # devices must cover the interfaces traffic enters on for offloaded flows.
        flowtable ft_fast {
                hook ingress priority 0
                devices = { $DEV_LAN, $DEV_WAN }
                counter   # sync bytes/packets back into conntrack (kernel 5.7+)
                # flags offload;  # uncomment ONLY if NICs support HW flow offload
        }

        chain input {
                type filter hook input priority filter; policy drop;

                ct state vmap { established : accept, related : accept, invalid : drop }
                iifname lo accept
                iifname $DEV_LAN accept
                # WAN: allow SSH only from a management prefix if needed
                # iifname $DEV_WAN tcp dport 22 ip saddr 203.0.113.0/24 accept
        }

        chain forward {
                type filter hook forward priority filter; policy drop;

                # Offload established TCP (and optionally UDP) once state exists.
                # Prefer matching return traffic / established flows; first packets
                # still need a normal accept path below.
                ct state established,related \
                        meta l4proto { tcp, udp } \
                        flow add @ft_fast \
                        counter

                ct state vmap { established : accept, related : accept, invalid : drop }

                # New connections from LAN toward WAN (and hairpin LAN if desired)
                iifname $DEV_LAN oifname $DEV_WAN accept
                # replies / hairpin already covered by established above
        }

        chain postrouting {
                type nat hook postrouting priority srcnat; policy accept;
                ip saddr $NET_LAN oifname $DEV_WAN masquerade
        }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Load and persist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/flowtable-router.nft   &lt;span class="c"&gt;# syntax check&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.d/flowtable-router.nft

&lt;span class="c"&gt;# Debian/Ubuntu packaged service&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nftables

&lt;span class="c"&gt;# If your distro expects a single file, include the snippet from /etc/nftables.conf:&lt;/span&gt;
&lt;span class="c"&gt;# include "/etc/nftables.d/*.nft"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why the devices list matters
&lt;/h3&gt;

&lt;p&gt;The flowtable’s &lt;code&gt;devices = { ... }&lt;/code&gt; is not cosmetic. It registers the ingress fastpath on those interfaces. For a router you almost always need &lt;strong&gt;both&lt;/strong&gt; LAN and WAN (and any other interfaces that carry offloaded flows in either direction).&lt;/p&gt;

&lt;p&gt;The nftables wiki is explicit: devices are required for both traffic directions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;flow add&lt;/code&gt; lives in forward
&lt;/h3&gt;

&lt;p&gt;Offload is decided after the connection has been seen on the classic path. The forward chain is the natural place: you already know this packet is being routed, and you can constrain offload to protocols/ports you care about.&lt;/p&gt;

&lt;p&gt;Example: only offload bulk HTTP(S), keep everything else on the slow path for deeper inspection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tcp dport { 80, 443 } ct state established flow add @ft_fast counter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the reverse — offload everything established except SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ct state established,related meta l4proto { tcp, udp } \
        tcp dport != 22 \
        flow add @ft_fast counter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Adjust to your policy; the point is selectivity.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware offload vs software offload
&lt;/h2&gt;

&lt;p&gt;Software mode is the default and works without special NIC features. Flows show as &lt;strong&gt;&lt;code&gt;[OFFLOAD]&lt;/code&gt;&lt;/strong&gt; in conntrack.&lt;/p&gt;

&lt;p&gt;If your NICs and driver support Netfilter flowtable hardware offload, enable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowtable ft_fast {
        hook ingress priority 0
        devices = { eth0, eth1 }
        flags offload;
        counter
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hardware-offloaded flows are tagged &lt;strong&gt;&lt;code&gt;[HW_OFFLOAD]&lt;/code&gt;&lt;/strong&gt;. A few packets may still traverse the software path until a workqueue pushes the flow to the device.&lt;/p&gt;

&lt;p&gt;Do not enable &lt;code&gt;flags offload&lt;/code&gt; “just because.” If the driver cannot offload, you want a clean failure or plain software mode — not a mystery performance regression. Confirm with vendor/driver docs and by watching for &lt;code&gt;HW_OFFLOAD&lt;/code&gt; tags under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bridge, VLAN, and PPPoE notes (kernel 5.13+)
&lt;/h2&gt;

&lt;p&gt;Useful topology facts from the kernel docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flowtables can discover the real device behind &lt;strong&gt;VLAN&lt;/strong&gt; and &lt;strong&gt;PPPoE&lt;/strong&gt;. You generally add the &lt;strong&gt;underlying&lt;/strong&gt; device to &lt;code&gt;devices&lt;/code&gt;, not every stacked virtual interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bridge ports&lt;/strong&gt; can be added so the fastpath spans bridge-port ↔ gateway-NIC topologies, including bridge VLAN filtering (PVID/untagged).&lt;/li&gt;
&lt;li&gt;If you combine bridge forwarding and IP forwarding aggressively, treat stale MAC/egress caching as a first-class operational risk — the flowtable is a cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already run a VLAN-aware bridge on the LAN side, add the real member ports you care about to the flowtable devices list and test carefully before calling it production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification checklist
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Confirm the flowtable is present
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list flowtables
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list ruleset | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'/flowtable/,/^$/p'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your &lt;code&gt;flowtable&lt;/code&gt; block, devices, and the &lt;code&gt;flow add @...&lt;/code&gt; rule in forward.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Generate forwarded traffic
&lt;/h3&gt;

&lt;p&gt;From a LAN client, open a long-lived bulk transfer through the router (iperf3, a large HTTPS download, etc.). Local-process traffic on the router itself is &lt;strong&gt;not&lt;/strong&gt; the forward path — test with real forwarded flows.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Look for OFFLOAD tags
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# IPv4&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; extended | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'OFFLOAD|HW_OFFLOAD'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# Filter established TCP if the table is large&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--state&lt;/span&gt; ESTABLISHED &lt;span class="nt"&gt;-o&lt;/span&gt; extended | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kernel docs show software offload like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tcp 6 src=10.141.10.2 dst=192.168.10.2 sport=52728 dport=5201 \
    src=192.168.10.2 dst=192.168.10.1 sport=5201 dport=52728 [OFFLOAD] mark=0 use=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also filter by status bit where supported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; OFFLOAD &lt;span class="nt"&gt;-o&lt;/span&gt; extended | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Prove the forward chain counter stalls for offloaded packets
&lt;/h3&gt;

&lt;p&gt;List the forward rule counters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft list chain inet filter forward
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a fully offloaded elephant flow, the &lt;strong&gt;&lt;code&gt;flow add&lt;/code&gt; / forward counters should stop climbing quickly&lt;/strong&gt;, while the transfer continues. That is the visible effect of the ingress bypass: those packets never revisit your forward chain.&lt;/p&gt;

&lt;p&gt;If counters keep racing for the whole transfer, offload is not sticking — check devices list, that traffic is actually forwarded (not input), protocol match, and conntrack state.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Optional: CPU sanity check under load
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# before / after enabling flow add&lt;/span&gt;
mpstat &lt;span class="nt"&gt;-P&lt;/span&gt; ALL 1 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are looking for lower softirq/&lt;code&gt;si&lt;/code&gt; time on the forwarding cores at the same bulk throughput — not a microbenchmark religion. Directionally, established-flow offload should move work off the full Netfilter forward path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safe roll-in pattern
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Deploy the ruleset &lt;strong&gt;without&lt;/strong&gt; &lt;code&gt;flow add&lt;/code&gt; and confirm connectivity + NAT.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;flow add&lt;/code&gt; for a narrow port set (e.g. 443 only).&lt;/li&gt;
&lt;li&gt;Verify &lt;code&gt;[OFFLOAD]&lt;/code&gt; appears and forward counters behave.&lt;/li&gt;
&lt;li&gt;Widen to &lt;code&gt;tcp, udp&lt;/code&gt; established if results look good.&lt;/li&gt;
&lt;li&gt;Only then consider &lt;code&gt;flags offload&lt;/code&gt; on known-good hardware.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rollback is immediate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# remove offload rule only (example handle — use nft -a list to find yours)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-a&lt;/span&gt; list chain inet filter forward
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft delete rule inet filter forward handle &amp;lt;N&amp;gt;

&lt;span class="c"&gt;# or flush back to a known-good file&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.conf.good
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common failure modes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Never see &lt;code&gt;[OFFLOAD]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;flow add&lt;/code&gt; match; traffic is local input not forward; devices list wrong; connection never becomes established&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offload appears then dies&lt;/td&gt;
&lt;td&gt;Egress/MAC change; bridge topology churn; route change stale cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HW flag set, only &lt;code&gt;[OFFLOAD]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Hardware offload not actually active/supported on that path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weird IPv6-only behavior&lt;/td&gt;
&lt;td&gt;Missing inet/ip6 coverage or asymmetric policy routing on reply path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy routing surprises&lt;/td&gt;
&lt;td&gt;Reply path must resolve consistently; wiki notes special &lt;code&gt;ip rule&lt;/code&gt; setups need to match the packet that creates the flow entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fragments “ignore” fastpath&lt;/td&gt;
&lt;td&gt;Expected — fragments take classic path&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Not a replacement for &lt;strong&gt;CAKE/fq_codel&lt;/strong&gt; queue discipline (latency under working-buffer bloat)&lt;/li&gt;
&lt;li&gt;Not &lt;strong&gt;conntrackd&lt;/strong&gt; state sync for HA failover&lt;/li&gt;
&lt;li&gt;Not a substitute for correct &lt;strong&gt;forward/NAT policy&lt;/strong&gt; — offload amplifies whatever policy created the flow&lt;/li&gt;
&lt;li&gt;Not XDP/eBPF programming — this stays inside nftables + conntrack&lt;/li&gt;
&lt;li&gt;Not a cure for single-core NIC driver bottlenecks or missing checksum/TSO offloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use flowtables when the box is a legitimate forwarder and established traffic dominates CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Complete compact lab example
&lt;/h2&gt;

&lt;p&gt;Two namespaces + veth pairs are enough to rehearse without touching production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns add lan
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns add wan
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add v-lan &lt;span class="nb"&gt;type &lt;/span&gt;veth peer name v-lan-r
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add v-wan &lt;span class="nb"&gt;type &lt;/span&gt;veth peer name v-wan-r
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;v-lan netns lan
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;v-wan netns wan
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.168.10.1/24 dev v-lan-r
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 203.0.113.1/24 dev v-wan-r
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;v-lan-r up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;v-wan-r up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;lan ip addr add 192.168.10.10/24 dev v-lan
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;lan ip &lt;span class="nb"&gt;link set &lt;/span&gt;v-lan up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;lan ip route add default via 192.168.10.1
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;wan ip addr add 203.0.113.10/24 dev v-wan
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;wan ip &lt;span class="nb"&gt;link set &lt;/span&gt;v-wan up

&lt;span class="c"&gt;# Point the nft devices at v-lan-r / v-wan-r, enable forwarding, load ruleset,&lt;/span&gt;
&lt;span class="c"&gt;# then run iperf3 server in wan ns and client in lan ns.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;[OFFLOAD]&lt;/code&gt; shows up on the router namespace’s conntrack during iperf, the mechanism is working.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel docs — &lt;em&gt;Netfilter’s flowtable infrastructure&lt;/em&gt;: &lt;a href="https://www.kernel.org/doc/html/latest/networking/nf_flowtable.html" rel="noopener noreferrer"&gt;https://www.kernel.org/doc/html/latest/networking/nf_flowtable.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — &lt;em&gt;Flowtables&lt;/em&gt;: &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Flowtables" rel="noopener noreferrer"&gt;https://wiki.nftables.org/wiki-nftables/index.php/Flowtables&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — &lt;em&gt;Netfilter hooks&lt;/em&gt;: &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks" rel="noopener noreferrer"&gt;https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nftables wiki — &lt;em&gt;Simple ruleset for a home router&lt;/em&gt; (forwarding + masquerade baseline): &lt;a href="https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_home_router" rel="noopener noreferrer"&gt;https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_home_router&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Debian man pages — &lt;code&gt;nft(8)&lt;/code&gt;, &lt;code&gt;conntrack(8)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Flowtables do not invent a new firewall. They let established, policy-approved TCP/UDP flows stop paying the full Netfilter tax on every packet. Start narrow, verify with &lt;code&gt;conntrack&lt;/code&gt; &lt;code&gt;[OFFLOAD]&lt;/code&gt; tags and stalled forward counters, then widen. That is the difference between “we enabled offload” and “we can prove the fastpath is doing work.”&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Leaking Between VLANs: Practical VLAN-Aware Bridges with systemd-networkd</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Wed, 12 Aug 2026 05:02:50 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-leaking-between-vlans-practical-vlan-aware-bridges-with-systemd-networkd-33nb</link>
      <guid>https://dev.to/lyraalishaikh/stop-leaking-between-vlans-practical-vlan-aware-bridges-with-systemd-networkd-33nb</guid>
      <description>&lt;h1&gt;
  
  
  Stop Leaking Between VLANs: Practical VLAN-Aware Bridges with systemd-networkd
&lt;/h1&gt;

&lt;p&gt;A plain Linux bridge is a software switch that forwards frames. That is useful for VMs and containers — and dangerous if you expected VLANs to stay isolated.&lt;/p&gt;

&lt;p&gt;By default, the kernel bridge does &lt;strong&gt;not&lt;/strong&gt; filter on VLAN tags. Untagged frames and tagged frames can share the same L2 domain more freely than most people assume. If you trunk two VLANs into &lt;code&gt;br0&lt;/code&gt; and also plug an access port into that same bridge without VLAN filtering, you have not built a switch. You have built a mixer.&lt;/p&gt;

&lt;p&gt;Linux already has the fix: &lt;strong&gt;VLAN filtering&lt;/strong&gt; on the bridge (&lt;code&gt;vlan_filtering=1&lt;/code&gt;), plus per-port VLAN membership. With &lt;strong&gt;systemd-networkd&lt;/strong&gt; you can declare the whole thing: bridge netdev, trunk port, access ports, and optional SVIs for the host itself.&lt;/p&gt;

&lt;p&gt;This post is a practical L2 setup. It is not bonding, not VRF, not stacked &lt;code&gt;eth0.100&lt;/code&gt;-only config, and not a full Open vSwitch guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get (and what you do not)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In scope&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a bridge with &lt;code&gt;VLANFiltering=yes&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Configure trunk and access ports with &lt;code&gt;[BridgeVLAN]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Understand &lt;code&gt;VLAN=&lt;/code&gt;, &lt;code&gt;EgressUntagged=&lt;/code&gt;, and &lt;code&gt;PVID=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Optionally attach host SVIs (&lt;code&gt;Kind=vlan&lt;/code&gt; on top of the bridge)&lt;/li&gt;
&lt;li&gt;Verify with &lt;code&gt;bridge vlan&lt;/code&gt;, &lt;code&gt;bridge link&lt;/code&gt;, and a simple isolation check&lt;/li&gt;
&lt;li&gt;Roll back cleanly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Out of scope&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NIC failover (bonding/LACP)&lt;/li&gt;
&lt;li&gt;L3 tenant isolation (VRF / separate FIBs)&lt;/li&gt;
&lt;li&gt;Floating VIP HA (keepalived/VRRP)&lt;/li&gt;
&lt;li&gt;Hardware switchdev offload details&lt;/li&gt;
&lt;li&gt;VXLAN/Geneve overlays&lt;/li&gt;
&lt;li&gt;Docker/Podman’s default bridges (different lifecycle; same kernel ideas apply if you own the bridge)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Mental model
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 trunk (tagged 10,20)
 upstream NIC  -----------------------&amp;gt;  br0 (vlan_filtering=1)
   eth0                                       |
                    +-------------------------+-------------------------+
                    |                         |                         |
               access PVID 10            access PVID 20              SVI
               (untag 10)                (untag 20)              br0.10 / br0.20
               veth-a / VM               veth-b / IoT            host IPs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important kernel/userspace facts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Without VLAN filtering, the bridge largely ignores 802.1Q membership as a security boundary.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;VLANFiltering=yes&lt;/code&gt;, each port only admits the VLAN IDs you assign.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PVID&lt;/strong&gt; is the VLAN assigned to &lt;em&gt;ingress untagged&lt;/em&gt; frames on that port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress untagged&lt;/strong&gt; means frames for that VLAN leave the port without a tag (classic access-port behavior).&lt;/li&gt;
&lt;li&gt;A VLAN must be configured on &lt;strong&gt;both&lt;/strong&gt; the bridge master and each enslaved port that should carry it. systemd-networkd documents this explicitly for &lt;code&gt;[BridgeVLAN]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Stacked VLAN netdevs (&lt;code&gt;Kind=vlan&lt;/code&gt;) on the &lt;em&gt;bridge&lt;/em&gt; are how the host itself joins a VLAN (SVI-style), separate from port membership.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kernel bridge with VLAN filtering (any current distro kernel)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iproute2&lt;/code&gt; (&lt;code&gt;bridge&lt;/code&gt; and &lt;code&gt;ip&lt;/code&gt; commands)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-networkd&lt;/code&gt; managing the interfaces involved&lt;/li&gt;
&lt;li&gt;Root for live tests&lt;/li&gt;
&lt;li&gt;A maintenance window if you are moving a live uplink onto the bridge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example names used below:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bridge&lt;/td&gt;
&lt;td&gt;&lt;code&gt;br0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;VLAN-aware software switch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trunk NIC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eth0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Uplink to a real switch trunk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access A&lt;/td&gt;
&lt;td&gt;&lt;code&gt;veth-a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Simulates a workstation on VLAN 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access B&lt;/td&gt;
&lt;td&gt;&lt;code&gt;veth-b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Simulates a device on VLAN 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host SVI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;br0.10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional host address on VLAN 10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Replace &lt;code&gt;eth0&lt;/code&gt; with your real interface (&lt;code&gt;ip -br link&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Declarative setup with systemd-networkd
&lt;/h2&gt;

&lt;p&gt;Put files under &lt;code&gt;/etc/systemd/network/&lt;/code&gt;. Lexical order matters only for readability here; matching is by interface name.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Create the VLAN-aware bridge
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/10-br0.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bridge&lt;/span&gt;

&lt;span class="nn"&gt;[Bridge]&lt;/span&gt;
&lt;span class="c"&gt;# Required for [BridgeVLAN] membership to actually enforce isolation
&lt;/span&gt;&lt;span class="py"&gt;VLANFiltering&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="c"&gt;# Optional: protocol for VLAN filtering; 802.1q is the common case
&lt;/span&gt;&lt;span class="py"&gt;VLANProtocol&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;802.1q&lt;/span&gt;
&lt;span class="c"&gt;# Homelab single-path bridges often disable STP to avoid forward-delay
# On multi-switch loops, leave STP enabled and tune timers instead
&lt;/span&gt;&lt;span class="py"&gt;STP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="c"&gt;# Default PVID for newly attached ports if a port omits its own PVID
&lt;/span&gt;&lt;span class="py"&gt;DefaultPVID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From &lt;code&gt;systemd.netdev(5)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;VLANFiltering=&lt;/code&gt; maps to the kernel &lt;code&gt;IFLA_BR_VLAN_FILTERING&lt;/code&gt; option&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DefaultPVID=&lt;/code&gt; sets the default port VLAN ID for newly attached ports (&lt;code&gt;1...4094&lt;/code&gt; or &lt;code&gt;none&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;STP=&lt;/code&gt; enables classic Spanning Tree when you have physical loops&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) Do not put IPs on member NICs
&lt;/h3&gt;

&lt;p&gt;Members are L2 only. Addressing belongs on &lt;code&gt;br0&lt;/code&gt; (untagged/default) or on VLAN SVIs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/20-eth0.network&lt;/code&gt; (trunk uplink):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;eth0&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Bridge&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0&lt;/span&gt;
&lt;span class="c"&gt;# No Address=, no DHCP= on the member
&lt;/span&gt;
&lt;span class="nn"&gt;[BridgeVLAN]&lt;/span&gt;
&lt;span class="c"&gt;# Trunk: allow VLANs 10 and 20 tagged both ways
&lt;/span&gt;&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;
&lt;span class="c"&gt;# Intentionally no PVID / EgressUntagged on a pure trunk
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/20-veth-a.network&lt;/code&gt; (access VLAN 10):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;veth-a&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Bridge&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0&lt;/span&gt;

&lt;span class="nn"&gt;[BridgeVLAN]&lt;/span&gt;
&lt;span class="c"&gt;# Access port semantics:
# - admit VLAN 10
# - untagged frames in -&amp;gt; VLAN 10 (PVID)
# - VLAN 10 frames out -&amp;gt; untagged
&lt;/span&gt;&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;EgressUntagged&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;PVID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/20-veth-b.network&lt;/code&gt; (access VLAN 20):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;veth-b&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Bridge&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0&lt;/span&gt;

&lt;span class="nn"&gt;[BridgeVLAN]&lt;/span&gt;
&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;
&lt;span class="py"&gt;EgressUntagged&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;
&lt;span class="py"&gt;PVID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From &lt;code&gt;systemd.network(5)&lt;/code&gt; &lt;code&gt;[BridgeVLAN]&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;VLAN=&lt;/code&gt; — allowed VLAN ID or range &lt;code&gt;M-N&lt;/code&gt; (1...4094); repeatable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EgressUntagged=&lt;/code&gt; — egress without tag; also enables the ID for ingress&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PVID=&lt;/code&gt; — VLAN for ingress untagged frames; also enables the ID&lt;/li&gt;
&lt;li&gt;VLAN filtering must be on the bridge master&lt;/li&gt;
&lt;li&gt;IDs not listed in the matching &lt;code&gt;.network&lt;/code&gt; are removed from the interface when networkd manages them&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3) Configure the bridge device itself
&lt;/h3&gt;

&lt;p&gt;The bridge master also needs VLAN membership for the VLANs it should bridge — and for any local SVIs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/30-br0.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="c"&gt;# Optional: create host SVIs declared as .netdev units
&lt;/span&gt;&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0.10&lt;/span&gt;
&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0.20&lt;/span&gt;
&lt;span class="c"&gt;# Usually leave LinkLocalAddressing alone unless you need IPv6 LL on br0
&lt;/span&gt;&lt;span class="py"&gt;ConfigureWithoutCarrier&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[BridgeVLAN]&lt;/span&gt;
&lt;span class="c"&gt;# Bridge master must list the VLANs present on the switch fabric
&lt;/span&gt;&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4) Optional host SVIs (Layer 3 on selected VLANs)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/40-br0.10.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0.10&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vlan&lt;/span&gt;

&lt;span class="nn"&gt;[VLAN]&lt;/span&gt;
&lt;span class="py"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/40-br0.20.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0.20&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vlan&lt;/span&gt;

&lt;span class="nn"&gt;[VLAN]&lt;/span&gt;
&lt;span class="py"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/50-br0.10.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0.10&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.10.10.1/24&lt;/span&gt;
&lt;span class="c"&gt;# DHCP=no is implicit when you set Address=
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/50-br0.20.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;br0.20&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.10.20.1/24&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the “router-on-a-stick / SVI” pattern on one box: L2 isolation on the bridge, L3 only where you intentionally address a VLAN interface. Forwarding between SVIs still requires &lt;code&gt;IPv4Forwarding=yes&lt;/code&gt; (or sysctl) &lt;strong&gt;and&lt;/strong&gt; firewall policy — VLAN filtering alone does not replace a firewall.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Apply
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;networkctl reload
&lt;span class="c"&gt;# If netdevs were newly added or Kind options like VLANFiltering changed&lt;/span&gt;
&lt;span class="c"&gt;# after the device already existed, a restart is safer:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-networkd.service

networkctl status br0 eth0 veth-a veth-b br0.10 br0.20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;systemd.netdev(5)&lt;/code&gt; notes that several settings cannot be changed in place on an existing netdev; removing the device or restarting networkd may be required when altering bridge VLAN mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab ports without spare NICs
&lt;/h2&gt;

&lt;p&gt;If you do not have extra physical NICs, create veth pairs for access ports (document-only lab pattern):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add veth-a &lt;span class="nb"&gt;type &lt;/span&gt;veth peer name veth-a-peer
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add veth-b &lt;span class="nb"&gt;type &lt;/span&gt;veth peer name veth-b-peer
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-a up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-b up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-a-peer up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-b-peer up

&lt;span class="c"&gt;# Address the *peer* side as if it were a VM NIC&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 10.10.10.10/24 dev veth-a-peer
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 10.10.20.10/24 dev veth-b-peer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let networkd enslave &lt;code&gt;veth-a&lt;/code&gt; / &lt;code&gt;veth-b&lt;/code&gt; via the &lt;code&gt;.network&lt;/code&gt; files above. The peer ends stay outside the bridge and act as endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Bridge flags and members
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show br0
bridge &lt;span class="nb"&gt;link &lt;/span&gt;show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm members list &lt;code&gt;master br0&lt;/code&gt; and the bridge shows VLAN filtering enabled in the detailed link output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-port VLAN table
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bridge vlan show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want something conceptually like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;port              vlan-id  
eth0              10
                  20
veth-a            10 PVID Egress Untagged
veth-b            20 PVID Egress Untagged
br0               10
                  20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exact formatting varies by &lt;code&gt;iproute2&lt;/code&gt; version; the important signals are &lt;strong&gt;PVID&lt;/strong&gt; and &lt;strong&gt;Egress Untagged&lt;/strong&gt; on access ports, and &lt;strong&gt;no accidental shared PVID&lt;/strong&gt; across tenants.&lt;/p&gt;

&lt;h3&gt;
  
  
  FDB (MAC learning)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bridge fdb show br br0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Learned entries should appear on the correct port after traffic. This is L2 reachability evidence, not a security audit by itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Isolation check (the point of the article)
&lt;/h3&gt;

&lt;p&gt;From the VLAN 10 endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Should work if br0.10 is 10.10.10.1/24&lt;/span&gt;
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 3 10.10.10.1

&lt;span class="c"&gt;# Should fail (no L2 path into VLAN 20 from an access-10 port)&lt;/span&gt;
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 3 10.10.20.1
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 3 10.10.20.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the VLAN 20 endpoint, reverse the expectations.&lt;/p&gt;

&lt;p&gt;Optional packet view on the trunk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;tcpdump &lt;span class="nt"&gt;-ni&lt;/span&gt; eth0 &lt;span class="nt"&gt;-e&lt;/span&gt; vlan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Access-port traffic should appear &lt;strong&gt;tagged&lt;/strong&gt; on the trunk with the correct VLAN ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  Negative test worth doing once
&lt;/h3&gt;

&lt;p&gt;Temporarily break isolation on purpose in a lab (not production): set both access ports to the same &lt;code&gt;PVID&lt;/code&gt;/&lt;code&gt;EgressUntagged&lt;/code&gt; and re-test. When pings suddenly work “across VLANs,” you have demonstrated that membership — not hope — is the control plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational pitfalls
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting VLAN filtering&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;[BridgeVLAN]&lt;/code&gt; without &lt;code&gt;VLANFiltering=yes&lt;/code&gt; on the bridge does not give you a VLAN-aware switch.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Configuring VLANs only on ports, not on &lt;code&gt;br0&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
systemd-networkd requires the bridge master and enslaved devices to share the relevant VLAN IDs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Putting DHCP/IP on member NICs&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Members should be pure L2. Addresses go on SVIs or, for a single untagged LAN, on &lt;code&gt;br0&lt;/code&gt; itself.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Assuming L2 isolation equals security policy&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Host SVIs can still route between VLANs if IP forwarding and firewall rules allow it. Pair bridge VLANs with nftables/iptables policy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;STP forward delay surprises&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
With STP enabled, new ports can sit in listening/learning before forwarding. Labs often set &lt;code&gt;STP=no&lt;/code&gt;; multi-switch fabrics should keep STP (or move to a proper control plane) and accept the delay.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;MAC address / hosting filters&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Some providers filter by the physical NIC MAC. Cloning the uplink MAC onto &lt;code&gt;br0&lt;/code&gt; (or using &lt;code&gt;MACAddress=none&lt;/code&gt; + &lt;code&gt;MACAddressPolicy=none&lt;/code&gt; patterns from the ArchWiki bridge notes) may be required when bridging the primary NIC on a hosted server.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wireless clients as bridge members&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Client-mode Wi-Fi usually cannot be bridged like Ethernet. That is a 802.11 limitation, not a networkd bug.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fighting other network managers&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Only one manager should own these interfaces. Disable conflicting NetworkManager/dhcpcd claims on the same NICs.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Minimal nftables reminder (not a full firewall guide)
&lt;/h2&gt;

&lt;p&gt;If the host is a gateway between SVIs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Example only — adapt to your policy framework&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.ip_forward&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="c"&gt;# Prefer persistent sysctl.d or IPv4Forwarding= in .network where appropriate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then allow only the flows you intend between &lt;code&gt;br0.10&lt;/code&gt; and &lt;code&gt;br0.20&lt;/code&gt;. VLAN filtering stops accidental L2 mixing; it does not invent L3 policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remove or move aside the units you added&lt;/span&gt;
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/systemd/network/10-br0.netdev &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/20-eth0.network &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/20-veth-a.network &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/20-veth-b.network &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/30-br0.network &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/40-br0.&lt;span class="k"&gt;*&lt;/span&gt;.netdev &lt;span class="se"&gt;\&lt;/span&gt;
           /etc/systemd/network/50-br0.&lt;span class="k"&gt;*&lt;/span&gt;.network

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-networkd.service

&lt;span class="c"&gt;# Lab-only cleanup&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del veth-a 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del veth-b 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del br0 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restore the previous &lt;code&gt;.network&lt;/code&gt; that addressed &lt;code&gt;eth0&lt;/code&gt; directly before you leave the window.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this differs from nearby tools
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Isolates&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VLAN-aware bridge&lt;/td&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;Broadcast domains / 802.1Q membership&lt;/td&gt;
&lt;td&gt;Hypervisor uplink, lab switch-in-a-box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stacked &lt;code&gt;eth0.10&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;L2 endpoint&lt;/td&gt;
&lt;td&gt;Host sees one VLAN each&lt;/td&gt;
&lt;td&gt;Simple host-on-trunk, no multi-port switch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bonding/LACP&lt;/td&gt;
&lt;td&gt;L2 path&lt;/td&gt;
&lt;td&gt;Nothing by itself&lt;/td&gt;
&lt;td&gt;NIC/cable redundancy under a bridge or VLAN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VRF&lt;/td&gt;
&lt;td&gt;L3&lt;/td&gt;
&lt;td&gt;Routing tables / defaults&lt;/td&gt;
&lt;td&gt;Multi-tenant gateways on one host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network namespaces&lt;/td&gt;
&lt;td&gt;L2+L3+sockets&lt;/td&gt;
&lt;td&gt;Whole network stack instances&lt;/td&gt;
&lt;td&gt;Strong process isolation, containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;keepalived/VRRP&lt;/td&gt;
&lt;td&gt;L3 VIP ownership&lt;/td&gt;
&lt;td&gt;Failover, not VLAN separation&lt;/td&gt;
&lt;td&gt;HA default gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use the VLAN-aware bridge when you need &lt;strong&gt;one Linux box to behave like a small 802.1Q switch&lt;/strong&gt;. Use VRF when the problem is &lt;strong&gt;which routing table owns the default route&lt;/strong&gt;. Use both when a hypervisor is a tagged switch &lt;em&gt;and&lt;/em&gt; a multi-table router.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;Kind=bridge&lt;/code&gt; with &lt;code&gt;VLANFiltering=yes&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Trunk port: &lt;code&gt;Bridge=&lt;/code&gt; + &lt;code&gt;VLAN=&lt;/code&gt; IDs, no accidental shared PVID&lt;/li&gt;
&lt;li&gt;[ ] Access ports: matching &lt;code&gt;VLAN=&lt;/code&gt; + &lt;code&gt;PVID=&lt;/code&gt; + &lt;code&gt;EgressUntagged=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Bridge master &lt;code&gt;[BridgeVLAN]&lt;/code&gt; lists the same fabric VLANs&lt;/li&gt;
&lt;li&gt;[ ] Host IPs only on SVIs (or carefully on &lt;code&gt;br0&lt;/code&gt; for a single LAN)&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;bridge vlan show&lt;/code&gt; matches the design&lt;/li&gt;
&lt;li&gt;[ ] Cross-VLAN ping fails without intentional L3 forwarding&lt;/li&gt;
&lt;li&gt;[ ] Firewall policy written if SVIs route&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://man.archlinux.org/man/systemd.netdev.5.en" rel="noopener noreferrer"&gt;systemd.netdev(5)&lt;/a&gt; — &lt;code&gt;Kind=bridge&lt;/code&gt;, &lt;code&gt;[Bridge]&lt;/code&gt; (&lt;code&gt;VLANFiltering=&lt;/code&gt;, &lt;code&gt;DefaultPVID=&lt;/code&gt;, &lt;code&gt;STP=&lt;/code&gt;, &lt;code&gt;VLANProtocol=&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man.archlinux.org/man/systemd.network.5.en" rel="noopener noreferrer"&gt;systemd.network(5)&lt;/a&gt; — &lt;code&gt;Bridge=&lt;/code&gt;, &lt;code&gt;[BridgeVLAN]&lt;/code&gt; (&lt;code&gt;VLAN=&lt;/code&gt;, &lt;code&gt;EgressUntagged=&lt;/code&gt;, &lt;code&gt;PVID=&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man.archlinux.org/man/bridge.8.en" rel="noopener noreferrer"&gt;bridge(8)&lt;/a&gt; — &lt;code&gt;bridge vlan&lt;/code&gt;, &lt;code&gt;bridge link&lt;/code&gt;, &lt;code&gt;bridge fdb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wiki.archlinux.org/title/Systemd-networkd" rel="noopener noreferrer"&gt;ArchWiki: systemd-networkd — Bridge interface&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wiki.archlinux.org/title/Network_bridge" rel="noopener noreferrer"&gt;ArchWiki: Network bridge&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.archlinux.org/title/VLAN" rel="noopener noreferrer"&gt;ArchWiki: VLAN&lt;/a&gt; — stacked VLAN netdevs vs bridge membership&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.kernel.org/doc/Documentation/networking/switchdev.txt" rel="noopener noreferrer"&gt;Kernel switchdev overview&lt;/a&gt; — notes enabling VLAN filtering on bridges (&lt;code&gt;vlan_filtering&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Build the bridge like a switch: filtering on, membership explicit, trunks tagged, access ports untagged on purpose. Anything less is just a multiport hub with better marketing.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>systemd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Mixing Tenant Routes: Practical Linux VRF with systemd-networkd</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Tue, 11 Aug 2026 05:02:56 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-mixing-tenant-routes-practical-linux-vrf-with-systemd-networkd-5643</link>
      <guid>https://dev.to/lyraalishaikh/stop-mixing-tenant-routes-practical-linux-vrf-with-systemd-networkd-5643</guid>
      <description>&lt;h1&gt;
  
  
  Stop Mixing Tenant Routes: Practical Linux VRF with systemd-networkd
&lt;/h1&gt;

&lt;p&gt;You already know the failure mode: one Linux box has a management NIC, a lab VLAN, and a production uplink. Someone adds a second default route "just for testing," and suddenly SSH, monitoring, or a backup job exits through the wrong gateway. Or two tenants share overlapping RFC1918 space and your host starts answering both with the same FIB.&lt;/p&gt;

&lt;p&gt;Linux &lt;strong&gt;VRF&lt;/strong&gt; (Virtual Routing and Forwarding — specifically VRF-lite in the kernel docs) fixes that at Layer 3. Each VRF is a separate routing domain with its own table and default gateway. Interfaces are &lt;em&gt;enslaved&lt;/em&gt; to a VRF device; connected and local routes move with them. L2 tools like LLDP keep working on the real NICs because VRF only reshapes L3 and above.&lt;/p&gt;

&lt;p&gt;This post is a practical, declarative setup with &lt;strong&gt;systemd-networkd&lt;/strong&gt;, plus the &lt;code&gt;ip vrf&lt;/code&gt; helpers you will use day to day. It is not a bonding guide, not keepalived/VRRP, and not full network namespaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get (and what you do not)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In scope&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create VRF devices with dedicated FIB tables&lt;/li&gt;
&lt;li&gt;Enslave NICs/VLANs into VRFs with systemd-networkd&lt;/li&gt;
&lt;li&gt;Per-VRF default routes and lookups&lt;/li&gt;
&lt;li&gt;Run commands inside a VRF (&lt;code&gt;ip vrf exec&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Optional cross-VRF service accept sysctls&lt;/li&gt;
&lt;li&gt;Verification and a clean rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Out of scope&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NIC redundancy (bonding/LACP) — path resilience, not route isolation&lt;/li&gt;
&lt;li&gt;Floating VIP HA (keepalived/VRRP) and conntrack state sync&lt;/li&gt;
&lt;li&gt;Full network namespace / container isolation (you can nest VRF &lt;em&gt;inside&lt;/em&gt; netns later)&lt;/li&gt;
&lt;li&gt;Dynamic routing daemons (FRR/BIRD) beyond noting the unreachable-default metric pattern&lt;/li&gt;
&lt;li&gt;Policy routing that is &lt;em&gt;not&lt;/em&gt; tied to a VRF device (classic &lt;code&gt;ip rule&lt;/code&gt; PBR alone)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Mental model
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------------+          +------------------+
|   vrf-mgmt       |          |   vrf-tenant-a   |
|   table 10       |          |   table 20       |
+--------+---------+          +--------+---------+
         |                             |
    +----+----+                   +----+----+
    | eth0    |                   | eth1.100|
    | 10.0.0.2|                   | 10.10.1.2|
    +---------+                   +---------+
 default via 10.0.0.1            default via 10.10.1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kernel behavior that matters in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;type vrf table &amp;lt;id&amp;gt;&lt;/code&gt; and bring it up.&lt;/li&gt;
&lt;li&gt;On modern kernels (4.8+), a single &lt;strong&gt;l3mdev&lt;/strong&gt; FIB rule (preference 1000 by default) steers lookups for all VRFs — you usually do &lt;strong&gt;not&lt;/strong&gt; hand-craft per-VRF &lt;code&gt;iif&lt;/code&gt;/&lt;code&gt;oif&lt;/code&gt; rules anymore.&lt;/li&gt;
&lt;li&gt;Enslave interfaces: &lt;code&gt;ip link set dev eth1 master vrf-tenant-a&lt;/code&gt; (or &lt;code&gt;VRF=&lt;/code&gt; in &lt;code&gt;.network&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Connected/local routes move into that table automatically. Extra routes that depended on the device are dropped and must be re-added into the VRF table.&lt;/li&gt;
&lt;li&gt;Processes are in the &lt;strong&gt;default&lt;/strong&gt; VRF unless they bind to a VRF device (&lt;code&gt;SO_BINDTODEVICE&lt;/code&gt;) or you launch them with &lt;code&gt;ip vrf exec&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel with VRF / l3mdev support (any current distro kernel qualifies)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iproute2&lt;/code&gt; with &lt;code&gt;ip vrf&lt;/code&gt; (Debian/Ubuntu: package &lt;code&gt;iproute2&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-networkd&lt;/code&gt; managing the relevant interfaces&lt;/li&gt;
&lt;li&gt;Root or equivalent for link/route changes&lt;/li&gt;
&lt;li&gt;A maintenance window if you are moving a live management path into a VRF&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Name your tables in &lt;code&gt;/etc/iproute2/rt_tables.d/&lt;/code&gt; so &lt;code&gt;ip route show table tenant-a&lt;/code&gt; is readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/iproute2/rt_tables.d/vrf.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
10 mgmt
20 tenant-a
30 tenant-b
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Table IDs are yours to choose; avoid colliding with well-known ones (&lt;code&gt;local&lt;/code&gt;/&lt;code&gt;main&lt;/code&gt;/&lt;code&gt;default&lt;/code&gt;). Using 10+ keeps the examples clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Declarative setup with systemd-networkd
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1) Create the VRF devices
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/10-vrf-mgmt.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf-mgmt&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf&lt;/span&gt;

&lt;span class="nn"&gt;[VRF]&lt;/span&gt;
&lt;span class="py"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/10-vrf-tenant-a.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf-tenant-a&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf&lt;/span&gt;

&lt;span class="nn"&gt;[VRF]&lt;/span&gt;
&lt;span class="py"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/10-vrf-tenant-b.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf-tenant-b&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf&lt;/span&gt;

&lt;span class="nn"&gt;[VRF]&lt;/span&gt;
&lt;span class="py"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Table=&lt;/code&gt; is compulsory for &lt;code&gt;Kind=vrf&lt;/code&gt; — that is the FIB table the VRF owns.&lt;/p&gt;

&lt;p&gt;Bring the VRF devices themselves up (no IPs on the VRF device in the common pattern):&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/15-vrf-mgmt.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf-mgmt&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="c"&gt;# VRF master device: L3 domain only; addresses live on enslaved links.
&lt;/span&gt;&lt;span class="py"&gt;ConfigureWithoutCarrier&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mirror that for &lt;code&gt;vrf-tenant-a&lt;/code&gt; and &lt;code&gt;vrf-tenant-b&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Enslave interfaces and configure addressing
&lt;/h3&gt;

&lt;p&gt;Example: management NIC stays on &lt;code&gt;eth0&lt;/code&gt;, tenant A rides VLAN 100 on &lt;code&gt;eth1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/20-eth0-mgmt.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;eth0&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;VRF&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf-mgmt&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.0.0.2/24&lt;/span&gt;
&lt;span class="py"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.0.0.1&lt;/span&gt;
&lt;span class="py"&gt;DNS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.0.0.53&lt;/span&gt;
&lt;span class="c"&gt;# Keep wait-online sane if other links are optional:
&lt;/span&gt;&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/20-eth1.network&lt;/code&gt; (parent link, no IP — only carries VLANs):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;eth1&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;eth1.100&lt;/span&gt;
&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;eth1.200&lt;/span&gt;
&lt;span class="py"&gt;LinkLocalAddressing&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="py"&gt;LLDP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;EmitLLDP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;nearest-bridge&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/21-eth1.100.netdev&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;eth1.100&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vlan&lt;/span&gt;

&lt;span class="nn"&gt;[VLAN]&lt;/span&gt;
&lt;span class="py"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/21-eth1.100.network&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;eth1.100&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;VRF&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf-tenant-a&lt;/span&gt;
&lt;span class="py"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.10.1.2/24&lt;/span&gt;
&lt;span class="py"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.10.1.1&lt;/span&gt;
&lt;span class="py"&gt;DNS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10.10.1.53&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And tenant B on VLAN 200 → &lt;code&gt;VRF=vrf-tenant-b&lt;/code&gt;, addresses in that tenant's space (even if it overlaps tenant A's RFC1918).&lt;/p&gt;

&lt;p&gt;Key systemd facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;VRF=&lt;/code&gt; on a &lt;code&gt;.network&lt;/code&gt; file enslaves the matched link to that VRF master (same idea as &lt;code&gt;Bond=&lt;/code&gt; / &lt;code&gt;Bridge=&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Routes generated for the link (including DHCP/RA routes when used) land in the VRF table when &lt;code&gt;VRF=&lt;/code&gt; is set; you do not need a separate &lt;code&gt;RouteTable=&lt;/code&gt; unless you are doing something custom.&lt;/li&gt;
&lt;li&gt;For kernels before 4.8, systemd's own example notes that traffic will not follow the VRF table unless extra &lt;code&gt;ip rule&lt;/code&gt; entries exist. On anything you actually run in 2026, you are past that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3) Optional: unreachable default as a safety net
&lt;/h3&gt;

&lt;p&gt;Kernel VRF documentation recommends a high-metric unreachable default in non-management tables so a missing real default does not silently leak into another table's expectations, and so routing suites can override it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Equivalent one-shot (prefer declaring Gateway= on the enslaved .network when you have one)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip route replace table 20 unreachable default metric 4278198272
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip route replace table 30 unreachable default metric 4278198272
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With systemd-networkd, a real &lt;code&gt;Gateway=&lt;/code&gt; on the enslaved interface is usually enough for homelab/edge hosts. Keep the unreachable default in mind if a routing daemon owns the table.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Apply
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;networkctl reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;networkctl reconfigure vrf-mgmt vrf-tenant-a vrf-tenant-b eth0 eth1 eth1.100 eth1.200
&lt;span class="c"&gt;# or, if your distro prefers a full bounce for netdev creation:&lt;/span&gt;
&lt;span class="c"&gt;# sudo systemctl restart systemd-networkd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verify before you trust it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# VRF devices and table IDs&lt;/span&gt;
ip &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show &lt;span class="nb"&gt;type &lt;/span&gt;vrf
ip &lt;span class="nt"&gt;-br&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show &lt;span class="nb"&gt;type &lt;/span&gt;vrf

&lt;span class="c"&gt;# Which links sit in which domain&lt;/span&gt;
ip &lt;span class="nt"&gt;-br&lt;/span&gt; &lt;span class="nb"&gt;link &lt;/span&gt;show vrf vrf-tenant-a
ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr show vrf vrf-tenant-a

&lt;span class="c"&gt;# Routes are in the VRF table, not main&lt;/span&gt;
ip route show vrf vrf-mgmt
ip route show vrf vrf-tenant-a
ip route show table main | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# l3mdev rule present (modern kernels)&lt;/span&gt;
ip rule show | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'l3mdev|pref 1000'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; ip rule show

&lt;span class="c"&gt;# FIB lookup from a specific VRF&lt;/span&gt;
ip route get 1.1.1.1 vrf vrf-tenant-a
ip route get 1.1.1.1 vrf vrf-mgmt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want different egress interfaces/gateways for the two &lt;code&gt;ip route get&lt;/code&gt; calls when destinations are reached via different defaults.&lt;/p&gt;

&lt;p&gt;Neighbor cache is also VRF-scoped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip neigh show vrf vrf-tenant-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Run apps inside a VRF
&lt;/h2&gt;

&lt;p&gt;Most programs are &lt;strong&gt;not&lt;/strong&gt; VRF-aware. They open sockets in the default VRF and use the main table. That is why "ping works from the host" can still mean "tenant path is broken for the service."&lt;/p&gt;

&lt;h3&gt;
  
  
  One-shot commands
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Requires cgroup v2 + appropriate capabilities (normal on systemd hosts as root)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip vrf &lt;span class="nb"&gt;exec &lt;/span&gt;vrf-tenant-a ping &lt;span class="nt"&gt;-c&lt;/span&gt; 3 10.10.1.1
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip vrf &lt;span class="nb"&gt;exec &lt;/span&gt;vrf-tenant-a curl &lt;span class="nt"&gt;-4&lt;/span&gt; &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 https://example.com/ &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip vrf &lt;span class="nb"&gt;exec &lt;/span&gt;vrf-mgmt ssh admin@10.0.0.10 &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Helpers from &lt;code&gt;ip-vrf(8)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip vrf show
ip vrf identify &lt;span class="nv"&gt;$$&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip vrf pids vrf-tenant-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ip vrf exec&lt;/code&gt; associates the process (and children) with the VRF so new IPv4/IPv6 sockets inherit the domain. It only affects network-layer sockets.&lt;/p&gt;

&lt;h3&gt;
  
  
  systemd service bound to a VRF
&lt;/h3&gt;

&lt;p&gt;For a long-running daemon, wrap the &lt;code&gt;ExecStart&lt;/code&gt; (simple pattern) or bind the socket to the device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern A — exec wrapper (works for most off-the-shelf binaries):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/demo-exporter.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Demo exporter pinned to tenant-a VRF&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/ip vrf exec vrf-tenant-a /usr/local/bin/demo-exporter --listen 0.0.0.0:9100&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pattern B — socket unit with &lt;code&gt;BindToDevice=&lt;/code&gt;&lt;/strong&gt; (when you socket-activate or the daemon honors the systemd socket):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/demo-exporter.socket
&lt;/span&gt;&lt;span class="nn"&gt;[Socket]&lt;/span&gt;
&lt;span class="py"&gt;ListenStream&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;9100&lt;/span&gt;
&lt;span class="py"&gt;BindToDevice&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vrf-tenant-a&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sockets.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BindToDevice=&lt;/code&gt; pins the listening socket to that interface/VRF device name. Prefer the pattern your app already supports; do not assume every binary behaves identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-VRF listeners (usually leave these off)
&lt;/h2&gt;

&lt;p&gt;By default, a process in the default VRF does &lt;strong&gt;not&lt;/strong&gt; accept TCP/UDP connections that arrive on VRF-enslaved interfaces. That is the safe multi-tenant default: same port can exist independently per VRF.&lt;/p&gt;

&lt;p&gt;Kernel knobs (documented in the VRF howto):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Allow default-VRF TCP/UDP servers to accept connections from all VRFs&lt;/span&gt;
&lt;span class="c"&gt;# Disabled by default — only enable with a clear reason.&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.tcp_l3mdev_accept&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.udp_l3mdev_accept&lt;span class="o"&gt;=&lt;/span&gt;0

&lt;span class="c"&gt;# RAW sockets historically default to accept=1 for compatibility (e.g. older ping).&lt;/span&gt;
&lt;span class="c"&gt;# Tighten if you want strict VRF isolation for RAW as well:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.raw_l3mdev_accept&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist under &lt;code&gt;/etc/sysctl.d/90-vrf.conf&lt;/code&gt; only after you decide the policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;net&lt;/span&gt;.&lt;span class="n"&gt;ipv4&lt;/span&gt;.&lt;span class="n"&gt;tcp_l3mdev_accept&lt;/span&gt; = &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;net&lt;/span&gt;.&lt;span class="n"&gt;ipv4&lt;/span&gt;.&lt;span class="n"&gt;udp_l3mdev_accept&lt;/span&gt; = &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;net&lt;/span&gt;.&lt;span class="n"&gt;ipv4&lt;/span&gt;.&lt;span class="n"&gt;raw_l3mdev_accept&lt;/span&gt; = &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="c"&gt;# Optional when enslaving interfaces that already hold global IPv6 addresses:
# net.ipv6.conf.all.keep_addr_on_down = 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you set &lt;code&gt;tcp_l3mdev_accept=1&lt;/code&gt;, the kernel docs warn that it can be &lt;strong&gt;unspecified&lt;/strong&gt; whether a VRF-bound listener or a default-VRF listener wins for new VRF traffic — ugly if you rely on per-VRF TCP MD5 or different socket options. Prefer explicit &lt;code&gt;ip vrf exec&lt;/code&gt; / bind-to-device services over global accept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Netfilter and capture notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You can attach nftables/iptables and &lt;code&gt;tc&lt;/code&gt; rules to the &lt;strong&gt;VRF device&lt;/strong&gt; to match the whole domain.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tcpdump -i vrf-tenant-a&lt;/code&gt; sees traffic that enters/leaves the VRF domain as a whole; forwarded packets that never traverse the VRF netdev path may not show up (kernel docs call this out).&lt;/li&gt;
&lt;li&gt;For conntrack-heavy firewalls, remember VRF changes &lt;em&gt;which table&lt;/em&gt; owns the route; it does not replace state sync across HA pairs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Controlled isolation test
&lt;/h2&gt;

&lt;p&gt;On a host with two VRFs and distinct defaults:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) Confirm main table is not holding tenant defaults&lt;/span&gt;
ip route show table main

&lt;span class="c"&gt;# 2) Lookups differ by VRF&lt;/span&gt;
ip route get 8.8.8.8 vrf vrf-mgmt
ip route get 8.8.8.8 vrf vrf-tenant-a

&lt;span class="c"&gt;# 3) Data plane from each domain&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip vrf &lt;span class="nb"&gt;exec &lt;/span&gt;vrf-mgmt    ping &lt;span class="nt"&gt;-c&lt;/span&gt; 2 &lt;span class="nt"&gt;-W&lt;/span&gt; 2 8.8.8.8
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip vrf &lt;span class="nb"&gt;exec &lt;/span&gt;vrf-tenant-a ping &lt;span class="nt"&gt;-c&lt;/span&gt; 2 &lt;span class="nt"&gt;-W&lt;/span&gt; 2 8.8.8.8

&lt;span class="c"&gt;# 4) Negative test: without vrf exec, traffic uses default VRF only&lt;/span&gt;
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 2 &lt;span class="nt"&gt;-W&lt;/span&gt; 2 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If step 3 works per domain and step 2 shows different &lt;code&gt;dev&lt;/code&gt;/&lt;code&gt;via&lt;/code&gt;, your isolation is real. If both VRFs somehow share one gateway you did not configure, stop and re-check enslavement (&lt;code&gt;ip link show master vrf-tenant-a&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational pitfalls
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Moving the only management path into a VRF without &lt;code&gt;ip vrf exec&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Your existing SSH session may survive until restart; new admin tools on the box will not reach mgmt routes unless they run in &lt;code&gt;vrf-mgmt&lt;/code&gt;. Keep a console/IPMI plan.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting to re-add static routes after enslavement&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Non-connected routes that pointed at the device are dropped on master assignment. Reinstall them with &lt;code&gt;table &amp;lt;id&amp;gt;&lt;/code&gt; / &lt;code&gt;vrf &amp;lt;name&amp;gt;&lt;/code&gt; or declare them in the &lt;code&gt;.network&lt;/code&gt; file.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Overlapping tenant IP space without VRF&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Overlap is a reason &lt;em&gt;to&lt;/em&gt; use VRF. Without it, the FIB cannot disambiguate. With it, still be careful with services that bind &lt;code&gt;*:&lt;/code&gt; in the default VRF and &lt;code&gt;*_l3mdev_accept=1&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DHCP on enslaved links&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Works with networkd; learned routes go to the VRF table when &lt;code&gt;VRF=&lt;/code&gt; is set. Verify with &lt;code&gt;ip route show vrf …&lt;/code&gt; after lease acquisition.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mixing VRF with ad-hoc &lt;code&gt;ip rule&lt;/code&gt; PBR&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Higher-priority policy rules can override VRF l3mdev steering. That is powerful and easy to make undebuggable — document any extra rules next to the VRF config.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;cgroup v2 requirement for &lt;code&gt;ip vrf exec&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The man page requires cgroup v2 (normal on current systemd). If exec fails, check that before blaming the VRF device.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Rollback
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Temporary: free a NIC from its VRF&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;dev eth1.100 nomaster

&lt;span class="c"&gt;# Persistent: remove VRF= lines / .netdev files, then&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;networkctl reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-networkd

&lt;span class="c"&gt;# Remove unused VRF devices (after no slaves remain)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;delete dev vrf-tenant-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connected routes return to the main/local tables when you detach with &lt;code&gt;nomaster&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal lab (two VRFs, no spare physical NICs)
&lt;/h2&gt;

&lt;p&gt;If you only have one NIC, practice with dummy interfaces or veth pairs before touching production uplinks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add dummy-a &lt;span class="nb"&gt;type &lt;/span&gt;dummy
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add dummy-b &lt;span class="nb"&gt;type &lt;/span&gt;dummy
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add vrf-a &lt;span class="nb"&gt;type &lt;/span&gt;vrf table 110
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add vrf-b &lt;span class="nb"&gt;type &lt;/span&gt;vrf table 120
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;vrf-a up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;vrf-b up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;dummy-a up master vrf-a
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;dummy-b up master vrf-b
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.0.2.1/24 dev dummy-a
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 198.51.100.1/24 dev dummy-b
ip route show vrf vrf-a
ip route show vrf vrf-b
&lt;span class="c"&gt;# cleanup&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del dummy-a&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del dummy-b
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del vrf-a&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;del vrf-b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then promote the same shape into systemd-networkd units.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to choose something else
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Better tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cable/NIC failure on one host&lt;/td&gt;
&lt;td&gt;Bonding/LACP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host failure for a shared VIP&lt;/td&gt;
&lt;td&gt;keepalived/VRRP (+ conntrackd if stateful FW)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full process + interface isolation&lt;/td&gt;
&lt;td&gt;network namespaces / containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encrypt a path&lt;/td&gt;
&lt;td&gt;WireGuard/IPsec (can still sit &lt;em&gt;inside&lt;/em&gt; a VRF)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L2 segmentation only&lt;/td&gt;
&lt;td&gt;VLANs/bridges&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;VRF shines when &lt;strong&gt;one kernel&lt;/strong&gt; must hold &lt;strong&gt;multiple independent L3 realities&lt;/strong&gt; — multi-tenant edge, separate mgmt plane, overlapping lab/prod prefixes — without standing up a VM per table.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux kernel documentation: &lt;a href="https://docs.kernel.org/networking/vrf.html" rel="noopener noreferrer"&gt;Virtual Routing and Forwarding (VRF)&lt;/a&gt; (also plain-text howto on kernel.org)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ip-vrf(8)&lt;/code&gt; — &lt;code&gt;show&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;identify&lt;/code&gt;, &lt;code&gt;pids&lt;/code&gt; (iproute2)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ip-link(8)&lt;/code&gt; — &lt;code&gt;type vrf&lt;/code&gt;, &lt;code&gt;master&lt;/code&gt; / &lt;code&gt;vrf&lt;/code&gt; enslavement&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd.netdev(5)&lt;/code&gt; — &lt;code&gt;Kind=vrf&lt;/code&gt;, &lt;code&gt;[VRF] Table=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd.network(5)&lt;/code&gt; — &lt;code&gt;VRF=&lt;/code&gt;, examples for enslaving links into a VRF&lt;/li&gt;
&lt;li&gt;sysctl policy knobs: &lt;code&gt;net.ipv4.tcp_l3mdev_accept&lt;/code&gt;, &lt;code&gt;udp_l3mdev_accept&lt;/code&gt;, &lt;code&gt;raw_l3mdev_accept&lt;/code&gt;, &lt;code&gt;net.ipv6.conf.all.keep_addr_on_down&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Ship the units, verify with &lt;code&gt;ip route get … vrf …&lt;/code&gt;, and wrap anything that must speak tenant routes in &lt;code&gt;ip vrf exec&lt;/code&gt; or a device-bound socket. Once the FIB stops lying to you about which gateway is "default," the rest of the host gets a lot quieter.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>systemd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Dropping Established Sessions on Failover: Practical conntrackd State Sync on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Mon, 10 Aug 2026 05:02:52 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-dropping-established-sessions-on-failover-practical-conntrackd-state-sync-on-linux-2lcn</link>
      <guid>https://dev.to/lyraalishaikh/stop-dropping-established-sessions-on-failover-practical-conntrackd-state-sync-on-linux-2lcn</guid>
      <description>&lt;h1&gt;
  
  
  Stop Dropping Established Sessions on Failover: Practical conntrackd State Sync on Linux
&lt;/h1&gt;

&lt;p&gt;A floating VIP with keepalived is only half of firewall high availability.&lt;/p&gt;

&lt;p&gt;You move the address. Clients still send packets to the same IP. Then the new active node drops perfectly good established sessions because its kernel never saw the original handshake — so Netfilter has no conntrack entry, &lt;code&gt;ct state established&lt;/code&gt; does not match, and your stateful policy treats the traffic as garbage.&lt;/p&gt;

&lt;p&gt;That is the gap &lt;strong&gt;conntrackd&lt;/strong&gt; fills.&lt;/p&gt;

&lt;p&gt;This guide walks through a practical active/backup setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;why VIP failover alone breaks stateful firewalls&lt;/li&gt;
&lt;li&gt;FTFW sync over a dedicated link&lt;/li&gt;
&lt;li&gt;a clean &lt;code&gt;conntrackd.conf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;keepalived notify hooks that commit state on promote&lt;/li&gt;
&lt;li&gt;verification and a real cutover test&lt;/li&gt;
&lt;li&gt;the boundaries that still bite people&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No theory dump. Config you can paste and reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode (why VRRP is not enough)
&lt;/h2&gt;

&lt;p&gt;Assume two firewalls, one VIP, stateful rules roughly like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# default deny for forwarded traffic&lt;/span&gt;
nft add rule inet filter forward ct state invalid drop
nft add rule inet filter forward ct state established,related accept
nft add rule inet filter forward iifname &lt;span class="s2"&gt;"lan0"&lt;/span&gt; tcp flags syn / syn,rst,ack ct state new accept
&lt;span class="c"&gt;# SNAT / MASQUERADE on the WAN path for LAN clients&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the classic iptables shape from the official conntrack-tools test case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables &lt;span class="nt"&gt;-P&lt;/span&gt; FORWARD DROP
iptables &lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-i&lt;/span&gt; eth0 &lt;span class="nt"&gt;-m&lt;/span&gt; state &lt;span class="nt"&gt;--state&lt;/span&gt; ESTABLISHED,RELATED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
iptables &lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-i&lt;/span&gt; eth1 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;--syn&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; state &lt;span class="nt"&gt;--state&lt;/span&gt; NEW &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
iptables &lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-i&lt;/span&gt; eth1 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; state &lt;span class="nt"&gt;--state&lt;/span&gt; ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
iptables &lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-m&lt;/span&gt; state &lt;span class="nt"&gt;--state&lt;/span&gt; INVALID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; POSTROUTING &lt;span class="nt"&gt;-s&lt;/span&gt; 192.168.0.3 &lt;span class="nt"&gt;-j&lt;/span&gt; SNAT &lt;span class="nt"&gt;--to-source&lt;/span&gt; 192.168.1.100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now start a long SSH (or HTTPS, or DB) session through the active node.&lt;/p&gt;

&lt;p&gt;Kill the active node. keepalived promotes the backup. The VIP moves.&lt;/p&gt;

&lt;p&gt;Without conntrack sync:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The backup owns the VIP.&lt;/li&gt;
&lt;li&gt;Mid-flow TCP segments arrive with no local conntrack entry.&lt;/li&gt;
&lt;li&gt;They are not &lt;code&gt;NEW&lt;/code&gt; SYNs, so they miss the allow-new rule.&lt;/li&gt;
&lt;li&gt;They are not &lt;code&gt;ESTABLISHED&lt;/code&gt; locally, so they miss the established rule.&lt;/li&gt;
&lt;li&gt;With a default-drop forward policy, they die — often logged as &lt;code&gt;INVALID&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With &lt;strong&gt;conntrackd&lt;/strong&gt;, the backup already holds a replica of those flow entries. On promote, it &lt;strong&gt;commits&lt;/strong&gt; them into the kernel table and the same sessions keep flowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are installing
&lt;/h2&gt;

&lt;p&gt;Package names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian / Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; conntrackd conntrack keepalived

&lt;span class="c"&gt;# Fedora / RHEL-ish&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; conntrack-tools keepalived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package gives you two tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;conntrack&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CLI for the kernel conntrack table (&lt;code&gt;-L&lt;/code&gt;, &lt;code&gt;-E&lt;/code&gt;, &lt;code&gt;-D&lt;/code&gt;, …)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;conntrackd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Daemon that replicates flow state between firewalls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kernel prerequisites (almost always present on modern distro kernels):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nf_conntrack&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nf_conntrack_netlink&lt;/code&gt; / &lt;code&gt;CONFIG_NF_CT_NETLINK&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;connection tracking events (&lt;code&gt;CONFIG_NF_CONNTRACK_EVENTS&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick sanity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;modinfo nf_conntrack | &lt;span class="nb"&gt;head
&lt;/span&gt;lsmod | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'nf_conntrack|nfnetlink'&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/net/netfilter/nf_conntrack_max
conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Architecture for this guide
&lt;/h2&gt;

&lt;p&gt;Two-node &lt;strong&gt;active/backup&lt;/strong&gt; firewall pair:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;LAN iface&lt;/th&gt;
&lt;th&gt;WAN iface&lt;/th&gt;
&lt;th&gt;Sync iface&lt;/th&gt;
&lt;th&gt;Dedicated link IP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Preferred primary&lt;/td&gt;
&lt;td&gt;fw1&lt;/td&gt;
&lt;td&gt;lan0&lt;/td&gt;
&lt;td&gt;wan0&lt;/td&gt;
&lt;td&gt;sync0&lt;/td&gt;
&lt;td&gt;192.168.100.1/24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup&lt;/td&gt;
&lt;td&gt;fw2&lt;/td&gt;
&lt;td&gt;lan0&lt;/td&gt;
&lt;td&gt;wan0&lt;/td&gt;
&lt;td&gt;sync0&lt;/td&gt;
&lt;td&gt;192.168.100.2/24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Floating VIP (LAN side example)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;192.168.10.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Floating VIP (WAN side example)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;203.0.113.10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rules of the road from the official manual:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated sync link&lt;/strong&gt; — do not piggyback state replication on the production LAN/WAN path if you can avoid it. State messages are sensitive and lossy under congestion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateful ruleset on both nodes&lt;/strong&gt; — same policy, same NAT shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HA manager hooks&lt;/strong&gt; — keepalived (or equivalent) must call the primary/backup transition script.&lt;/li&gt;
&lt;li&gt;Prefer &lt;strong&gt;FTFW&lt;/strong&gt; mode (message tracking / recover from loss and reordering) over plain NOTRACK for production.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1 — Size conntrack before you replicate it
&lt;/h2&gt;

&lt;p&gt;Replication multiplies pain if the table is already undersized.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# current usage&lt;/span&gt;
conntrack &lt;span class="nt"&gt;-C&lt;/span&gt;
&lt;span class="c"&gt;# or&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/net/netfilter/nf_conntrack_count
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/net/netfilter/nf_conntrack_max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set a realistic max and match &lt;code&gt;conntrackd&lt;/code&gt; cache limits to it. The man page guidance: &lt;strong&gt;&lt;code&gt;HashLimit&lt;/code&gt; should be about double &lt;code&gt;nf_conntrack_max&lt;/code&gt;&lt;/strong&gt;, because the daemon may retain dead entries for retransmission.&lt;/p&gt;

&lt;p&gt;Example sysctl drop-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/sysctl.d/99-conntrack.conf&lt;/span&gt;
net.netfilter.nf_conntrack_max &lt;span class="o"&gt;=&lt;/span&gt; 262144
net.netfilter.nf_conntrack_buckets &lt;span class="o"&gt;=&lt;/span&gt; 65536
&lt;span class="c"&gt;# optional: be less aggressive about mid-flow recovery edge cases on old kernels&lt;/span&gt;
&lt;span class="c"&gt;# net.netfilter.nf_conntrack_tcp_be_liberal = 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also bump the Netlink event socket early in &lt;code&gt;conntrackd.conf&lt;/code&gt; (shown below). Default ~100 KiB receive buffers are small for busy firewalls and cause event drops / expensive resyncs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — &lt;code&gt;conntrackd.conf&lt;/code&gt; (FTFW + UDP dedicated link)
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;/etc/conntrackd/conntrackd.conf&lt;/code&gt; on &lt;strong&gt;both&lt;/strong&gt; nodes. Only the local dedicated-link addresses change.&lt;/p&gt;

&lt;p&gt;This example uses &lt;strong&gt;FTFW over unicast UDP&lt;/strong&gt; on the sync NIC — a solid default when you have exactly two nodes and multicast is awkward. Multicast works too; the official example ships with multicast &lt;code&gt;225.0.0.50&lt;/code&gt; / group &lt;code&gt;3780&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  fw1
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/conntrackd/conntrackd.conf  (fw1)
&lt;/span&gt;&lt;span class="n"&gt;Sync&lt;/span&gt; {
    &lt;span class="n"&gt;Mode&lt;/span&gt; &lt;span class="n"&gt;FTFW&lt;/span&gt; {
        &lt;span class="c"&gt;# ResendQueueSize 131072
&lt;/span&gt;        &lt;span class="c"&gt;# CommitTimeout 180
&lt;/span&gt;        &lt;span class="c"&gt;# PurgeTimeout 60
&lt;/span&gt;        &lt;span class="c"&gt;# DisableExternalCache no
&lt;/span&gt;        &lt;span class="c"&gt;# StartupResync yes
&lt;/span&gt;    }

    &lt;span class="n"&gt;UDP&lt;/span&gt; {
        &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;100&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;IPv4_Destination_Address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;100&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;
        &lt;span class="n"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;3780&lt;/span&gt;
        &lt;span class="n"&gt;Interface&lt;/span&gt; &lt;span class="n"&gt;sync0&lt;/span&gt;
        &lt;span class="n"&gt;SndSocketBuffer&lt;/span&gt; &lt;span class="m"&gt;1249280&lt;/span&gt;
        &lt;span class="n"&gt;RcvSocketBuffer&lt;/span&gt; &lt;span class="m"&gt;1249280&lt;/span&gt;
        &lt;span class="n"&gt;Checksum&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;
    }
}

&lt;span class="n"&gt;General&lt;/span&gt; {
    &lt;span class="n"&gt;HashSize&lt;/span&gt; &lt;span class="m"&gt;32768&lt;/span&gt;
    &lt;span class="n"&gt;HashLimit&lt;/span&gt; &lt;span class="m"&gt;524288&lt;/span&gt;

    &lt;span class="n"&gt;LogFile&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;
    &lt;span class="n"&gt;Syslog&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;
    &lt;span class="n"&gt;LockFile&lt;/span&gt; /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;lock&lt;/span&gt;/&lt;span class="n"&gt;conntrack&lt;/span&gt;.&lt;span class="n"&gt;lock&lt;/span&gt;

    &lt;span class="n"&gt;UNIX&lt;/span&gt; {
        &lt;span class="n"&gt;Path&lt;/span&gt; /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;run&lt;/span&gt;/&lt;span class="n"&gt;conntrackd&lt;/span&gt;.&lt;span class="n"&gt;ctl&lt;/span&gt;
        &lt;span class="n"&gt;Backlog&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
    }

    &lt;span class="n"&gt;NetlinkBufferSize&lt;/span&gt; &lt;span class="m"&gt;2097152&lt;/span&gt;
    &lt;span class="n"&gt;NetlinkBufferSizeMaxGrowth&lt;/span&gt; &lt;span class="m"&gt;8388608&lt;/span&gt;
    &lt;span class="c"&gt;# NetlinkEventsReliable yes   # kernel &amp;gt;= 2.6.31; if yes, consider NetlinkOverrunResync off
&lt;/span&gt;
    &lt;span class="n"&gt;Filter&lt;/span&gt; &lt;span class="n"&gt;From&lt;/span&gt; &lt;span class="n"&gt;Userspace&lt;/span&gt; {
        &lt;span class="n"&gt;Protocol&lt;/span&gt; &lt;span class="n"&gt;Accept&lt;/span&gt; {
            &lt;span class="n"&gt;TCP&lt;/span&gt;
            &lt;span class="n"&gt;UDP&lt;/span&gt;
            &lt;span class="n"&gt;ICMP&lt;/span&gt;
        }

        &lt;span class="c"&gt;# Do NOT replicate purely local / VIP / sync-link noise.
&lt;/span&gt;        &lt;span class="c"&gt;# Only forwarded flows are worth recovering on the peer.
&lt;/span&gt;        &lt;span class="n"&gt;Address&lt;/span&gt; &lt;span class="n"&gt;Ignore&lt;/span&gt; {
            &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;127&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;
            &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;100&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;
            &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;100&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;
            &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;      &lt;span class="c"&gt;# LAN VIP
&lt;/span&gt;            &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;      &lt;span class="c"&gt;# WAN VIP
&lt;/span&gt;            &lt;span class="c"&gt;# add each node's real interface addresses too
&lt;/span&gt;            &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;11&lt;/span&gt;
            &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;12&lt;/span&gt;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  fw2
&lt;/h3&gt;

&lt;p&gt;Same file, swap the UDP addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;    &lt;span class="n"&gt;UDP&lt;/span&gt; {
        &lt;span class="n"&gt;IPv4_address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;100&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;
        &lt;span class="n"&gt;IPv4_Destination_Address&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;100&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;3780&lt;/span&gt;
        &lt;span class="n"&gt;Interface&lt;/span&gt; &lt;span class="n"&gt;sync0&lt;/span&gt;
        &lt;span class="n"&gt;SndSocketBuffer&lt;/span&gt; &lt;span class="m"&gt;1249280&lt;/span&gt;
        &lt;span class="n"&gt;RcvSocketBuffer&lt;/span&gt; &lt;span class="m"&gt;1249280&lt;/span&gt;
        &lt;span class="n"&gt;Checksum&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt;
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And flip the local addresses in &lt;code&gt;Address Ignore&lt;/code&gt; if you listed node-specific IPs separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why these knobs matter
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Why it is there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Mode FTFW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reliable-ish replication with ACK / resend queue — recovers from loss and reordering better than pure NOTRACK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UDP&lt;/code&gt; on &lt;code&gt;sync0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Dedicated path; unicast is simple for two nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large &lt;code&gt;Snd/RcvSocketBuffer&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Avoid overrun on the sync sockets under bursty state churn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large &lt;code&gt;NetlinkBufferSize*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Avoid dropping kernel→userspace conntrack events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HashLimit ≈ 2 × nf_conntrack_max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Room for live + retransmit cache objects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Address Ignore&lt;/code&gt; for locals/VIPs&lt;/td&gt;
&lt;td&gt;Official guidance: local traffic is not worth replicating; forwarded flows are&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External cache left &lt;strong&gt;enabled&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Safer first deploy: backup keeps foreign state in userspace until promote commits it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;DisableExternalCache yes&lt;/code&gt; injects peer state straight into the kernel table on the backup. That skips the commit step and saves userspace memory, but burns kernel conntrack slots and CPU on the idle node. The man page still steers first-time installs toward the fail-over scripts instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Transition script (the important part)
&lt;/h2&gt;

&lt;p&gt;Ship the upstream &lt;code&gt;primary-backup.sh&lt;/code&gt; logic. On Debian/Ubuntu it is often packaged under &lt;code&gt;/usr/share/doc/conntrackd/examples/sync/&lt;/code&gt; (path varies by release). Install it as &lt;code&gt;/etc/conntrackd/primary-backup.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# Adapted from conntrack-tools doc/sync/primary-backup.sh&lt;/span&gt;
&lt;span class="c"&gt;# (C) 2006-2011 Pablo Neira Ayuso — GPL-2.0-or-later&lt;/span&gt;

&lt;span class="nv"&gt;CONNTRACKD_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/sbin/conntrackd
&lt;span class="nv"&gt;CONNTRACKD_LOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lock/conntrack.lock
&lt;span class="nv"&gt;CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/conntrackd/conntrackd.conf

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;primary&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;# Inject peer-replicated flows into the local kernel table&lt;/span&gt;
    &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
    &lt;span class="c"&gt;# Flush userspace caches, then rebuild internal cache from kernel&lt;/span&gt;
    &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;
    &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt;
    &lt;span class="c"&gt;# Tell backups what we now own&lt;/span&gt;
    &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-B&lt;/span&gt;
    &lt;span class="p"&gt;;;&lt;/span&gt;
  backup&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;# Ensure daemon is alive&lt;/span&gt;
    &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
        &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_LOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_LOCK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
    &lt;span class="k"&gt;fi&lt;/span&gt;
    &lt;span class="c"&gt;# Shorten timers to age out zombies after demotion&lt;/span&gt;
    &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;
    &lt;span class="c"&gt;# Request resync from the current primary (FTFW/NOTRACK)&lt;/span&gt;
    &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt;
    &lt;span class="p"&gt;;;&lt;/span&gt;
  fault&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;$CONNTRACKD_BIN&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONNTRACKD_CONFIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;
    &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Usage: &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt; {primary|backup|fault}"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
    &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;

&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 primary-backup.sh /etc/conntrackd/primary-backup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What each client flag does
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-c&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Commit&lt;/strong&gt; external cache → kernel conntrack table (promote path)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flush internal/external userspace caches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-R&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resync internal cache from the kernel table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bulk-send owned state to peers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-t&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reset/shorten in-kernel timers (&lt;code&gt;PurgeTimeout&lt;/code&gt;) after demotion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Request resync from the other node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Statistics (also used as a liveness probe)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;-i&lt;/code&gt; / &lt;code&gt;-e&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Dump &lt;strong&gt;internal&lt;/strong&gt; (local) / &lt;strong&gt;external&lt;/strong&gt; (foreign) cache&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If promote works, logs should show something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[notice] committing external cache
[notice] Committed 1545 new entries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4 — keepalived notify hooks
&lt;/h2&gt;

&lt;p&gt;Minimal keepalived integration (pair with your existing VRRP VIP config):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/keepalived/keepalived.conf  (sketch — merge with your real instance)
&lt;/span&gt;&lt;span class="n"&gt;global_defs&lt;/span&gt; {
    &lt;span class="n"&gt;router_id&lt;/span&gt; &lt;span class="n"&gt;fw_pair&lt;/span&gt;
    &lt;span class="n"&gt;script_user&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;
    &lt;span class="n"&gt;enable_script_security&lt;/span&gt;
}

&lt;span class="n"&gt;vrrp_sync_group&lt;/span&gt; &lt;span class="n"&gt;G1&lt;/span&gt; {
    &lt;span class="n"&gt;group&lt;/span&gt; {
        &lt;span class="n"&gt;VI_LAN&lt;/span&gt;
        &lt;span class="n"&gt;VI_WAN&lt;/span&gt;
    }
    &lt;span class="n"&gt;notify_master&lt;/span&gt; &lt;span class="s2"&gt;"/etc/conntrackd/primary-backup.sh primary"&lt;/span&gt;
    &lt;span class="n"&gt;notify_backup&lt;/span&gt; &lt;span class="s2"&gt;"/etc/conntrackd/primary-backup.sh backup"&lt;/span&gt;
    &lt;span class="n"&gt;notify_fault&lt;/span&gt;  &lt;span class="s2"&gt;"/etc/conntrackd/primary-backup.sh fault"&lt;/span&gt;
}

&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_LAN&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;          &lt;span class="c"&gt;# both start BACKUP if you use nopreempt patterns
&lt;/span&gt;    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;lan0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;150&lt;/span&gt;          &lt;span class="c"&gt;# fw2 uses a lower priority, e.g. 100
&lt;/span&gt;    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="c"&gt;# authentication { ... }  # note: VRRPv2 PASS is weak; prefer unicast + ACLs
&lt;/span&gt;    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt;
    }
    &lt;span class="c"&gt;# track_interface / track_script as needed
&lt;/span&gt;}

&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_WAN&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;
    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;wan0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;52&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;150&lt;/span&gt;
    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;32&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a &lt;strong&gt;&lt;code&gt;vrrp_sync_group&lt;/code&gt;&lt;/strong&gt; so LAN and WAN VIPs flip together and the conntrack transition runs once for the group. The upstream conntrack-tools example does exactly that.&lt;/p&gt;

&lt;p&gt;If you already run keepalived for gateway VIP HA, you are not replacing that design — you are finishing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Firewall the sync path (and only the sync path)
&lt;/h2&gt;

&lt;p&gt;Allow state replication on the dedicated link. Examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# nftables — dedicated sync NIC only&lt;/span&gt;
nft add table inet raw
nft add chain inet raw conntrackd_sync &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;type &lt;/span&gt;filter hook input priority &lt;span class="nt"&gt;-300&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
nft add rule inet raw conntrackd_sync iifname &lt;span class="s2"&gt;"sync0"&lt;/span&gt; udp dport 3780 accept
nft add rule inet raw conntrackd_sync oifname &lt;span class="s2"&gt;"sync0"&lt;/span&gt; udp dport 3780 accept
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use multicast instead of UDP unicast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# allow the example group traffic on the sync NIC&lt;/span&gt;
iptables &lt;span class="nt"&gt;-I&lt;/span&gt; INPUT  &lt;span class="nt"&gt;-i&lt;/span&gt; sync0 &lt;span class="nt"&gt;-d&lt;/span&gt; 225.0.0.50 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-o&lt;/span&gt; sync0 &lt;span class="nt"&gt;-d&lt;/span&gt; 225.0.0.50 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; expose sync traffic on untrusted interfaces. Anyone who can inject or observe conntrack replicas learns a lot about your live sessions.&lt;/p&gt;

&lt;p&gt;Also make sure your &lt;strong&gt;forward&lt;/strong&gt; policy is truly stateful on both nodes (established/related accept, invalid drop, identical NAT). conntrackd cannot fix a mismatched ruleset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Enable services
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; conntrackd.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; keepalived.service

systemctl status conntrackd &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
systemctl status keepalived &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modern conntrackd builds support &lt;code&gt;Type=notify&lt;/code&gt; systemd units and watchdog integration (&lt;code&gt;Systemd yes&lt;/code&gt; in config when compiled with support). Prefer the distro unit over hand-rolled &lt;code&gt;conntrackd -d&lt;/code&gt; in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — Verify replication before you break anything
&lt;/h2&gt;

&lt;h3&gt;
  
  
  On the active node
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# generate a long-lived flow through the VIP (SSH, curl --http1.1 keep-alive, iperf3, etc.)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;head
sudo &lt;/span&gt;conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  On the backup node
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-e&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Healthy pair shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active &lt;strong&gt;internal&lt;/strong&gt; cache count ≈ Backup &lt;strong&gt;external&lt;/strong&gt; cache count&lt;/li&gt;
&lt;li&gt;Backup external dump shows the same 5-tuple / state you care about (ESTABLISHED SSH, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;conntrackd -s network&lt;/code&gt; (or general stats) is not racking up send/receive errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example of what you want on the backup external cache (shape from the official test case):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tcp 6 ESTABLISHED src=192.168.0.3 dst=192.168.0.100 sport=51356 dport=22 \
  src=192.168.0.100 dst=192.168.1.3 sport=22 dport=51356 [ASSURED]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful ops commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-s&lt;/span&gt; cache
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-s&lt;/span&gt; network
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-s&lt;/span&gt; runtime
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; conntrackd &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100 /var/log/conntrackd.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 8 — Controlled failover test
&lt;/h2&gt;

&lt;p&gt;Do this on purpose once, during a maintenance window.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start a traffic generator through the VIP (SSH session that prints a clock, &lt;code&gt;iperf3 -t 600&lt;/code&gt;, a keep-alive API client).&lt;/li&gt;
&lt;li&gt;Confirm the flow is on the active internal cache and the backup external cache.&lt;/li&gt;
&lt;li&gt;Force demotion of the active node, for example:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on current master — pick ONE deliberate failure mode&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop keepalived
&lt;span class="c"&gt;# or: sudo ip link set lan0 down&lt;/span&gt;
&lt;span class="c"&gt;# or: sudo kill -STOP $(pidof keepalived)   # only in a lab&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Watch the backup:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# VIP ownership&lt;/span&gt;
ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr show lan0
ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr show wan0

&lt;span class="c"&gt;# transition should commit external cache&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; keepalived &lt;span class="nt"&gt;-u&lt;/span&gt; conntrackd &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 50
&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; commit /var/log/conntrackd.log | &lt;span class="nb"&gt;tail
sudo &lt;/span&gt;conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'dport=22|dport=443'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Confirm the client session survived without a reconnect.&lt;/li&gt;
&lt;li&gt;Restore the original node and decide whether you want preemption (&lt;code&gt;nopreempt&lt;/code&gt; vs priority takeback). Either way, the demoted node should run the &lt;strong&gt;backup&lt;/strong&gt; path (&lt;code&gt;-t&lt;/code&gt; + &lt;code&gt;-n&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Pass / fail criteria
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Pass&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VIP moves&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Committed N new entries&lt;/code&gt; (N &amp;gt; 0 for live flows)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-lived TCP still transfers data&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No flood of INVALID drops for that flow&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After settle, new primary internal ≈ new backup external&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the VIP moves but sessions die, you almost always have one of: commit script not hooked, external cache empty (sync broken), NAT/filter policy mismatch, or Address Ignore filtering the wrong prefixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional hardening and tuning
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Expectation sync (FTP/SIP helpers)
&lt;/h3&gt;

&lt;p&gt;If you still run helper-dependent protocols:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Sync&lt;/span&gt; {
    &lt;span class="n"&gt;Mode&lt;/span&gt; &lt;span class="n"&gt;FTFW&lt;/span&gt; {
        &lt;span class="c"&gt;# ...
&lt;/span&gt;    }
    &lt;span class="c"&gt;# ...
&lt;/span&gt;    &lt;span class="n"&gt;Options&lt;/span&gt; {
        &lt;span class="n"&gt;ExpectationSync&lt;/span&gt; &lt;span class="n"&gt;On&lt;/span&gt;
        &lt;span class="c"&gt;# or a list: ftp, sip, ...
&lt;/span&gt;        &lt;span class="c"&gt;# TCPWindowTracking Off
&lt;/span&gt;    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Needs a modern enough kernel (expectation sync features landed in the 3.x era; check your man page). Many sites are happier reducing helper use than replicating expectations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Direct kernel injection on backup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Mode&lt;/span&gt; &lt;span class="n"&gt;FTFW&lt;/span&gt; {
    &lt;span class="n"&gt;DisableExternalCache&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Faster failover (no &lt;code&gt;-c&lt;/code&gt; commit bulk), higher steady-state cost on the backup. Only switch after the scripted path is proven.&lt;/p&gt;

&lt;h3&gt;
  
  
  Startup catch-up
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Mode&lt;/span&gt; &lt;span class="n"&gt;FTFW&lt;/span&gt; {
    &lt;span class="n"&gt;StartupResync&lt;/span&gt; &lt;span class="n"&gt;yes&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful when a node boots while its peer has been carrying production state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the peer honest
&lt;/h3&gt;

&lt;p&gt;Add a simple timer that alerts if external cache is empty while the node is backup and the peer is up — empty external cache during load means you will fail open into session death.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/sbin/check-conntrack-sync.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="c"&gt;# only meaningful on backup nodes — detect via VIP absence&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;ip &lt;span class="nt"&gt;-4&lt;/span&gt; addr show lan0 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'192.168.10.1/'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0  &lt;span class="c"&gt;# we are master; internal cache is the source of truth&lt;/span&gt;
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nv"&gt;ext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;conntrackd &lt;span class="nt"&gt;-e&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# crude: expect some foreign state during business hours&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ext&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 1 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;logger &lt;span class="nt"&gt;-t&lt;/span&gt; conntrack-sync &lt;span class="s2"&gt;"WARNING: external cache empty while backup"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it with a systemd timer if you want continuous signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does &lt;strong&gt;not&lt;/strong&gt; solve
&lt;/h2&gt;

&lt;p&gt;Be explicit so you do not over-promise HA:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stateless service HA&lt;/strong&gt; for apps that bind only to the VIP without shared backend state — different problem (and often keepalived + app-level session affinity).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LVS/IPVS full load-balancer farms&lt;/strong&gt; — conntrackd is about Netfilter flow tables, not scheduler persistence tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asymmetric multi-path active/active&lt;/strong&gt; where request and reply legs hit different firewalls unpredictably — the official manual warns this fights stateful design; prefer symmetric paths or accept weaker guarantees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iptables/nft modules that keep private side state&lt;/strong&gt; (&lt;code&gt;recent&lt;/code&gt;, &lt;code&gt;connbytes&lt;/code&gt;, &lt;code&gt;quota&lt;/code&gt;, …) — those counters are outside conntrack, so takeover may still mis-handle flows depending on them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broken or divergent NAT/filter policy&lt;/strong&gt; between nodes — replicated conntrack cannot invent SNAT mappings your backup would never have created the same way if the rules differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security of the sync channel&lt;/strong&gt; — treat the dedicated link like cluster interconnect: isolated VLAN/cable, tight input rules, no general routed path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Minimal operator runbook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Is the daemon up?&lt;/span&gt;
systemctl is-active conntrackd keepalived

&lt;span class="c"&gt;# Who is master?&lt;/span&gt;
ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'192.168.10.1|203.0.113.10'&lt;/span&gt;

&lt;span class="c"&gt;# Are we replicating?&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-i&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-e&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# Force a bulk push after maintenance (on primary)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-B&lt;/span&gt;

&lt;span class="c"&gt;# Force kernel resync into internal cache&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrackd &lt;span class="nt"&gt;-R&lt;/span&gt;

&lt;span class="c"&gt;# Watch live conntrack events (noisy — lab/debug)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;conntrack &lt;span class="nt"&gt;-E&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;conntrack-tools user manual — state sync modes, caches, keepalived integration: &lt;a href="https://conntrack-tools.netfilter.org/manual.html" rel="noopener noreferrer"&gt;https://conntrack-tools.netfilter.org/manual.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Official HA test case (why established sessions die without sync): &lt;a href="https://conntrack-tools.netfilter.org/testcase.html" rel="noopener noreferrer"&gt;https://conntrack-tools.netfilter.org/testcase.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;conntrackd(8)&lt;/code&gt; and &lt;code&gt;conntrackd.conf(5)&lt;/code&gt; (Debian manpages): &lt;a href="https://manpages.debian.org/bookworm/conntrackd/conntrackd.8.en.html" rel="noopener noreferrer"&gt;https://manpages.debian.org/bookworm/conntrackd/conntrackd.8.en.html&lt;/a&gt;, &lt;a href="https://manpages.debian.org/bookworm/conntrackd/conntrackd.conf.5.en.html" rel="noopener noreferrer"&gt;https://manpages.debian.org/bookworm/conntrackd/conntrackd.conf.5.en.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Example &lt;code&gt;primary-backup.sh&lt;/code&gt;, FTFW &lt;code&gt;conntrackd.conf&lt;/code&gt;, and keepalived notify stubs from conntrack-tools &lt;code&gt;doc/sync/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;keepalived project docs for VRRP instances and notify scripts: &lt;a href="https://www.keepalived.org" rel="noopener noreferrer"&gt;https://www.keepalived.org&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;keepalived moves the address. &lt;strong&gt;conntrackd moves the memory of every flow that address was mid-way through.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a two-node stateful firewall or NAT gateway:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Give the pair a dedicated sync link.&lt;/li&gt;
&lt;li&gt;Run FTFW replication with sane buffer and hash limits.&lt;/li&gt;
&lt;li&gt;Hook &lt;code&gt;primary-backup.sh&lt;/code&gt; into keepalived notify events.&lt;/li&gt;
&lt;li&gt;Prove it with a live session and a deliberate cutover — not a hope and a ping.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once that commit path is boring and predictable, VIP failover stops feeling like a soft reboot of every client connection.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Single-Host Gateway Outages: Practical keepalived VRRP Floating IPs on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Sun, 09 Aug 2026 05:02:35 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-single-host-gateway-outages-practical-keepalived-vrrp-floating-ips-on-linux-3k6k</link>
      <guid>https://dev.to/lyraalishaikh/stop-single-host-gateway-outages-practical-keepalived-vrrp-floating-ips-on-linux-3k6k</guid>
      <description>&lt;h1&gt;
  
  
  Stop Single-Host Gateway Outages: Practical keepalived VRRP Floating IPs on Linux
&lt;/h1&gt;

&lt;p&gt;A service IP that lives on only one box is a single point of failure.&lt;/p&gt;

&lt;p&gt;When that host reboots, freezes, or loses its uplink, every client that hard-coded &lt;code&gt;192.0.2.10&lt;/code&gt; goes dark — even if an identical standby is sitting next to it with a warm cache and healthy disks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;keepalived&lt;/strong&gt; implements the Virtual Router Redundancy Protocol (VRRP) on Linux so two or more hosts can share a &lt;strong&gt;floating virtual IP (VIP)&lt;/strong&gt;. One node owns the address as MASTER; the others stay BACKUP and take over when advertisements stop or a tracked check fails.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;host-level IP failover&lt;/strong&gt;, not NIC bonding on a single machine and not full application clustering. Different failure domain, different tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get (and what you do not)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You get:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A stable service address clients can keep forever&lt;/li&gt;
&lt;li&gt;Automatic VIP migration when the current master dies or is demoted&lt;/li&gt;
&lt;li&gt;Optional health coupling (&lt;code&gt;vrrp_script&lt;/code&gt;, &lt;code&gt;vrrp_track_process&lt;/code&gt;, &lt;code&gt;track_interface&lt;/code&gt;) so “host up but nginx dead” still fails over&lt;/li&gt;
&lt;li&gt;Gratuitous ARP / unsolicited Neighbor Advertisements so L2 neighbors relearn the VIP quickly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;This article does not cover:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Linux Ethernet bonding / LACP (link aggregation on one host)&lt;/li&gt;
&lt;li&gt;Device Mapper multipath (storage path HA)&lt;/li&gt;
&lt;li&gt;Full LVS/IPVS load-balancer farms (keepalived can do that too; different problem)&lt;/li&gt;
&lt;li&gt;Pacemaker/Corosync multi-resource clusters&lt;/li&gt;
&lt;li&gt;DNS-based failover (TTLs and client caches behave differently)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need two cables on one server, bond first. If you need one IP that survives a whole server dying, keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  How VRRP actually works (short version)
&lt;/h2&gt;

&lt;p&gt;VRRP (RFC 5798 for version 3; RFC 3768 for the older v2 story) elects one &lt;strong&gt;Master&lt;/strong&gt; per virtual router ID (VRID) on a LAN:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Routers periodically send advertisements (default destination for IPv4 multicast is &lt;code&gt;224.0.0.18&lt;/code&gt;, protocol 112).&lt;/li&gt;
&lt;li&gt;The highest &lt;strong&gt;priority&lt;/strong&gt; healthy router becomes Master and owns the virtual IPvX address(es).&lt;/li&gt;
&lt;li&gt;Backups listen. If advertisements stop for long enough, a backup promotes itself.&lt;/li&gt;
&lt;li&gt;On becoming Master, the new owner sends gratuitous ARP (IPv4) or unsolicited NA (IPv6) so switches and hosts update their neighbor caches.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;keepalived is the common Linux implementation: it programs VIPs via netlink, runs optional track scripts/processes, and can use a VMAC (&lt;code&gt;use_vmac&lt;/code&gt;) so the virtual router MAC stays stable across failovers.&lt;/p&gt;

&lt;p&gt;Important keepalived note from the man page: VRRPv2 authentication (&lt;code&gt;auth_type PASS&lt;/code&gt; / &lt;code&gt;AH&lt;/code&gt;) was removed from the VRRPv2 specification by RFC 3768. PASS sends a cleartext password on the wire. Treat it as a &lt;strong&gt;misconfiguration guard&lt;/strong&gt;, not real security. Prefer network isolation (dedicated VLAN, firewall) for VRRP traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lab topology
&lt;/h2&gt;

&lt;p&gt;Two Debian/Ubuntu-style hosts on the same L2 segment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Real IP (management)&lt;/th&gt;
&lt;th&gt;Role intent&lt;/th&gt;
&lt;th&gt;VRRP priority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gw-a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.0.2.11/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preferred master&lt;/td&gt;
&lt;td&gt;&lt;code&gt;150&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gw-b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.0.2.12/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Standby&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VIP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.0.2.10/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Floating service IP&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Clients (or upstream routes) use &lt;strong&gt;&lt;code&gt;192.0.2.10&lt;/code&gt;&lt;/strong&gt; only. Never point production traffic at the real host IPs if you want transparent failover.&lt;/p&gt;

&lt;p&gt;Replace interface names (&lt;code&gt;eth0&lt;/code&gt; / &lt;code&gt;enp1s0&lt;/code&gt; / &lt;code&gt;bond0&lt;/code&gt;) with whatever &lt;code&gt;ip -br link&lt;/code&gt; shows. If the uplink is a bond, put VRRP on &lt;code&gt;bond0&lt;/code&gt; — bonding and VRRP compose cleanly and cover different failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and enable
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; keepalived

&lt;span class="c"&gt;# Fedora/RHEL family&lt;/span&gt;
&lt;span class="c"&gt;# sudo dnf install -y keepalived&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;keepalived.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config path: &lt;code&gt;/etc/keepalived/keepalived.conf&lt;/code&gt;&lt;br&gt;&lt;br&gt;
Logs: &lt;code&gt;journalctl -u keepalived -f&lt;/code&gt;&lt;br&gt;&lt;br&gt;
Runtime state: &lt;code&gt;ip -br addr&lt;/code&gt;, &lt;code&gt;ip -d link&lt;/code&gt;, and keepalived’s own logs on state transitions.&lt;/p&gt;
&lt;h2&gt;
  
  
  Baseline: two-node VIP with intentional preemption
&lt;/h2&gt;

&lt;p&gt;Prefer the healthier, higher-priority node when it is online. Both nodes share the same &lt;code&gt;virtual_router_id&lt;/code&gt; and VIP; only &lt;code&gt;priority&lt;/code&gt; (and optional tracks) differ.&lt;/p&gt;
&lt;h3&gt;
  
  
  Host A (&lt;code&gt;gw-a&lt;/code&gt;) — higher priority
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/keepalived/keepalived.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;global_defs&lt;/span&gt; {
    &lt;span class="n"&gt;router_id&lt;/span&gt; &lt;span class="n"&gt;gw&lt;/span&gt;-&lt;span class="n"&gt;a&lt;/span&gt;
    &lt;span class="c"&gt;# Prefer a non-root script user when you add track/notify scripts later
&lt;/span&gt;    &lt;span class="n"&gt;script_user&lt;/span&gt; &lt;span class="n"&gt;keepalived_script&lt;/span&gt;
    &lt;span class="n"&gt;enable_script_security&lt;/span&gt;

    &lt;span class="c"&gt;# GARP tuning after becoming MASTER (modern switches rarely need a storm)
&lt;/span&gt;    &lt;span class="n"&gt;vrrp_garp_master_refresh&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="n"&gt;vrrp_garp_master_refresh_repeat&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
}

&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_GATEWAY&lt;/span&gt; {
    &lt;span class="c"&gt;# Initial state before advertisements settle. Priority still wins the election.
&lt;/span&gt;    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;
    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;          &lt;span class="c"&gt;# 1-255, unique per VIP set on this LAN
&lt;/span&gt;    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;150&lt;/span&gt;                  &lt;span class="c"&gt;# higher = preferred master
&lt;/span&gt;    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;                  &lt;span class="c"&gt;# seconds (fractional values allowed)
&lt;/span&gt;
    &lt;span class="c"&gt;# VRRPv3 is the RFC 5798 path; IPv6 instances use v3 anyway
&lt;/span&gt;    &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

    &lt;span class="c"&gt;# Optional: keep VIP MAC stable via macvlan VMAC (good with picky switches)
&lt;/span&gt;    &lt;span class="c"&gt;# use_vmac vrrp51
&lt;/span&gt;    &lt;span class="c"&gt;# vmac_xmit_base
&lt;/span&gt;
    &lt;span class="n"&gt;authentication&lt;/span&gt; {
        &lt;span class="n"&gt;auth_type&lt;/span&gt; &lt;span class="n"&gt;PASS&lt;/span&gt;
        &lt;span class="n"&gt;auth_pass&lt;/span&gt; &lt;span class="n"&gt;chg&lt;/span&gt;-&lt;span class="n"&gt;me&lt;/span&gt;-&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;        &lt;span class="c"&gt;# first 8 chars matter; same on all peers
&lt;/span&gt;    }

    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt; &lt;span class="n"&gt;dev&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    }

    &lt;span class="c"&gt;# Demote if the uplink itself disappears
&lt;/span&gt;    &lt;span class="n"&gt;track_interface&lt;/span&gt; {
        &lt;span class="n"&gt;eth0&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Host B (&lt;code&gt;gw-b&lt;/code&gt;) — lower priority
&lt;/h3&gt;

&lt;p&gt;Same file with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;global_defs&lt;/span&gt; {
    &lt;span class="n"&gt;router_id&lt;/span&gt; &lt;span class="n"&gt;gw&lt;/span&gt;-&lt;span class="n"&gt;b&lt;/span&gt;
    &lt;span class="n"&gt;script_user&lt;/span&gt; &lt;span class="n"&gt;keepalived_script&lt;/span&gt;
    &lt;span class="n"&gt;enable_script_security&lt;/span&gt;
    &lt;span class="n"&gt;vrrp_garp_master_refresh&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="n"&gt;vrrp_garp_master_refresh_repeat&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
}

&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_GATEWAY&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;
    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

    &lt;span class="n"&gt;authentication&lt;/span&gt; {
        &lt;span class="n"&gt;auth_type&lt;/span&gt; &lt;span class="n"&gt;PASS&lt;/span&gt;
        &lt;span class="n"&gt;auth_pass&lt;/span&gt; &lt;span class="n"&gt;chg&lt;/span&gt;-&lt;span class="n"&gt;me&lt;/span&gt;-&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;
    }

    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt; &lt;span class="n"&gt;dev&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    }

    &lt;span class="n"&gt;track_interface&lt;/span&gt; {
        &lt;span class="n"&gt;eth0&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the script user if your package did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;useradd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /usr/sbin/nologin keepalived_script 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;keepalived &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/keepalived/keepalived.conf   &lt;span class="c"&gt;# config test&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart keepalived.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nt"&gt;--no-pager&lt;/span&gt; &lt;span class="nt"&gt;--full&lt;/span&gt; status keepalived.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Verify ownership
&lt;/h3&gt;

&lt;p&gt;On both hosts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr show eth0
ip addr show eth0 | &lt;span class="nb"&gt;grep &lt;/span&gt;192.0.2.10 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no VIP here"&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; keepalived &lt;span class="nt"&gt;-n&lt;/span&gt; 50 &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exactly &lt;strong&gt;one&lt;/strong&gt; host shows &lt;code&gt;192.0.2.10/24&lt;/code&gt; (secondary address)&lt;/li&gt;
&lt;li&gt;That host’s journal says transition to &lt;strong&gt;MASTER&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The other stays &lt;strong&gt;BACKUP&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;From a third machine: &lt;code&gt;ping -c3 192.0.2.10&lt;/code&gt; succeeds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick ownership check you can script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;VIP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.0.2.10
&lt;span class="k"&gt;if &lt;/span&gt;ip &lt;span class="nt"&gt;-4&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; addr show | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $4}'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VIP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"MASTER holds &lt;/span&gt;&lt;span class="nv"&gt;$VIP&lt;/span&gt;&lt;span class="s2"&gt; on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BACKUP without &lt;/span&gt;&lt;span class="nv"&gt;$VIP&lt;/span&gt;&lt;span class="s2"&gt; on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Make failover mean “service healthy,” not just “kernel still boots”
&lt;/h2&gt;

&lt;p&gt;A VIP on a host whose reverse proxy is dead is a polished outage. Couple VRRP priority to real health.&lt;/p&gt;

&lt;h3&gt;
  
  
  Track a process (simple, low overhead)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;vrrp_track_process&lt;/span&gt; &lt;span class="n"&gt;track_nginx&lt;/span&gt; {
    &lt;span class="n"&gt;process&lt;/span&gt; &lt;span class="n"&gt;nginx&lt;/span&gt;          &lt;span class="c"&gt;# exact match semantics (not a loose pgrep regex)
&lt;/span&gt;    &lt;span class="c"&gt;# weight omitted =&amp;gt; instance goes to FAULT when process is gone after delay
&lt;/span&gt;    &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
}

&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_GATEWAY&lt;/span&gt; {
    &lt;span class="c"&gt;# ...same as before...
&lt;/span&gt;    &lt;span class="n"&gt;track_process&lt;/span&gt; {
        &lt;span class="n"&gt;track_nginx&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Track a script (port/HTTP check)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;vrrp_script&lt;/span&gt; &lt;span class="n"&gt;chk_https&lt;/span&gt; {
    &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="s2"&gt;"/usr/lib/keepalived/check_https.sh"&lt;/span&gt;
    &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;weight&lt;/span&gt; -&lt;span class="m"&gt;30&lt;/span&gt;          &lt;span class="c"&gt;# subtract from priority while failing; 0 weight =&amp;gt; FAULT
&lt;/span&gt;    &lt;span class="n"&gt;fall&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;              &lt;span class="c"&gt;# failures before down
&lt;/span&gt;    &lt;span class="n"&gt;rise&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;              &lt;span class="c"&gt;# successes before up
&lt;/span&gt;}

&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_GATEWAY&lt;/span&gt; {
    &lt;span class="c"&gt;# ...
&lt;/span&gt;    &lt;span class="n"&gt;track_script&lt;/span&gt; {
        &lt;span class="n"&gt;chk_https&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example check script (mode &lt;code&gt;0755&lt;/code&gt;, owned by root, not world-writable — &lt;code&gt;enable_script_security&lt;/code&gt; cares):&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/usr/lib/keepalived/check_https.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Exit 0 = healthy, non-zero = unhealthy&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 1 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"http://127.0.0.1:80/healthz"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; root &lt;span class="nt"&gt;-g&lt;/span&gt; root &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 /tmp/check_https.sh /usr/lib/keepalived/check_https.sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;keepalived &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/keepalived/keepalived.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload keepalived.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Weight semantics (from keepalived.conf(5)):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;weight 0&lt;/code&gt; (default for scripts): monitoring failure drives the instance to &lt;strong&gt;FAULT&lt;/strong&gt; after &lt;code&gt;fall&lt;/code&gt; failures&lt;/li&gt;
&lt;li&gt;Non-zero weight: adjust effective priority up/down so another node can win without a hard FAULT&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use FAULT when the node must not hold the VIP at all. Use negative weight when you want soft preference (“prefer the node with a warm cache, but either can serve”).&lt;/p&gt;

&lt;h2&gt;
  
  
  nopreempt: stop flapping when the old master returns
&lt;/h2&gt;

&lt;p&gt;By default, a recovering higher-priority node snatches the VIP back. That is correct for “always prefer gw-a,” and painful for long-lived TCP sessions if gw-a reboots every patch night.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;nopreempt&lt;/code&gt; keeps the current master until &lt;em&gt;it&lt;/em&gt; fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_GATEWAY&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;        &lt;span class="c"&gt;# REQUIRED: nopreempt is ignored if initial state is MASTER
&lt;/span&gt;    &lt;span class="n"&gt;nopreempt&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;150&lt;/span&gt;
    &lt;span class="c"&gt;# ...
&lt;/span&gt;}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply on &lt;strong&gt;both&lt;/strong&gt; nodes (with different priorities). Document the tradeoff:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Preempt (default)&lt;/td&gt;
&lt;td&gt;Highest healthy priority always owns VIP&lt;/td&gt;
&lt;td&gt;Deterministic “primary DC node”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nopreempt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Winner stays until it fails&lt;/td&gt;
&lt;td&gt;Fewer failbacks, calmer TCP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can also delay preemption with &lt;code&gt;preempt_delay&lt;/code&gt; when you want eventual return to the preferred node without an instant bounce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unicast peers when multicast is blocked
&lt;/h2&gt;

&lt;p&gt;Cloud security groups, some Wi-Fi AP isolation modes, and locked-down switches break &lt;code&gt;224.0.0.18&lt;/code&gt;. keepalived can speak VRRP over unicast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_GATEWAY&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;
    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;150&lt;/span&gt;
    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

    &lt;span class="n"&gt;unicast_src_ip&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;.&lt;span class="m"&gt;11&lt;/span&gt;
    &lt;span class="n"&gt;unicast_peer&lt;/span&gt; {
        &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;.&lt;span class="m"&gt;12&lt;/span&gt;
    }

    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;2&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;gw-b&lt;/code&gt;, swap &lt;code&gt;unicast_src_ip&lt;/code&gt; / peer addresses. Man page warning: unicast mode without peers is invalid — configure real peers explicitly.&lt;/p&gt;

&lt;p&gt;If you combine &lt;code&gt;use_vmac&lt;/code&gt; with unicast, set &lt;code&gt;vmac_xmit_base&lt;/code&gt; as documented so advertisements leave the underlying interface correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firewall notes
&lt;/h2&gt;

&lt;p&gt;Allow VRRP between peers on the VRRP interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# nftables sketch — adjust interface and policy to your base table&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft add rule inet filter input iifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; ip protocol 112 accept
&lt;span class="nb"&gt;sudo &lt;/span&gt;nft add rule inet filter input iifname &lt;span class="s2"&gt;"eth0"&lt;/span&gt; ip daddr 224.0.0.18 accept
&lt;span class="c"&gt;# unicast mode: allow protocol 112 between peer unicast addresses instead&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also permit whatever your track scripts probe (&lt;code&gt;localhost&lt;/code&gt; health checks usually need nothing extra).&lt;/p&gt;

&lt;p&gt;If you use &lt;code&gt;use_vmac&lt;/code&gt; or &lt;code&gt;no_accept&lt;/code&gt;, modern keepalived prefers &lt;strong&gt;nftables&lt;/strong&gt; helpers to manage its small firewall table — do not randomly flush all nft tables on those hosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controlled failover test
&lt;/h2&gt;

&lt;p&gt;Do this once before you trust the VIP in DNS or upstream static routes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;From a client: &lt;code&gt;ping -i 0.2 192.0.2.10&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Confirm VIP on preferred node: &lt;code&gt;ip addr show eth0 | grep 192.0.2.10&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Stop keepalived on the master: &lt;code&gt;sudo systemctl stop keepalived&lt;/code&gt;
(or pull its cable / kill nginx if you track the process)&lt;/li&gt;
&lt;li&gt;Watch ping: a small blip is normal; multi-second black holes are not&lt;/li&gt;
&lt;li&gt;On the standby: VIP should appear; journal should show MASTER&lt;/li&gt;
&lt;li&gt;Start keepalived on the original node again&lt;/li&gt;
&lt;li&gt;Confirm preemption vs &lt;code&gt;nopreempt&lt;/code&gt; matches your design&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Optional packet view (on systems with tcpdump):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;tcpdump &lt;span class="nt"&gt;-ni&lt;/span&gt; eth0 &lt;span class="s1"&gt;'ip proto 112 or host 224.0.0.18'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see regular advertisements from the master and a burst of gratuitous ARP when ownership changes (&lt;code&gt;tcpdump -ni eth0 arp&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Duplicate &lt;code&gt;virtual_router_id&lt;/code&gt; for unrelated VIPs on the same LAN.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
VRIDs must be unique per virtual router on that broadcast domain. Collisions produce split-brain weirdness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VIP still configured statically in netplan/networkd/NM.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If both keepalived and your network manager permanently own &lt;code&gt;192.0.2.10&lt;/code&gt;, failover becomes a fight. Let keepalived add/remove the address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication as security theater.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;auth_type PASS&lt;/code&gt; is cleartext and non-compliant with modern VRRPv2 expectations. Isolate VRRP; do not rely on the password against a hostile L2 neighbor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scripts writable by non-root.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
With &lt;code&gt;enable_script_security&lt;/code&gt;, keepalived refuses unsafe root scripts. Good. Fix ownership/mode instead of disabling the guard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Initial &lt;code&gt;state MASTER&lt;/code&gt; + &lt;code&gt;nopreempt&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The man page is explicit: for &lt;code&gt;nopreempt&lt;/code&gt; to work, initial state must not be MASTER. Use &lt;code&gt;BACKUP&lt;/code&gt; on all nodes and let priority decide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting track failures are sticky without &lt;code&gt;rise&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Tune &lt;code&gt;fall&lt;/code&gt;/&lt;code&gt;rise&lt;/code&gt; so a single slow health check does not thrash the VIP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assuming VRRP replaces backups.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Failover preserves reachability. It does not replicate disk state. Pair with real data replication for anything stateful.&lt;/p&gt;
&lt;h2&gt;
  
  
  Minimal systemd-friendly health timer (optional)
&lt;/h2&gt;

&lt;p&gt;If you already alert from node exporters, scrape whether the VIP is local:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/system/check-vip.service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Check floating VIP presence&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/sbin/check-vip.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/system/check-vip.timer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Periodic VIP ownership check&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnBootSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2min&lt;/span&gt;
&lt;span class="py"&gt;OnUnitActiveSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1min&lt;/span&gt;
&lt;span class="py"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;check-vip.service&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alert on “VIP missing on &lt;em&gt;all&lt;/em&gt; nodes” (bad) separately from “VIP not on preferred node” (info under &lt;code&gt;nopreempt&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick chooser
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two gateways, prefer A, fail to B, fail back to A:&lt;/strong&gt; priorities &lt;code&gt;150&lt;/code&gt;/&lt;code&gt;100&lt;/code&gt;, default preempt, track uplink + service&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two gateways, minimize failback flaps:&lt;/strong&gt; both &lt;code&gt;state BACKUP&lt;/code&gt;, &lt;code&gt;nopreempt&lt;/code&gt;, track service hard (&lt;code&gt;weight 0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multicast blocked:&lt;/strong&gt; &lt;code&gt;unicast_src_ip&lt;/code&gt; + &lt;code&gt;unicast_peer { ... }&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Picky switch MAC learning:&lt;/strong&gt; consider &lt;code&gt;use_vmac&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need L4 load balancing across many real servers:&lt;/strong&gt; that is IPVS/&lt;code&gt;virtual_server&lt;/code&gt; — adjacent keepalived feature, not this VIP recipe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need two NICs on one box:&lt;/strong&gt; bonding first, then VRRP on &lt;code&gt;bond0&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc5798" rel="noopener noreferrer"&gt;RFC 5798 — VRRP Version 3 for IPv4 and IPv6&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc3768" rel="noopener noreferrer"&gt;RFC 3768 — VRRP Version 2 (historical; authentication removed from the spec)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/bookworm/keepalived/keepalived.conf.5.en.html" rel="noopener noreferrer"&gt;keepalived.conf(5) — Debian man page&lt;/a&gt; (exhaustive keyword reference maintained with the project)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.archlinux.org/title/Keepalived" rel="noopener noreferrer"&gt;ArchWiki: Keepalived&lt;/a&gt; — compact master/backup and track_process examples&lt;/li&gt;
&lt;li&gt;&lt;a href="https://keepalived.readthedocs.io/en/latest/configuration_synopsis.html" rel="noopener noreferrer"&gt;keepalived configuration synopsis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;IANA VRRP: IPv4 multicast &lt;code&gt;224.0.0.18&lt;/code&gt;, IP protocol number &lt;code&gt;112&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Floating IPs are one of the highest-leverage HA upgrades you can give a pair of Linux hosts. Keep the recipe boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Same &lt;code&gt;virtual_router_id&lt;/code&gt; and VIP on every peer
&lt;/li&gt;
&lt;li&gt;Different priorities (and honest health tracks)
&lt;/li&gt;
&lt;li&gt;Config test with &lt;code&gt;keepalived -t&lt;/code&gt;, then restart/reload
&lt;/li&gt;
&lt;li&gt;Prove ownership with &lt;code&gt;ip addr&lt;/code&gt; + a third-party ping
&lt;/li&gt;
&lt;li&gt;Fail the master on purpose once before DNS points at the VIP
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do that, and a single host outage stops being “the gateway is down” and becomes a short, tested cutover your clients barely notice.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Single-NIC Outages: Practical Network Bonding with systemd-networkd on Linux</title>
      <dc:creator>Lyra</dc:creator>
      <pubDate>Sat, 08 Aug 2026 05:02:52 +0000</pubDate>
      <link>https://dev.to/lyraalishaikh/stop-single-nic-outages-practical-network-bonding-with-systemd-networkd-on-linux-3548</link>
      <guid>https://dev.to/lyraalishaikh/stop-single-nic-outages-practical-network-bonding-with-systemd-networkd-on-linux-3548</guid>
      <description>&lt;h1&gt;
  
  
  Stop Single-NIC Outages: Practical Network Bonding with systemd-networkd on Linux
&lt;/h1&gt;

&lt;p&gt;One unplugged cable should not take a server offline.&lt;/p&gt;

&lt;p&gt;If a host has two NICs into the same switch (or two uplinks into a redundant pair), Linux can present them as one logical interface with the bonding driver. Fail one path and traffic keeps moving. Do it declaratively with &lt;code&gt;systemd-networkd&lt;/code&gt;, and you get a config you can review, version, and reload without shell archaeology.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;link aggregation / NIC failover&lt;/strong&gt;, not storage multipath and not a VPN. Different layer, different failure domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get (and what you do not)
&lt;/h2&gt;

&lt;p&gt;Bonding aggregates multiple physical Ethernet ports into one logical bond device.&lt;/p&gt;

&lt;p&gt;Common goals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failover&lt;/strong&gt; — keep a single IP alive when one cable, SFP, or NIC dies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throughput&lt;/strong&gt; — spread flows across links when the switch and mode allow it (especially 802.3ad / LACP)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean ops&lt;/strong&gt; — one address, one default route, fewer “which NIC is primary?” surprises&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article does &lt;strong&gt;not&lt;/strong&gt; cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Device Mapper Multipath (&lt;code&gt;multipathd&lt;/code&gt;) for storage LUNs&lt;/li&gt;
&lt;li&gt;Software bridges for VMs/containers (though a bond can be a bridge port)&lt;/li&gt;
&lt;li&gt;WireGuard / VPN tunnels&lt;/li&gt;
&lt;li&gt;AQM / bufferbloat (&lt;code&gt;fq_codel&lt;/code&gt; / CAKE)&lt;/li&gt;
&lt;li&gt;Teamd (older alternative; bonding + networkd is the path here)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Modes that matter in real racks
&lt;/h2&gt;

&lt;p&gt;The bonding driver supports several modes. For most homelab and production Linux hosts, start with one of these two:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Switch help needed?&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;active-backup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No special config (two independent ports is enough)&lt;/td&gt;
&lt;td&gt;Simple HA: one NIC active, one standby&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;802.3ad&lt;/code&gt; (LACP)&lt;/td&gt;
&lt;td&gt;Yes — LACP EtherChannel / LAG on the switch&lt;/td&gt;
&lt;td&gt;Aggregated bandwidth + redundancy with a standards-based partner&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Other modes (&lt;code&gt;balance-rr&lt;/code&gt;, &lt;code&gt;balance-xor&lt;/code&gt;, &lt;code&gt;broadcast&lt;/code&gt;, &lt;code&gt;balance-tlb&lt;/code&gt;, &lt;code&gt;balance-alb&lt;/code&gt;) exist and are documented in the kernel bonding HOWTO. They are easier to misconfigure against a switch. Prefer &lt;code&gt;active-backup&lt;/code&gt; when the switch is dumb or unmanaged; prefer &lt;code&gt;802.3ad&lt;/code&gt; when you control LACP on both ends.&lt;/p&gt;

&lt;p&gt;Critical kernel guidance: &lt;strong&gt;enable link monitoring&lt;/strong&gt;. Without &lt;code&gt;miimon&lt;/code&gt; (MII monitoring) or ARP monitoring, the bond can keep a dead slave “up” and black-hole traffic. The kernel bonding docs call this out explicitly — very few devices lack MII support, so &lt;code&gt;MIIMonitorSec=&lt;/code&gt; should almost always be set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two (or more) Ethernet interfaces you can dedicate to the bond&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd-networkd&lt;/code&gt; managing those interfaces (not NetworkManager / ifupdown on the same NICs)&lt;/li&gt;
&lt;li&gt;Console or out-of-band access the first time you cut over production networking&lt;/li&gt;
&lt;li&gt;For LACP: switch ports configured as an LACP lag/port-channel with a matching hash policy preference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Identify interfaces first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;networkctl
ip &lt;span class="nt"&gt;-br&lt;/span&gt; &lt;span class="nb"&gt;link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use stable names (&lt;code&gt;enp1s0&lt;/code&gt;, &lt;code&gt;enp2s0&lt;/code&gt;, or names you set with &lt;code&gt;.link&lt;/code&gt; files). Do not build production bonds on temporary USB NIC names if you can avoid it.&lt;/p&gt;

&lt;p&gt;Disable conflicting managers on those NICs only. One DHCP client / network manager per interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layout: three small files
&lt;/h2&gt;

&lt;p&gt;networkd splits the job cleanly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.netdev&lt;/code&gt;&lt;/strong&gt; — create &lt;code&gt;bond0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.network&lt;/code&gt; (slaves)&lt;/strong&gt; — enslave physical NICs, no IP on the members&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.network&lt;/code&gt; (bond)&lt;/strong&gt; — address, routes, DNS on &lt;code&gt;bond0&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Put them in &lt;code&gt;/etc/systemd/network/&lt;/code&gt; with numeric prefixes so ordering stays obvious.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Create the bond device
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Active-backup (works without switch LACP):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/10-bond0.netdev&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond&lt;/span&gt;
&lt;span class="c"&gt;# Optional but useful for predictable MAC across reboots:
# MACAddress=aa:bb:cc:dd:ee:ff
&lt;/span&gt;
&lt;span class="nn"&gt;[Bond]&lt;/span&gt;
&lt;span class="py"&gt;Mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;active-backup&lt;/span&gt;
&lt;span class="py"&gt;MIIMonitorSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;100ms&lt;/span&gt;
&lt;span class="py"&gt;UpDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;200ms&lt;/span&gt;
&lt;span class="py"&gt;DownDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;200ms&lt;/span&gt;
&lt;span class="c"&gt;# After failover, remind peers who owns the IP:
&lt;/span&gt;&lt;span class="py"&gt;GratuitousARP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="c"&gt;# Primary reselect when the preferred NIC recovers:
&lt;/span&gt;&lt;span class="py"&gt;PrimaryReselectPolicy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from &lt;code&gt;systemd.netdev(5)&lt;/code&gt; / kernel docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Mode=&lt;/code&gt; accepts &lt;code&gt;active-backup&lt;/code&gt;, &lt;code&gt;802.3ad&lt;/code&gt;, &lt;code&gt;balance-rr&lt;/code&gt;, &lt;code&gt;balance-xor&lt;/code&gt;, &lt;code&gt;broadcast&lt;/code&gt;, &lt;code&gt;balance-tlb&lt;/code&gt;, &lt;code&gt;balance-alb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MIIMonitorSec=&lt;/code&gt; is the MII poll interval; &lt;code&gt;0&lt;/code&gt; disables monitoring (do not leave it disabled in production)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UpDelaySec=&lt;/code&gt; / &lt;code&gt;DownDelaySec=&lt;/code&gt; are rounded down to multiples of the MII interval — they reduce flapping on flaky PHYs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GratuitousARP=&lt;/code&gt; (active-backup) controls how many peer notifications go out after failover&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;LACP / 802.3ad (switch must participate):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond&lt;/span&gt;

&lt;span class="nn"&gt;[Bond]&lt;/span&gt;
&lt;span class="py"&gt;Mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;802.3ad&lt;/span&gt;
&lt;span class="py"&gt;TransmitHashPolicy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;layer3+4&lt;/span&gt;
&lt;span class="py"&gt;LACPTransmitRate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;fast&lt;/span&gt;
&lt;span class="py"&gt;MIIMonitorSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;100ms&lt;/span&gt;
&lt;span class="py"&gt;MinLinks&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;AdSelect&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;stable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why these knobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TransmitHashPolicy=layer3+4&lt;/code&gt; spreads TCP/UDP flows better than pure MAC hashing for many east-west workloads (valid for &lt;code&gt;802.3ad&lt;/code&gt; / &lt;code&gt;balance-xor&lt;/code&gt; / &lt;code&gt;balance-tlb&lt;/code&gt; per networkd)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LACPTransmitRate=fast&lt;/code&gt; asks the partner for 1s LACPDUs instead of 30s (&lt;code&gt;slow&lt;/code&gt;) — faster detection when the switch agrees&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MinLinks=&lt;/code&gt; controls how many member links must be up before the bond asserts carrier (802.3ad)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) Enslave the physical NICs (no addresses)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/20-bond0-members.network&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp1s0 enp2s0&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Bond&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="c"&gt;# Members should not block boot if one cable is missing
&lt;/span&gt;&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;strong&gt;active-backup&lt;/strong&gt;, mark the preferred NIC as primary (only valid for &lt;code&gt;active-backup&lt;/code&gt;, &lt;code&gt;balance-tlb&lt;/code&gt;, &lt;code&gt;balance-alb&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/20-bond0-primary.network&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp1s0&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Bond&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0&lt;/span&gt;
&lt;span class="py"&gt;PrimarySlave&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/21-bond0-backup.network&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;enp2s0&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;Bond&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PrimarySlave=true&lt;/code&gt; means: while &lt;code&gt;enp1s0&lt;/code&gt; is healthy, it stays active. The backup is used only when the primary is offline — exactly what you want for “10G preferred, 1G spare” or “NIC on the main switch preferred.”&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Put the IP on the bond, not the members
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/30-bond0.network&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0&lt;/span&gt;

&lt;span class="nn"&gt;[Link]&lt;/span&gt;
&lt;span class="py"&gt;RequiredForOnline&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;routable&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;DHCP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="c"&gt;# Or static:
# Address=192.0.2.10/24
# Gateway=192.0.2.1
# DNS=192.0.2.53
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;RequiredForOnline=routable&lt;/code&gt; on &lt;code&gt;bond0&lt;/code&gt; (and &lt;code&gt;no&lt;/code&gt; on members) keeps &lt;code&gt;systemd-networkd-wait-online&lt;/code&gt; honest: boot waits for a working bond address, not for every physical port to show carrier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply safely
&lt;/h2&gt;

&lt;p&gt;On a live host, prefer console/IPMI the first time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Review files&lt;/span&gt;
networkctl &lt;span class="nb"&gt;cat &lt;/span&gt;bond0 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /etc/systemd/network/

&lt;span class="c"&gt;# Make sure networkd owns the stack&lt;/span&gt;
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; systemd-networkd.service

&lt;span class="c"&gt;# Load new netdev/network definitions&lt;/span&gt;
networkctl reload
networkctl reconfigure enp1s0 enp2s0 bond0
&lt;span class="c"&gt;# If a netdev kind/setting cannot hot-update, restart once:&lt;/span&gt;
&lt;span class="c"&gt;# systemctl restart systemd-networkd.service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an existing &lt;code&gt;bond0&lt;/code&gt; was created with a different kind or immutable setting, networkd may keep the old device. Remove it and reload (console recommended):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;delete bond0 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;systemctl restart systemd-networkd.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verify the bond is real
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;networkctl status bond0
networkctl
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/net/bonding/bond0
ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr show bond0
ip route
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Healthy signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bond0&lt;/code&gt; operational state is &lt;code&gt;degraded&lt;/code&gt; or &lt;code&gt;routable&lt;/code&gt; with at least one slave up (&lt;code&gt;degraded&lt;/code&gt; with one of two slaves is normal and still useful)&lt;/li&gt;
&lt;li&gt;Members show as &lt;code&gt;enslaved&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/proc/net/bonding/bond0&lt;/code&gt; lists mode, MII status, active slave (active-backup), or aggregator/partner info (802.3ad)&lt;/li&gt;
&lt;li&gt;Addresses and default route sit on &lt;code&gt;bond0&lt;/code&gt;, not on &lt;code&gt;enp1s0&lt;/code&gt;/&lt;code&gt;enp2s0&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example fields you want in &lt;code&gt;/proc/net/bonding/bond0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bonding Mode: fault-tolerance (active-backup)
Primary Slave: enp1s0 (primary_reselect always)
Currently Active Slave: enp1s0
MII Status: up
MII Polling Interval (ms): 100
Slave Interface: enp1s0
MII Status: up
Slave Interface: enp2s0
MII Status: up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For LACP, confirm the switch also shows both ports bundled and in distributing state. Linux looking “up” while the switch still has independent access ports is a classic misconfig.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controlled failover test
&lt;/h2&gt;

&lt;p&gt;Do this during a maintenance window if the host is production.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start a continuous ping from another machine to the bond IP.&lt;/li&gt;
&lt;li&gt;Note the active slave: &lt;code&gt;grep -E 'Currently Active Slave|Slave Interface|MII Status' /proc/net/bonding/bond0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pull the &lt;strong&gt;active&lt;/strong&gt; cable (or &lt;code&gt;ip link set enp1s0 down&lt;/code&gt; from console).&lt;/li&gt;
&lt;li&gt;Watch ping loss — a handful of drops can be normal; multi-second black holes are not.&lt;/li&gt;
&lt;li&gt;Confirm the bond failed over: active slave changed, &lt;code&gt;bond0&lt;/code&gt; still has carrier/address.&lt;/li&gt;
&lt;li&gt;Restore the link and confirm recovery policy (&lt;code&gt;PrimaryReselectPolicy=always&lt;/code&gt; should return traffic to the primary when it is healthy again).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Optional one-liner while testing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;watch &lt;span class="nt"&gt;-n1&lt;/span&gt; &lt;span class="s1"&gt;'networkctl; echo; grep -E "Currently Active|MII Status|Slave Interface|802.3ad|Aggregator" /proc/net/bonding/bond0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Optional: VLAN on top of the bond
&lt;/h2&gt;

&lt;p&gt;If the uplink is a trunk, create VLAN devices on &lt;code&gt;bond0&lt;/code&gt;, not on the physical NICs:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/etc/systemd/network/40-bond0.10.netdev&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[NetDev]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0.10&lt;/span&gt;
&lt;span class="py"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;vlan&lt;/span&gt;

&lt;span class="nn"&gt;[VLAN]&lt;/span&gt;
&lt;span class="py"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/40-bond0.vlan.network&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;VLAN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0.10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/etc/systemd/network/45-bond0.10.network&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Match]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;bond0.10&lt;/span&gt;

&lt;span class="nn"&gt;[Network]&lt;/span&gt;
&lt;span class="py"&gt;DHCP&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep L3 configuration on the VLAN interfaces when the bond itself is only a tagged pipe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No monitoring configured.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Default &lt;code&gt;MIIMonitorSec=0&lt;/code&gt; disables MII monitoring in networkd’s Bond section. Set it. Kernel docs also allow ARP monitoring (&lt;code&gt;ARPIntervalSec=&lt;/code&gt; + &lt;code&gt;ARPIPTargets=&lt;/code&gt;) when MII is insufficient; do not run with neither.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IP still on a member NIC.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Leftover NetworkManager profiles, netplan, or old &lt;code&gt;.network&lt;/code&gt; files that match &lt;code&gt;enp*&lt;/code&gt; will fight the bond. &lt;code&gt;networkctl&lt;/code&gt; should show members without global addresses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LACP without a partner.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;Mode=802.3ad&lt;/code&gt; against non-LACP switch ports will not give you a happy aggregator. Use &lt;code&gt;active-backup&lt;/code&gt; until the switch lag exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;wait-online hangs at boot.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If every member is &lt;code&gt;RequiredForOnline=yes&lt;/code&gt;, a single unplugged cable can stall boot. Mark members &lt;code&gt;RequiredForOnline=no&lt;/code&gt; and require &lt;code&gt;bond0&lt;/code&gt; (often &lt;code&gt;routable&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hash policy mismatch expectations.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
LACP does not stripe a single TCP flow across NICs like a RAID-0 for packets. Flow distribution depends on the transmit hash and partner behavior. Measure with multiple flows, not one iperf stream, before declaring “2× bandwidth.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MAC surprises.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
By default networkd can generate a bond MAC from name + machine-id. Pin &lt;code&gt;MACAddress=&lt;/code&gt; if your DHCP reservations, switch port security, or license managers key off MAC stability.&lt;/p&gt;
&lt;h2&gt;
  
  
  Minimal health check you can schedule
&lt;/h2&gt;

&lt;p&gt;A tiny oneshot is enough to catch “bond has no active slaves” before users do:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/usr/local/sbin/check-bond0&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;BOND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;bond0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;proc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/proc/net/bonding/&lt;span class="nv"&gt;$BOND&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nv"&gt;$proc&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"missing &lt;/span&gt;&lt;span class="nv"&gt;$proc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'MII Status: up'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$proc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BOND&lt;/span&gt;&lt;span class="s2"&gt; has no MII up state"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="c"&gt;# active-backup: ensure an active slave exists&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'Currently Active Slave: None'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$proc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BOND&lt;/span&gt;&lt;span class="s2"&gt; has no active slave"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BOND&lt;/span&gt;&lt;span class="s2"&gt; ok"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;755 /usr/local/sbin/check-bond0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it to a timer if you already run node health checks elsewhere. Keep alerts on “no active slave” / “only one slave in a 2-link LACP bundle” — those are the pages that prevent silent single-path operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick mode chooser
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unmanaged switch / two random wall ports:&lt;/strong&gt; &lt;code&gt;active-backup&lt;/code&gt; + &lt;code&gt;PrimarySlave=&lt;/code&gt; + &lt;code&gt;MIIMonitorSec=100ms&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proper ToR with LACP:&lt;/strong&gt; &lt;code&gt;802.3ad&lt;/code&gt; + &lt;code&gt;TransmitHashPolicy=layer3+4&lt;/code&gt; + &lt;code&gt;LACPTransmitRate=fast&lt;/code&gt; + matching switch lag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need VLANs:&lt;/strong&gt; bond first, VLAN subinterfaces second&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need VM bridge:&lt;/strong&gt; bond (or bond.VLAN) as the bridge port; do not put the host IP on a member NIC&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;systemd.netdev(5)&lt;/code&gt; — &lt;code&gt;[Bond]&lt;/code&gt; options (&lt;code&gt;Mode=&lt;/code&gt;, &lt;code&gt;MIIMonitorSec=&lt;/code&gt;, &lt;code&gt;LACPTransmitRate=&lt;/code&gt;, &lt;code&gt;TransmitHashPolicy=&lt;/code&gt;, …)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;systemd.network(5)&lt;/code&gt; — &lt;code&gt;Bond=&lt;/code&gt;, &lt;code&gt;PrimarySlave=&lt;/code&gt;, &lt;code&gt;RequiredForOnline=&lt;/code&gt;, &lt;code&gt;VLAN=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;networkctl(1)&lt;/code&gt; — link operational states (&lt;code&gt;enslaved&lt;/code&gt;, &lt;code&gt;degraded&lt;/code&gt;, &lt;code&gt;routable&lt;/code&gt;, …)&lt;/li&gt;
&lt;li&gt;Linux kernel docs: &lt;a href="https://docs.kernel.org/networking/bonding.html" rel="noopener noreferrer"&gt;Ethernet Bonding Driver HOWTO&lt;/a&gt; — modes, &lt;code&gt;miimon&lt;/code&gt;, LACP, failover behavior&lt;/li&gt;
&lt;li&gt;Debian manpage mirror: &lt;a href="https://manpages.debian.org/testing/systemd/systemd.netdev.5.en.html" rel="noopener noreferrer"&gt;systemd.netdev(5)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ArchWiki: &lt;a href="https://wiki.archlinux.org/title/Systemd-networkd" rel="noopener noreferrer"&gt;systemd-networkd&lt;/a&gt; — wait-online, file layout, bridge patterns that compose with bonds&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Bonding is one of the highest-leverage “two cables” upgrades you can give a Linux host. Keep the recipe boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;bond0&lt;/code&gt; with an explicit mode and &lt;strong&gt;MII monitoring&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Enslave NICs with &lt;strong&gt;no IPs&lt;/strong&gt; and &lt;code&gt;RequiredForOnline=no&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Put addressing only on &lt;code&gt;bond0&lt;/code&gt; (or VLAN children)&lt;/li&gt;
&lt;li&gt;Verify with &lt;code&gt;networkctl&lt;/code&gt; + &lt;code&gt;/proc/net/bonding/bond0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pull a cable on purpose once before you trust it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do that, and a single NIC or cable fault stops being an outage and becomes a short blip you already tested.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>systemd</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
