nftables Operations Manual

14 min read
On this page

A rule existing, matching and ultimately allowing traffic are different facts. Identify input, forward or output and table ownership first. Examples are teaching fragments, not a replacement for a host ruleset shared with Docker, Tailscale or Nikki.

TaskStart here
Accept rule but no trafficPath and verdict
Repeated config loadsAtomic batch versus replacement
Find the matching ruleCounters and trace
Forwarding / NATTranslation and permission

Find the hook before reading rule order

Identify ownership and hooks first

# List families and tables to identify owned objects
sudo nft list tables

inet/ip/ip6 have different scopes; exclude Docker/VPN-owned tables from this change.

# Read all hooks, priorities, policies, and handles
sudo nft -a list ruleset

-a prints handles for precise changes. The whole-system view reveals later drops; it is not a replacement scope.

# Focus on the owned filter table
sudo nft list table inet ops_filter

NotFound is expected before first installation. If present, establish whether this file owns the entire table.

Establish the expected packet path

# Inspect destination routing and source selection
ip route get 192.0.2.10

Use the actual destination; this local lookup does not simulate every forwarded packet’s source or mark.

# Verify that the local target port has a listener
ss -lntup

An accept rule cannot create a missing listener. For forwarded traffic, inspect the destination host.

Preparing the visual
Why can a later chain drop an accepted packet?

On one input hook, an early base-chain accept is not final acceptance; a later chain may drop. Forward/output traffic does not visit these input chains.

External local traffic uses input, transit traffic forward, locally generated traffic output. Lower base-chain priorities run first; do not depend on ordering ties. Accept ends the current chain but later base chains can drop. Ordinary chains need references; the name input alone attaches no hook.

One transaction does not imply clearing old rules

nft -f submits a batch; add-only files can accumulate rules on repeated loads. Define the owned table and replacement boundary. This sample creates an independent accept-policy table for structure, not a complete security policy or an override of other tables.

table inet ops_filter {
    set admin4 {
        type ipv4_addr
        flags interval
        elements = { 192.0.2.0/24 }
    }
    chain input {
        type filter hook input priority 10; policy accept;
        ct state established,related counter accept
        iifname "lo" counter accept
        meta l4proto ipv6-icmp counter accept
        ip saddr @admin4 tcp dport 22 counter accept
    }
}

First load: file, validation, commit, request

Save the complete example as ./ops-filter.nft, replace admin4, and confirm the table is absent. This file owns only that table. Preserve existing owned configuration and independent management access first.

# Check whether the batch can be accepted in current state
sudo nft -c -f ./ops-filter.nft

-c does not change rules. Resolve syntax, references, or kernel support errors first; it does not test SSH reachability.

# Commit the reviewed, checked rule batch
sudo nft -f ./ops-filter.nft

On failure inspect the error and list again; successful validation is not successful installation.

# Verify installed rules, set contents, and handles
sudo nft -a list table inet ops_filter

Open new SSH/application connections independently and inspect counters. For persistence, inspect nftables.service to identify the actual loader path and install the verified file there.

Load initially only when the table is absent. For a fully owned existing table, put delete table inet ops_filter and its complete replacement in one file; check then load. Delete requires existence, so this is not an unconditional idempotent installer. Avoid global flush ruleset on shared hosts. -c is not a connectivity test; verify from another connection and retain the old config.

Edit by handle and trace a narrow flow

Modify rules by handle, not line number

# Refresh the chain’s rules and handles
sudo nft -a list chain inet ops_filter input

Example 42 must be replaced by the verified handle. Re-read after edits rather than reusing stale identifiers.

# Append a source- and port-scoped example rule
sudo nft add rule inet ops_filter input ip saddr 198.51.100.10 tcp dport 443 counter accept

add appends; insert defaults to the start. Inspect earlier terminal verdicts first. In this accept-policy chain, the rule mainly observes matches; it does not deny other sources.

# Replace the verified rule in place
sudo nft replace rule inet ops_filter input handle 42 ip saddr 198.51.100.10 tcp dport 8443 counter accept

This is an alternative for modifying an existing rule, not a mandatory step after add. Verify with a new connection and update the source configuration.

# Delete the verified unwanted rule
sudo nft delete rule inet ops_filter input handle 42

Re-list afterward; for an experiment, restore the original configuration and verify traffic.

Add and remove set elements at different stages

# Add a verified administration address to the set
sudo nft 'add element inet ops_filter admin4 { 198.51.100.10 }'

Quotes preserve nft braces. Verify with nft list set. If an interval already covers the address, inspect existing elements first.

# Remove the element when it is no longer needed
sudo nft 'delete element inet ops_filter admin4 { 198.51.100.10 }'

This is later removal, not an immediate sequel. Do not delete the shared set.

Trace: mark, reproduce, then clean up

Create a new owned trace_input and trace only incoming local TCP 443 from the selected source. It cannot explain packets dropped at earlier hooks.

# Create a temporary chain before ordinary input filtering
sudo nft 'add chain inet ops_filter trace_input { type filter hook input priority -301; policy accept; }'
# Enable nftrace only for the selected test traffic
sudo nft add rule inet ops_filter trace_input ip saddr 198.51.100.10 tcp dport 443 meta nftrace set 1
# Watch trace events while reproducing from another client
sudo nft monitor trace

Follow chains/verdicts for the same trace ID. If empty, check whether traffic reaches the marking rule. Ctrl-C stops the viewer, not the marking rule.

# Clear only this dedicated temporary chain after testing
sudo nft flush chain inet ops_filter trace_input

Confirm the chain contains only this experiment before clearing it, then remove the empty chain.

# Remove the empty temporary trace chain
sudo nft delete chain inet ops_filter trace_input

Re-list the table to confirm cleanup; preserve other chains and the ruleset.

Read current handles before delete/replace; line numbers are not handles. Counters require counter statements. monitor trace observes packets already marked nftrace; it does not enable tracing globally. Add a narrowly scoped nftrace rule in an owned early chain, record its handle, reproduce once and remove it.

DNAT changes destination, not forwarding permission

LayerRequirement
RoutingForwarding enabled, destination and return routes available
DNATIngress usually uses prerouting; locally generated traffic follows output
Forward filterAllow required traffic using the actual post-DNAT fields
SNAT / masqueradeUse where source translation/return routing requires it
table ip ops_nat {
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        iifname "wan0" ip daddr 192.0.2.20 tcp dport 8443 dnat to 10.0.0.10:443
    }
}

This IPv4 fragment needs a verified owned gateway, interface/addresses, forward rules and return path. Conntrack retains NAT bindings for existing connections, so test a new connection. iptables-nft and Docker’s native nftables backend are different modes; iptables=false is not a backend selector.

Chain verdicts · Atomic replacement · NAT · Docker nftables