Listing Rules
Everything here needs root. prints nothing at all when a table does not exist, which usually means the firewall is empty rather than broken.
| Command | What it does |
|---|---|
| Every table, chain, and rule on the box | |
| The same, with the you need in order to delete a rule | |
| Just the table families and names | |
| One table with its chains and rules | |
| One chain | |
| Only the IPv4 family (, , , , also work) | |
| Stateless output: no counters, ready to feed back in with | |
| JSON, for scripts | |
| Named sets and their elements | |
| Named counters | |
| Find the handle of the rule that mentions a port | |
| Whether the service that loads rules at boot is running | |
| nft version; named priorities need 0.9 or newer |
From iptables
Most people land here with an iptables ruleset already running. prints the nft equivalent of any iptables command without touching the firewall.
| Command | What it does |
|---|---|
| Print the nft version of one rule, change nothing | |
| Same for IPv6 | |
| Dump the current iptables ruleset | |
| Convert the whole dump to nft syntax | |
| Load the converted file (read it first, the output is rarely perfect) | |
| means your iptables commands already write nftables rules | |
| See the rules that iptables-nft wrote underneath |
Paste a ruleset into the iptables to nftables converter for the same job in a browser, or the firewall rule generator to write one rule for nft, iptables, UFW, and firewalld at once.
| iptables | nftables |
|---|---|
| Re-declare the chain with | |
| / | / |
| Native inside the ruleset | |
| Separate and rules | One rule in the family |
The full iptables side of the mapping is on the iptables cheat sheet.
Tables and Chains
nftables ships with nothing: no filter table, no INPUT chain. You create what you need. The family covers IPv4 and IPv6 in one place and is the right default.
| Command | What it does |
|---|---|
| Create a table (the name is yours; is convention) | |
| A table in the IPv4 family, for NAT | |
| Delete a table and every chain and rule in it | |
| Empty a table but keep its chains | |
| A plain chain, reached only by or | |
| Delete a chain (it must be empty and unreferenced) | |
| Rename a chain |
A chain only sees traffic if it is a base chain, meaning it declares a type, a hook, and a priority. The shell eats , , and , so quote the block.
sudo nft add table inet filter
sudo nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }'
sudo nft add chain inet filter forward '{ type filter hook forward priority 0; policy drop; }'
sudo nft add chain inet filter output '{ type filter hook output priority 0; policy accept; }'| Hook | Where it sits |
|---|---|
| Before the routing decision; NAT destination rewrites go here | |
| Packets addressed to this machine | |
| Packets routed through this machine | |
| Packets this machine generates | |
| After routing, on the way out; NAT source rewrites go here |
Chain types are , , and . Priority is a number, low first, and named values read better: (-300), (-150), (-100), (0), (100). Two base chains on the same hook both run, in priority order.
SSH Lockout
A base input chain with and no rules kills the session you are typing in, not just the next one, because nftables judges every packet including the ones carrying your keystrokes. Build the allow rules first and set the policy last.
sudo nft add table inet filter
sudo nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }'
sudo nft add rule inet filter input ct state established,related accept
sudo nft add rule inet filter input ct state invalid drop
sudo nft add rule inet filter input iif lo accept
sudo nft add rule inet filter input tcp dport 22 accept
# only now, once port 22 is open:
sudo nft add chain inet filter input '{ type filter hook input priority 0; policy drop; }'| Command | What it does |
|---|---|
| Deadman switch: wipes the firewall in 5 minutes if you lose the session | |
| What to run from the console if you are already locked out | |
| Check the accept rule is live before you change the policy | |
| from a second terminal | Test with a new connection, keep the old one open |
Cancel the deadman switch once the new ruleset works: , or list the pending jobs with . If SSH listens somewhere other than 22, open that port instead, and check with the SSH port checker from outside the box. The SSH cheat sheet covers the client side of a connection that will not come back.
Adding Rules
appends to the end of a chain, puts the rule at the top. Order matters: the first rule with a verdict wins, so a above an is the end of the story.
| Command | What it does |
|---|---|
| Append a rule | |
| Put a rule first | |
| Insert before the rule with handle 9 | |
| Insert after the rule with handle 9 | |
| Position by index, counting from 0 | |
| Swap one rule for another, keeping its place | |
| Several ports in one rule (quote the braces in a shell) | |
| Block one address | |
| Everything except an internal network | |
| Attach a comment that survives in |
Rule Syntax
A rule is a list of matches followed by a verdict. Everything before the verdict has to be true for the verdict to apply.
| Match | What it matches |
|---|---|
| Source IPv4 address or network | |
| Destination IPv4 address | |
| Source IPv6 address | |
| Destination TCP port | |
| Source port range | |
| Several UDP ports | |
| Port regardless of TCP or UDP | |
| Traffic belonging to a connection you already allowed | |
| The first packet of a connection | |
| Packets conntrack cannot place; drop these early | |
| Incoming interface by name ( for outgoing) | |
| Interface by index: faster, but breaks if the device is recreated | |
| Wildcard interface name | |
| Layer 4 protocol | |
| Ping ( for IPv6) | |
| TCP flag match | |
| Source MAC address | |
| Packet mark set elsewhere | |
| The local user that owns the socket (output chain only) |
| Verdict | What it does |
|---|---|
| Let the packet through, stop evaluating this chain | |
| Discard silently; the sender waits for a timeout | |
| Discard and answer () | |
| Answer a TCP connection with RST, so it fails instantly | |
| Run another chain and come back if it returns no verdict | |
| Run another chain and never come back | |
| Leave this chain for the one that jumped here | |
| No verdict: count the packet and keep evaluating | |
| Hand the packet to a userspace program |
Variables keep a file readable. at the top, then in the rules.
Deleting and Flushing
There are no rule numbers. Deletion works by handle, and handles only appear with , which is where this stops being obvious.
| Command | What it does |
|---|---|
| Print every rule with its | |
| Handles for one chain only | |
| Delete one rule | |
| Empty a chain, keep the chain and its policy | |
| Empty every chain in a table | |
| Delete an empty, unreferenced chain | |
| Delete a table and everything inside it | |
| Wipe every table: the firewall is then completely open | |
| Reload from the file, discarding live edits (the file starts with ) |
Handles are assigned in order but are not reused and do not survive a reload, so list them again after every .
Sets and Maps
Sets replace ipset. A named set is part of the ruleset, so it saves and reloads with everything else, and lookups stay fast as it grows.
sudo nft add set inet filter blocklist '{ type ipv4_addr; flags interval; }'
sudo nft add element inet filter blocklist '{ 203.0.113.0/24, 198.51.100.7 }'
sudo nft add rule inet filter input ip saddr @blocklist drop| Command | What it does |
|---|---|
| Every named set with its elements | |
| One set | |
| Add an address | |
| Remove an address | |
| Empty a set, leave the rules pointing at it | |
| A set of ports instead of addresses | |
| Allows CIDR ranges as elements | |
| Elements expire on their own, fail2ban style | |
| A timeout on one element | |
| Verdict map: one rule, one lookup, many ports |
A map pairs a key with a value, which is how you write a port forwarding table in one rule:
sudo nft add map ip nat porthost '{ type inet_service : ipv4_addr; }'
sudo nft add element ip nat porthost '{ 80 : 10.0.0.5, 8443 : 10.0.0.6 }'
sudo nft add rule ip nat prerouting dnat to tcp dport map @porthostNAT and Port Forwarding
NAT needs its own table with type chains; a filter chain cannot rewrite addresses. Forwarding between interfaces also needs and a forward chain that accepts the traffic.
sudo nft add table ip nat
sudo nft add chain ip nat prerouting '{ type nat hook prerouting priority dstnat; }'
sudo nft add chain ip nat postrouting '{ type nat hook postrouting priority srcnat; }'
sudo nft add rule ip nat postrouting oifname "eth0" masquerade
sudo nft add rule ip nat prerouting iifname "eth0" tcp dport 8080 dnat to 10.0.0.5:80| Statement | What it does |
|---|---|
| Source NAT using the outgoing interface's current address (dynamic IPs) | |
| Source NAT to a fixed address | |
| Send matching traffic to an internal host and port | |
| Same host, same port | |
| Send traffic to another port on this machine | |
| Turn on routing (add it to to keep it) | |
| Let the forwarded, rewritten traffic through the filter chain |
A dnat rule that does nothing is usually a forward chain with and no matching accept, or left at 0. Traffic from the same machine does not pass through , so a local test of a port forward always fails; test from another host, or with the SSH port checker.
Rate Limiting
matches while traffic is under the rate; matches once it goes above. Put the verdict that suits the one you picked.
| Command | What it does |
|---|---|
| Accept up to 3 new SSH connections a minute, rest falls through | |
| Drop new SSH connections above 3 a minute | |
| Allow a short burst before the limit bites | |
| Limit by bandwidth rather than packet count | |
| Answer ping, but not a flood of it |
A plain counts every packet together, so one noisy address starves everyone. For a per-address limit, use a dynamic set:
sudo nft add set inet filter ssh_meter '{ type ipv4_addr; flags dynamic; timeout 1m; }'
sudo nft add rule inet filter input tcp dport 22 ct state new \
add @ssh_meter { ip saddr limit rate 3/minute } acceptEach source address gets its own counter. Over the limit the statement stops matching, so the packet falls through to whatever comes next, which should be a drop rule. For a single server, does the same thing in one line; see the UFW cheat sheet.
Counters and Logging
Unlike iptables, counters are off unless you ask for them, which is why an nftables ruleset is cheaper to evaluate.
| Command | What it does |
|---|---|
| Count packets and bytes on one rule | |
| Counters appear inline on the rules that have them | |
| Zero every counter | |
| Zero the counters in one table | |
| A named counter several rules can share | |
| Point a rule at the named counter | |
| Log and count before dropping | |
| Syslog level for the line ( is the default) | |
| Include the full header details | |
| Keep a busy drop rule from flooding the log | |
| Watch the drops live | |
| Send to ulogd2 over nflog instead of the kernel log |
Logging happens where the rule sits, so a log rule at the bottom of a chain shows you exactly what the firewall is turning away. journalctl has the rest of the reading options.
nftables.conf
One file holds the whole ruleset. on the first line makes loading it idempotent: the file replaces what is running instead of adding to it.
#!/usr/sbin/nft -f
flush ruleset
define wan = "eth0"
define admin = { 203.0.113.10, 198.51.100.0/24 }
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
ip saddr $admin tcp dport 22 accept
tcp dport { 80, 443 } accept
limit rate 5/minute log prefix "nft-input-drop " counter
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}| Path or line | What it is |
|---|---|
| The file loads on Debian, Ubuntu, and Arch | |
| The same on RHEL, Rocky, and Fedora | |
| Split a large ruleset across files | |
| Lets you run the file directly if it is executable | |
| A variable, used later as | |
| Must be first, or a reload stacks a second copy of every rule |
Persist and Reload
Rules added with are live immediately and gone at the next reboot. Nothing writes them to disk for you.
| Command | What it does |
|---|---|
| Save the running ruleset (run it under , the redirect needs root too) | |
| Load the file at every boot, starting now | |
| Check it will actually run at boot | |
| Reload the file into the kernel, atomically | |
| The same, through the service | |
| Unload the ruleset, leaving the box open | |
| Install on Debian and Ubuntu ( on RHEL) |
Saving and enabling, in full:
sudo sh -c 'echo "flush ruleset" > /etc/nftables.conf && nft -s list ruleset >> /etc/nftables.conf'
sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftablesis atomic: the whole file applies or none of it does. A reload never leaves the firewall half configured, which is the main reason to edit the file rather than type rules one at a time.
Syntax Check
parses and checks without applying, so a broken file never reaches the kernel. Run it before every reload.
| Command | What it does |
|---|---|
| Check the whole file, change nothing | |
| Check one command before running it | |
| The long form of the same thing | |
| Read a ruleset from standard input (useful in scripts) | |
| Show what would be sent to the kernel |
An error names the file, line, and column, with a caret under the token it choked on:
/etc/nftables.conf:14:17-20: Error: syntax error, unexpected string
tcp dport 80 ACCEPT
^^^^^^The usual causes: iptables words in an nft file (, , uppercase verdicts), unquoted , , or on a shell command line, a base chain missing its line, a set used before it is declared, and reading a file that was written for .
nftables vs iptables
| Topic | iptables | nftables |
|---|---|---|
| Commands | , , , | One , with an family covering v4 and v6 |
| Starting point | Built-in filter, nat, and mangle tables | Empty: you create the tables and chains |
| Deleting | By line number | By handle from |
| Big address lists | , a separate tool and database | Native sets and maps in the ruleset |
| Reloads | , atomic per table | , atomic for the whole file |
| Counters | On every rule, always | Only where you write |
| Matching many ports | One rule each, or the multiport module | or a verdict map lookup |
| Status | Maintained, but the command is usually a shim | The kernel's firewall since 3.13, default on Debian 10, Ubuntu 20.04, RHEL 8 and newer |
Existing iptables scripts keep working through , so there is no rush. New rulesets are worth writing in native nft syntax.
Gotchas
- A base chain with and no rules drops the packets carrying your SSH session, immediately. Add and the port 22 rule before you set the policy.
- deletes the tables, so the policies go with them and the box ends up wide open. That is the opposite of the iptables trap, where flushing with a DROP policy locks you out. Both are worth a deadman switch.
- The shell, not nft, is what rejects . Wrap the braces in single quotes.
- Handles are not line numbers. They do not survive a reload, so re-run after every before deleting anything.
- A rule added with is not in , and a rule written in that file is not live. Nothing syncs them.
- / match the interface index and go stale when a device is recreated, which happens with VPN and container interfaces. Use / unless you have measured that you need the speed.
- Docker writes its own rules and publishes container ports through the forward path, so a container with is reachable even when your input chain says otherwise. Bind it to and put a reverse proxy in front.
- Two base chains on the same hook both run. If a rule seems to be ignored, look for another table hooking the same place at a lower priority, including the tables and firewalld create.
- shows nothing when there are no tables. That is an open firewall, not a broken command.