Skip to main content Skip to sidebar

Load Balancing with nftables only

Sometimes a separate load balancer is overkill. If the requirement is only L4 distribution for TCP or UDP, nftables can do it directly in the kernel with DNAT, maps, and the number generator.

The nftables wiki already has a compact Load balancing page. This note expands the same primitives into a complete gateway ruleset and adds the operational pieces that are easy to forget: forwarding, SNAT, backend updates, and health-check limitations.

The important limitation: this is packet filtering and NAT, not HAProxy or Envoy. There are no HTTP checks, no TLS awareness, no retries, no queueing, and no automatic backend health state. If you need these features, use a real load balancer. If you need a small and predictable gateway that distributes connections between a few backend hosts, nftables is enough.

Example Topology

flowchart LR
    C["client"] -->|"203.0.113.10:443"| LB["nftables gateway"]
    LB -->|"DNAT"| A["10.10.20.11:443"]
    LB -->|"DNAT"| B["10.10.20.12:443"]
    A --> LB
    B --> LB
    LB --> C

For this example:

  • public interface: wan0
  • backend interface: lan0
  • service VIP: 203.0.113.10
  • load balancer LAN address: 10.10.20.1
  • backend servers: 10.10.20.11, 10.10.20.12
  • service port: 443/tcp

Enable forwarding:

sysctl -w net.ipv4.ip_forward=1

Persist it:

printf 'net.ipv4.ip_forward=1\n' > /etc/sysctl.d/99-forwarding.conf
sysctl --system

Minimal Stateful NAT Load Balancer

This version does round-robin backend selection. The NAT chain is stateful: only the first packet of a flow selects the backend, then conntrack applies the same NAT binding to the rest of the flow.

#!/usr/sbin/nft -f

flush ruleset

define WAN = "wan0"
define LAN = "lan0"
define VIP = 203.0.113.10
define LB_LAN_IP = 10.10.20.1
define BACKENDS = { 10.10.20.11, 10.10.20.12 }

table ip lb_nat {
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;

        iifname $WAN ip daddr $VIP tcp dport 443 counter dnat to numgen inc mod 2 map {
            0 : 10.10.20.11,
            1 : 10.10.20.12,
        }
    }

    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;

        oifname $LAN ip daddr $BACKENDS tcp dport 443 counter snat to $LB_LAN_IP
    }
}

table inet lb_filter {
    chain forward {
        type filter hook forward priority filter; policy drop;

        ct state established,related counter accept
        iifname $WAN oifname $LAN ip daddr $BACKENDS tcp dport 443 counter accept
    }
}

The snat rule is optional. Use it when backend servers do not route replies back through the nftables gateway. If the backends use the gateway as their default route, remove the postrouting rule and preserve the real client source address.

Random Distribution

Round-robin is deterministic. I usually prefer random distribution for simple public services:

iifname $WAN ip daddr $VIP tcp dport 443 counter dnat to numgen random mod 2 map {
    0 : 10.10.20.11,
    1 : 10.10.20.12,
}

For TCP, this still selects only once per connection because it is in a stateful NAT chain.

Weighted Distribution

Intervals make simple weights possible. This example sends about 70 percent of new flows to 10.10.20.11 and 30 percent to 10.10.20.12:

iifname $WAN ip daddr $VIP tcp dport 443 counter dnat to numgen random mod 10 map {
    0-6 : 10.10.20.11,
    7-9 : 10.10.20.12,
}

The same idea works with numgen inc mod 10 if strict rotation is preferred.

Port Distribution

The backend port can also come from a map. This is useful when several instances run on one backend host:

iifname $WAN ip daddr $VIP tcp dport 443 counter dnat to 10.10.20.11 : numgen inc mod 2 map {
    0 : 8443,
    1 : 9443,
}

The incoming service is still 203.0.113.10:443, but new flows alternate between 10.10.20.11:8443 and 10.10.20.11:9443.

Source Hashing

If the same client IP should usually land on the same backend, use a hash instead of numgen:

iifname $WAN ip daddr $VIP tcp dport 443 counter dnat to jhash ip saddr mod 2 map {
    0 : 10.10.20.11,
    1 : 10.10.20.12,
}

This is useful for simple source affinity. It is stable while the modulo and backend map stay the same. Changing the backend count changes the mapping, so do not treat this as a Maglev-style load balancer.

If too many clients are hidden behind the same source NAT, hash more fields:

iifname $WAN ip daddr $VIP tcp dport 443 counter dnat to jhash ip saddr . tcp sport mod 2 map {
    0 : 10.10.20.11,
    1 : 10.10.20.12,
}

This distributes more evenly, but multiple connections from the same client can go to different backends.

Stateless NAT

The wiki also shows stateless NAT by changing packet headers directly with ip daddr set. This is lighter than stateful NAT because conntrack is not used for the rewritten packets, but it is much easier to break. Use it only when the packet path is fully controlled and reply traffic does not need the stateful NAT engine to undo the translation.

table ip lb_raw {
    chain prerouting {
        type filter hook prerouting priority raw; policy accept;

        iifname $WAN ip daddr $VIP tcp dport 443 notrack
    }
}

table ip lb_mangle {
    chain prerouting {
        type filter hook prerouting priority mangle; policy accept;

        iifname $WAN ip daddr $VIP tcp dport 443 counter ip daddr set numgen inc mod 2 map {
            0 : 10.10.20.11,
            1 : 10.10.20.12,
        }
    }
}

For normal TCP/UDP services, I would start with stateful DNAT and only use stateless NAT for a measured reason.

Direct Server Return

Direct Server Return keeps the VIP as the destination IP and changes only Ethernet headers. The backend receives the packet for the VIP and replies directly to the client, so return traffic bypasses the load balancer.

Backend requirements:

  • the VIP must be configured locally on each backend, usually on lo
  • backends must not answer ARP for the VIP on the LAN
  • the service must bind to the VIP or to all addresses
  • firewalling and observability must account for asymmetric traffic

Example for TCP using a netdev ingress chain:

table netdev lb_dsr {
    chain wan_ingress {
        type filter hook ingress device "wan0" priority 0; policy accept;

        ip daddr 203.0.113.10 tcp dport 443 counter \
            ether saddr set 02:00:00:00:00:01 \
            ether daddr set jhash ip saddr . tcp sport mod 2 map {
                0 : 02:00:00:00:20:11,
                1 : 02:00:00:00:20:12,
            } \
            fwd to "lan0"
    }
}

Replace 02:00:00:00:00:01 with the load balancer MAC on the backend segment and the map values with the real backend MAC addresses.

Using a Named Map

For a short example, anonymous maps are fine. For operations, named maps are easier to inspect and update:

table ip lb_nat {
    map https_backends {
        type integer : ipv4_addr
        elements = {
            0 : 10.10.20.11,
            1 : 10.10.20.12,
        }
    }

    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;

        iifname $WAN ip daddr $VIP tcp dport 443 counter dnat to numgen random mod 2 map @https_backends
    }
}

Inspect the map:

nft list map ip lb_nat https_backends

Change a backend with one nftables batch:

nft -f - <<'EOF'
delete element ip lb_nat https_backends { 1 }
add element ip lb_nat https_backends { 1 : 10.10.20.13 }
EOF

Existing conntrack entries keep their old NAT binding. New flows use the updated map.

Health Checks

nftables will not check backend health by itself. The usual pattern is to run a tiny external checker that updates the map or reloads a generated ruleset.

For example, if 10.10.20.12 is unhealthy:

nft -f - <<'EOF'
delete element ip lb_nat https_backends { 1 }
add element ip lb_nat https_backends { 1 : 10.10.20.11 }
EOF

This keeps the modulo unchanged and sends both slots to the healthy backend. When the server recovers:

nft -f - <<'EOF'
delete element ip lb_nat https_backends { 1 }
add element ip lb_nat https_backends { 1 : 10.10.20.12 }
EOF

This is not as graceful as draining in an application load balancer. It is simple and works when the service can tolerate connection-level decisions.

Apply And Check

Validate syntax before loading:

nft -c -f /etc/nftables.conf

Load the ruleset:

nft -f /etc/nftables.conf

Check counters:

nft list ruleset

Check conntrack entries if conntrack-tools is installed:

conntrack -L -p tcp --dport 443

Notes

  • NAT rules belong in type nat chains, not filter chains.
  • The forward filter sees the packet after DNAT, so match backend addresses there.
  • If SNAT is used, backends see the load balancer as the client.
  • If SNAT is not used, the backend route back to the client must pass through the load balancer.
  • For UDP, conntrack still creates flow entries, but timeouts matter more than with TCP.
  • DSR avoids reply-path NAT, but it requires backend VIP and ARP tuning.
  • This design is only L4. Anything requiring HTTP headers, TLS SNI, retries, circuit breaking, or health-aware balancing should be handled by a user-space load balancer.

References