A how-to manual: tables and chains, rule order, stateful baselines, matches, NAT, ip6tables, saving, safe changes with iptables-apply and the nft/legacy trap, all tested on a lab
▶Prefer to watch? This video runs the guide's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube. It is part of the Networking and Firewalls playlist.
iptables: rules, NAT and not locking yourself out
iptables -L -n -v --line-numbers
iptables is the classic Linux packet filter: a list of rules, read top to bottom, each saying "if the packet looks like this, do that". It has been the standard firewall tool for twenty years, it is still on millions of servers, and it is the engine under many things that look like something else: Docker, Kubernetes, fail2ban, ufw, and in part firewalld. It is also the tool in which the order of two lines can lock you out of your own server, and in which a typo in a port number silently opens the thing you meant to close. This guide is how to read it, how to write it, how to test it, and how to change it on a remote machine without a long walk to the data center.
Every example below ran for real: iptables v1.8.10 (nf_tables backend) on Ubuntu 24.04, in a lab with two virtual clients, one on an "inside" LAN (192.168.60.0/24, fd00:60::/64, interface eth1) and one "outside" (198.51.100.0/24, 2001:db8:2::/64, interface eth2), and real listeners on ports 22, 80, 443, 3306, 8080 and 9000. "probe" in the examples is a small test script: it connects from a client and prints what that client really experienced (open, refused, or silence). The lab ran as root; use sudo on a real server. Anything not run in the lab is marked "not run in the lab".
What iptables is, and which one you have
iptables is the command; the work is done by Netfilter, the packet filtering framework inside the Linux kernel. Since 2014 there is a newer kernel interface, nftables, and since iptables 1.8 the command comes in two versions that look identical and work differently:
iptables-nft writes rules into nftables (the default on Debian 10+, Ubuntu 20.04+, RHEL 8+)
iptables-legacy the original interface, with its own separate rule store
The command called plain "iptables" is a link to one of them. The version line says which:
$ iptables -V
iptables v1.8.10 (nf_tables)
$ update-alternatives --display iptables | head -3
iptables - auto mode
link best version is /usr/sbin/iptables-nft
link currently points to /usr/sbin/iptables-nft
The two backends do not see each other's rules. That deserves a whole section (see "Two backends"), because it is where the strangest iptables problems live. For everything else in this guide the syntax is the same on both.
Which tool manages your firewall? On a machine with firewalld or ufw, those tools write the iptables/nftables rules for you, and editing the rules by hand fights them. On a machine with Docker, Docker adds its own chains. Know who owns the rules before you edit (see the coexistence notes). Install what you need:
sudo apt install iptables iptables-persistent ipset Debian, Ubuntu
sudo dnf install iptables-nft iptables-services ipset RHEL family (not run in the lab)
Tables, chains and targets: the path of a packet
A packet entering the machine passes through fixed points in the kernel, called hooks. At each one, the rules in the matching chains are consulted. A rule has MATCHES (what it looks like) and a TARGET (what to do with it). The rules live in tables, grouped by purpose:
filter allow or block. The default table, where firewalls live.
nat change addresses or ports (masquerade, port forwarding).
mangle change packet fields (TTL, marks, MSS).
raw before connection tracking: exceptions and tracing.
security SELinux labels on packets (rare).
The built-in chains are:
PREROUTING packets as they arrive, before the routing decision
INPUT packets addressed to this machine
FORWARD packets passing THROUGH this machine (routing)
OUTPUT packets this machine creates
POSTROUTING packets about to leave
So a packet for a service on the machine goes PREROUTING, then INPUT; a packet from the LAN to the internet goes PREROUTING, FORWARD, POSTROUTING; a reply from the machine itself goes OUTPUT, POSTROUTING. Inside PREROUTING the order of tables is raw, mangle, nat. The kernel itself shows the walk when you trace a packet (section on tracing), here the first packet of an ssh connection:
raw:PREROUTING:rule:0x3:CONTINUE -4 -t raw -A PREROUTING -s 192.168.60.20/32 -p tcp -m tcp --dport 22 -j TRACE
raw:PREROUTING:policy:ACCEPT
nat:PREROUTING:policy:ACCEPT
filter:INPUT:rule:0x8:ACCEPT -4 -t filter -A INPUT -p tcp -m tcp --dport 22 -m comment --comment ssh -j ACCEPT
The targets you will use:
ACCEPT let it through (no more rules in THIS chain are read)
DROP discard silently
REJECT discard and tell the sender
LOG, NFLOG record it, then carry on with the next rule
RETURN leave this chain, back to the caller
a chain name jump to your own chain
MASQUERADE, SNAT, DNAT, REDIRECT the nat table's targets
Every built-in chain also has a POLICY, the target used when no rule decided: ACCEPT by default, which means that an empty iptables is a wide-open machine. The lab at the start:
$ iptables -L -n -v
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
$ probe c1 192.168.60.1 22 80 3306
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.20
c1 -> 192.168.60.1 port 3306 open: port 3306: you are 192.168.60.20
Everything open. That is what "no firewall" looks like.
Reading the rules
Three ways to list, and each shows a different thing:
iptables -L -n -v --line-numbers human table with counters and rule numbers. -n is important: without it iptables tries to resolve every address to a name, which is slow and can hang on a broken resolver
iptables -S the rules as the commands that would create them. Exact, copy-pasteable
iptables-save the whole rule set in the format iptables-restore reads, with counters of the chains
Look at the same rules two ways:
$ iptables -L INPUT -n --line-numbers
Chain INPUT (policy DROP)
num target prot opt source destination
1 DROP 0 -- 0.0.0.0/0 0.0.0.0/0 ctstate INVALID
2 ACCEPT 0 -- 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
3 ACCEPT 0 -- 0.0.0.0/0 0.0.0.0/0
4 ACCEPT 1 -- 0.0.0.0/0 0.0.0.0/0 icmptype 8 limit: avg 5/sec burst 5
5 ACCEPT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:22 /* ssh */
6 ACCEPT 6 -- 0.0.0.0/0 0.0.0.0/0 multiport dports 80,443 /* web */
$ iptables -S INPUT
-P INPUT DROP
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p icmp -m icmp --icmp-type 8 -m limit --limit 5/sec -j ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -m comment --comment ssh -j ACCEPT
-A INPUT -p tcp -m multiport --dports 80,443 -m comment --comment web -j ACCEPT
Learn to read the -L table: "prot" is the protocol number (6 is tcp, 1 is icmp, 0 is "any"), the third rule has a blank where "-i lo" is (-L only shows interface names with -v), and the conditions are hidden at the far right. With -v you also get the columns "pkts" and "bytes", the counters: how many packets and bytes each rule has matched since it was created or zeroed. Counters are the best debugging tool iptables has: send a test packet and see which rule's number went up.
$ iptables -L INPUT -n -v --line-numbers
Chain INPUT (policy DROP 14 packets, 840 bytes)
num pkts bytes target prot opt in out source destination
1 4 235 ACCEPT 0 -- * * 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
2 0 0 ACCEPT 0 -- lo * 0.0.0.0/0 0.0.0.0/0
3 4 216 ACCEPT 6 -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22
4 0 0 ACCEPT 6 -- * * 0.0.0.0/0 0.0.0.0/0 multiport dports 80,443
The header "policy DROP 14 packets, 840 bytes" is the number of packets that matched NO rule and fell to the policy: a counter that grows when something is being refused. The other tables: iptables -t nat -L -n -v, -t mangle, -t raw (always with -t, because the default is filter). A rule you added to nat will never show up in a plain iptables -L, which is one of the oldest "where did my rule go" questions.
Counters can be reset with iptables -Z (all chains) or iptables -Z INPUT 3 (one rule). One observation from the lab, with no explanation: on this nf_tables build, after a series of inserts, deletes and replaces, "iptables -Z INPUT" and plain "iptables -Z" failed every time with
iptables v1.8.10 (nf_tables): RULE_REPLACE failed (Invalid argument): rule in chain INPUT
while "iptables -Z INPUT 3" (one rule at a time) kept working, and after the chain was flushed and rebuilt, -Z was fine again. If you see it, zero rule by rule, or rebuild the chain from a saved file.
Adding, inserting, deleting, replacing: order is everything
Rules are read from the top. The first rule that matches a packet decides (for ACCEPT, DROP and REJECT; LOG and jumps behave differently), and the rest are never read. Four commands to know, and two letters that people confuse:
iptables -A CHAIN RULE APPEND at the end
iptables -I CHAIN [N] RULE INSERT at position N (default 1, the top)
iptables -D CHAIN N DELETE rule number N (or -D CHAIN RULE with the rule's exact text)
iptables -R CHAIN N RULE REPLACE rule number N
iptables -F [CHAIN] FLUSH (delete all rules; the policy stays as it is)
iptables -N NAME / -X NAME create / delete a user chain
iptables -P CHAIN TARGET set the policy
Here is why the order matters. A DROP for one address is inserted ABOVE the rule that accepts ssh. The ssh rule is still there and still correct, and that address can no longer connect:
$ iptables -I INPUT 3 -p tcp --dport 22 -s 192.168.60.20 -j DROP
$ iptables -L INPUT -n --line-numbers
Chain INPUT (policy DROP)
num target prot opt source destination
1 ACCEPT 0 -- 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
2 ACCEPT 0 -- 0.0.0.0/0 0.0.0.0/0
3 DROP 6 -- 192.168.60.20 0.0.0.0/0 tcp dpt:22
4 ACCEPT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:22
5 ACCEPT 6 -- 0.0.0.0/0 0.0.0.0/0 multiport dports 80,443
$ probe c1 192.168.60.1 22
c1 -> 192.168.60.1 port 22 no answer (timeout)
$ iptables -D INPUT 3
$ probe c1 192.168.60.1 22
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
Put the exceptions ABOVE the general rules: the specific "block this one address" at the top, the broad "everybody may use ssh" below. The reverse (an -A DROP appended below an ACCEPT that already matches) never fires, which is the most common "my block rule does nothing" bug. The counters show it: the DROP rule stays at 0 packets.
Be careful with the numbers. Rule numbers shift after every delete. If you delete 3 and then 4, you deleted the original 3 and the original 5. Always list again before the next numbered command, or delete by the full rule text, which is immune to renumbering:
$ iptables -R INPUT 4 -p tcp -m multiport --dports 80,443,8080 -j ACCEPT
$ iptables -D INPUT -p tcp -m multiport --dports 80,443,8080 -j ACCEPT
Check whether a rule exists, in a way a script can use, with -C (exit status 0 means yes):
$ iptables -C INPUT -p tcp --dport 22 -j ACCEPT ; echo "rc=$?"
rc=0
$ iptables -C INPUT -p tcp --dport 2222 -j ACCEPT ; echo "rc=$?"
iptables: Bad rule (does a matching rule exist in that chain?).
rc=1
That makes "add it only if it is not there already" a one-liner: iptables -C ... 2>/dev/null || iptables -A ... Every iptables command changes the running rules at once and none of it survives a reboot; see the section on saving.
Policy DROP, and the rule everybody forgets
A default-deny firewall sets the INPUT policy to DROP and then allows what is needed. The order in which you do it is how people lock themselves out. Here policy DROP is set FIRST, with nothing allowed yet. In the lab the client probes the machine, and not even ssh answers:
$ iptables -P INPUT DROP
$ probe c1 192.168.60.1 22 80
c1 -> 192.168.60.1 port 22 no answer (timeout)
c1 -> 192.168.60.1 port 80 no answer (timeout)
On a real remote machine, your ssh session just froze, and so did every new one. So the safe order is always: write the allow rules first, set the DROP policy last (or load everything in one atomic step, see below).
Now the rule everybody forgets. After allowing loopback and ssh, the machine can be reached, but it cannot reach out: an outbound connection sends a SYN, and the REPLY arrives in INPUT, where nothing allows it. Here the firewall host tries to connect to a service on the outside host:
$ iptables -A INPUT -i lo -j ACCEPT
$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT
$ timeout 4 bash -c 'exec 3<>/dev/tcp/198.51.100.50/9100 && head -n1 <&3' ; echo "rc=$?"
rc=124
Replies are dropped. The fix is the rule that every stateful firewall starts with: accept packets that belong to a connection that already exists. It uses connection tracking (conntrack), which remembers each connection and its state:
$ iptables -I INPUT 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$ timeout 4 bash -c 'exec 3<>/dev/tcp/198.51.100.50/9100 && head -n1 <&3' ; echo "rc=$?"
hello from outside
rc=0
The states: NEW (the first packet of a connection), ESTABLISHED (both directions have been seen), RELATED (a new connection tied to an existing one, like an ICMP error or an FTP data channel), INVALID (does not fit anything). Put the ESTABLISHED,RELATED rule at the TOP: almost all traffic is established, and the sooner a packet matches, the less work the firewall does. An older way, -m state --state, still works and does the same; use conntrack.
After the first rules, the lab's table with the counters, then the clients that should and should not get in:
$ iptables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
$ probe c1 192.168.60.1 22 80 443 3306 8080
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.20
c1 -> 192.168.60.1 port 443 open: port 443: you are 192.168.60.20
c1 -> 192.168.60.1 port 3306 no answer (timeout)
c1 -> 192.168.60.1 port 8080 no answer (timeout)
A sensible baseline, loaded in one go
Typing rules one at a time is fine for learning and wrong for production, because between two commands the machine is in a half-finished state. Write the whole rule set in a file in the iptables-save format and load it with iptables-restore, which applies it ATOMICALLY: all of it, or none of it. This is the baseline file used in the lab, /root/rules.v4:
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p icmp -m icmp --icmp-type echo-request -m limit --limit 5/second -j ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -m comment --comment "ssh" -j ACCEPT
-A INPUT -p tcp -m multiport --dports 80,443 -m comment --comment "web" -j ACCEPT
COMMIT
Read the format: a "*filter" line starts a table, ":CHAIN POLICY [packets:bytes]" lines declare the chains and set the policies, the -A lines are exactly what you would type after "iptables", and COMMIT ends the table and applies it. Check it before you load it, with --test:
$ iptables-restore --test < /root/rules.v4 ; echo "rc=$?"
rc=0
$ iptables-restore < /root/rules.v4 ; echo "rc=$?"
rc=0
$ probe c1 192.168.60.1 22 80 3306 ; ip netns exec c1 ping -c1 -W1 192.168.60.1 | grep received
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.20
c1 -> 192.168.60.1 port 3306 no answer (timeout)
1 packets transmitted, 1 received, 0% packet loss, time 0ms
What iptables-restore says to mistakes, each one made on purpose:
$ iptables-restore --test < bad1.v4 # "--dprot" instead of "--dport"
iptables-restore v1.8.10 (nf_tables): unknown option "--dprot"
Error occurred at line: 9
Try `iptables-restore -h' or 'iptables-restore --help' for more information.
$ iptables-restore --test < bad3.v4 # the COMMIT line is missing
iptables-restore: COMMIT expected at line 11
$ iptables-restore --test < bad2.v4 # "-j ACCEPTT": no error at all!
rc=0
$ iptables-restore < bad2.v4
iptables-restore v1.8.10 (nf_tables): Chain 'ACCEPTT' does not exist
Error occurred at line: 11
rc=2
$ iptables-save | grep -c '^-A INPUT' # and the running rules were untouched
6
Two lessons. First, --test is not a complete check: a jump to a chain that does not exist (a typo in a target) passes, because "ACCEPTT" could be the name of a user chain, and only the real load notices. Second, a failed restore changes NOTHING: the old rules stayed in force, which is why restore beats a script of separate iptables commands.
The format also carries the counters if you ask for them (iptables-restore -c), and "iptables-restore -n" (noflush) adds to the existing rules instead of replacing them (not run in the lab).
One more thing about restore, which the lab found the hard way: it REPLACES every table that appears in the file. This baseline contains only the filter table, with no FORWARD rules, so loading it wiped the FORWARD rules added by hand during an earlier experiment, and the router stopped passing replies (the clients' connections timed out, while the conntrack table showed the reply arriving and the counter of the FORWARD policy climbing). A rule file must contain EVERYTHING for the tables it names, or the rules you leave out disappear. The mirror image: a file that has no *nat section leaves the nat table alone.
Matching: ports, state, rates, lists and owners
A rule is a list of conditions, all of which must be true, and then a target. The basic ones come for free:
-p tcp|udp|icmp|all the protocol (needed before --dport)
-s ADDR[/MASK] -d ADDR source and destination (prefix with ! to negate)
-i eth0 -o eth1 the interface the packet came in or goes out of (-i only in INPUT, FORWARD, PREROUTING; -o in FORWARD, OUTPUT, POSTROUTING)
--dport 22 --sport 1024:65535 ports, ranges (only after -p tcp or -p udp: without -p, iptables says unknown option "--dport")
Extra matches are loaded with -m. The ones that earn their keep:
multiport and comment. Many ports in one rule, and a note to the next person. The comment is shown in -L and survives save and restore:
-A INPUT -p tcp -m multiport --dports 80,443,8080 -m comment --comment "web" -j ACCEPT
limit. Allow only so many matches per time unit, with a short burst. A token bucket: here 2 per minute, burst of 2, so the first two connections pass and the third and later are not matched by the ACCEPT:
$ iptables -I INPUT 5 -p tcp --dport 9000 -m limit --limit 2/min --limit-burst 2 -j ACCEPT
$ for i in 1 2 3 4 ; do probe c1 192.168.60.1 9000 ; done
c1 -> 192.168.60.1 port 9000 open: port 9000: you are 192.168.60.20
c1 -> 192.168.60.1 port 9000 open: port 9000: you are 192.168.60.20
c1 -> 192.168.60.1 port 9000 no answer (timeout)
c1 -> 192.168.60.1 port 9000 no answer (timeout)
Note that the limit is for the RULE, shared by all clients. It protects the machine, and it also lets one noisy client use up the allowance of everybody else. For a per-address limit use hashlimit (not run in the lab).
recent. Remember addresses, and act on how often they come back. The classic: slow down ssh brute force by dropping an address after too many NEW connections in 60 seconds. Two rules, in this order (the check first, the record after it):
$ iptables -I INPUT 5 -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --name SSH --update --seconds 60 --hitcount 4 -j DROP
$ iptables -I INPUT 6 -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --name SSH --set
$ for i in 1 2 3 4 5 ; do probe c1 192.168.60.1 22 ; done
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
c1 -> 192.168.60.1 port 22 no answer (timeout)
$ probe c1 192.168.60.1 22 -s 192.168.60.21 # a different address is not affected
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.21
$ cat /proc/net/xt_recent/SSH
src=192.168.60.20 ttl: 64 last_seen: 4735499328 oldest_pkt: 7 4735496969, 4735497042, ...
src=192.168.60.21 ttl: 64 last_seen: 4735500327 oldest_pkt: 1 4735500327
The list is in /proc/net/xt_recent/NAME, and you can empty it with echo / > /proc/net/xt_recent/SSH (or add an address with echo +ADDR). The fifth connection is the one that is dropped, so count carefully when you set --hitcount. A real SSH setup would also keep a legitimate user's retry in mind; keys, fail2ban or a non-default port do more for the same problem.
connlimit. Limit how many connections one address can have open at the same time. A backend that holds each connection for six seconds, and a rule that allows two per source address; the third is turned away with a reset:
$ iptables -I INPUT 5 -p tcp --dport 8081 -m connlimit --connlimit-above 2 --connlimit-mask 32 -j REJECT --reject-with tcp-reset
$ iptables -I INPUT 6 -p tcp --dport 8081 -j ACCEPT
$ ip netns exec c1 python3 many.py # four simultaneous connections
connection 1: open: holding
connection 2: open: holding
connection 3: Connection refused
connection 4: Connection refused
(after the first two end, a new try works again.) --connlimit-mask 32 counts per single address; 24 would count per /24 network.
set (ipset). One rule can match against a whole set of addresses, networks or ports held in a hash table. That is the right tool for a block list, a country list or a thousand office addresses: the lookup costs the same for ten entries and for a hundred thousand, and you change the set without touching the firewall rules.
$ ipset create blocklist hash:net
$ ipset add blocklist 192.168.60.21
$ ipset add blocklist 203.0.113.0/24
$ ipset list blocklist
Name: blocklist
Type: hash:net
Revision: 7
Header: family inet hashsize 1024 maxelem 65536 bucketsize 12 initval 0x2e1c0dfb
Size in memory: 552
References: 0
Number of entries: 2
Members:
192.168.60.21
203.0.113.0/24
$ iptables -I INPUT 1 -m set --match-set blocklist src -j DROP
$ probe c1 192.168.60.1 80 ; probe c1 192.168.60.1 80 -s 192.168.60.21
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.20
c1 -> 192.168.60.1 port 80 no answer (timeout)
$ ipset del blocklist 192.168.60.21 ; probe c1 192.168.60.1 80 -s 192.168.60.21
c1 -> 192.168.60.1 port 80 open: port 80: you are 192.168.60.21
One rule, and removing an entry took effect at once. The set must exist BEFORE the rule that uses it (so at boot, restore ipset first), and it is saved with ipset save, not iptables-save:
$ ipset save blocklist
create blocklist hash:net family inet hashsize 1024 maxelem 65536 bucketsize 12 initval 0x2e1c0dfb
add blocklist 203.0.113.0/24
$ ipset destroy nosuch
ipset v7.19: The set with the given name does not exist
owner. In OUTPUT only: match the user (or group) that creates the packet. A way to stop one service account from calling out:
$ iptables -A OUTPUT -m owner --uid-owner 1234 -p tcp --dport 9100 -j REJECT
$ setpriv --reuid 1234 --regid 1234 --clear-groups timeout 3 bash -c 'exec 3<>/dev/tcp/198.51.100.50/9100 && head -n1 <&3'
bash: connect: Connection refused
$ timeout 3 bash -c 'exec 3<>/dev/tcp/198.51.100.50/9100 && head -n1 <&3' # root is not matched
hello from outside
Others, not run in the lab: -m iprange (a start and an end address), -m time (only on certain days and hours), -m hashlimit (rate limits per address), -m string (match text in a packet; slow), -m mark and -m connmark (packet marks set earlier), -m addrtype (local, broadcast), -m tcp --tcp-flags (flag combinations). "iptables -m NAME -h" prints the options of any of them.
DROP, REJECT and what the other side sees
Both turn a packet down. What the sender experiences is very different, and that is how you recognize them from the other side:
$ iptables -A INPUT -p tcp --dport 3306 -j REJECT
$ iptables -A INPUT -p tcp --dport 8080 -j REJECT --reject-with tcp-reset
$ iptables -A INPUT -p tcp --dport 9000 -j REJECT --reject-with icmp-host-prohibited
$ probe c1 192.168.60.1 3306 8080 9000 6000
c1 -> 192.168.60.1 port 3306 Connection refused
c1 -> 192.168.60.1 port 8080 Connection refused
c1 -> 192.168.60.1 port 9000 No route to host
c1 -> 192.168.60.1 port 6000 no answer (timeout)
Port 6000 matched nothing and fell to the policy DROP: silence, the client waits for its timeout. The three REJECTs answer at once. The default (icmp-port-unreachable) and tcp-reset both end in "Connection refused" for the client, and --reject-with icmp-host-prohibited gives the odd "No route to host". Which to use:
DROP for the internet side: it gives a scanner nothing, and costs it a timeout per port. It makes YOUR debugging slower for the same reason: a mistake looks like a hang.
REJECT for your own network: users and programs get an immediate, honest "refused" instead of waiting thirty seconds. Also right when a service must tell its clients "no" quickly.
A default policy of DROP, with REJECT rules for the ports your own LAN uses, is a common compromise.
"No route to host" on a machine that is reachable usually does not mean a routing problem: it is a firewall REJECT with icmp-host-prohibited (firewalld uses it by default). "Connection refused" means a reject or nothing listening; "timeout" means a drop, a dead host, or a path problem.
Your own chains
A long rule set is easier to read in pieces. Make a chain, send packets to it with a jump (-j NAME), and put rules in it. When the chain has no verdict for a packet, processing returns to the rule after the jump:
$ iptables -N LOGDROP
$ iptables -A LOGDROP -j NFLOG --nflog-group 5 --nflog-prefix "BAD "
$ iptables -A LOGDROP -j DROP
$ iptables -A INPUT -p tcp --dport 3306 -j LOGDROP
Now the one thing that confuses people: a chain cannot be deleted while a rule points to it, and the error is not friendly:
$ iptables -X LOGDROP
iptables v1.8.10 (nf_tables): CHAIN_DEL failed (Device or resource busy): chain LOGDROP
$ iptables -D INPUT -p tcp --dport 3306 -j LOGDROP ; iptables -F LOGDROP ; iptables -X LOGDROP ; echo "rc=$?"
rc=0
"Device or resource busy" here means "something still jumps to it". Order of removal: first the rules that point at the chain, then flush it (-F NAME), then delete it (-X NAME). A tidy convention: one chain per purpose (SSH_RULES, BLOCKLIST, LOGDROP), and a main INPUT chain that is nothing but a readable list of jumps.
Logging and tracing
The LOG target writes a line into the kernel log for each matching packet, with the interface, addresses, ports and flags, and the prefix you give it. It does not stop processing: put it directly above the DROP it documents, or inside a LOGDROP chain as above.
iptables -A INPUT -m limit --limit 5/min -j LOG --log-prefix "IPT-DROP: " --log-level 4
Always put a limit on a LOG rule, or a port scan fills the disk. The lines show up in journalctl -k or /var/log/kern.log and dmesg, and are easy to filter on the prefix. (The one thing the lab could not show: inside a container's own network namespace, LOG writes nothing to the log; dmesg counted 0 lines for the prefix. A real server logs normally.) A more flexible alternative is NFLOG, which sends the packet to user space instead of the kernel log, where a program (ulogd, or tcpdump) reads it. That works anywhere, including namespaces, and was how the lab captured the dropped packets:
$ iptables -A INPUT -p tcp --dport 3306 -j NFLOG --nflog-group 5 --nflog-prefix "DROPPED "
$ tcpdump -i nflog:5 -n -l -c 2
listening on nflog:5, link-type NFLOG (Linux netfilter log messages), snapshot length 262144 bytes
19:20:12.687390 IP 192.168.60.20.57732 > 192.168.60.1.3306: Flags [S], seq 2915574604, win 64240, options [mss 1460,sackOK,TS val 77275665 ecr 0,nop,w
19:20:13.711348 IP 192.168.60.20.57732 > 192.168.60.1.3306: Flags [S], seq 2915574604, win 64240, ...
2 packets captured
Two SYNs one second apart from the same port: the client retrying a connection that is being dropped. That repeat is the classic signature of a drop, as seen from the server.
When you cannot see why a packet is treated the way it is, TRACE shows every rule it meets. It goes in the raw table so that it sees the packet first, and the output is read with xtables-monitor:
$ iptables -t raw -A PREROUTING -p tcp --dport 22 -s 192.168.60.20 -j TRACE
$ xtables-monitor --trace
raw:PREROUTING:rule:0x3:CONTINUE -4 -t raw -A PREROUTING -s 192.168.60.20/32 -p tcp -m tcp --dport 22 -j TRACE
raw:PREROUTING:policy:ACCEPT
nat:PREROUTING:policy:ACCEPT
filter:INPUT:rule:0x8:ACCEPT -4 -t filter -A INPUT -p tcp -m tcp --dport 22 -m comment --comment ssh -j ACCEPT
Each line is a table, a chain, and what decided: a rule (with its text), or the policy. This is the answer to "which of my 80 rules is it hitting". Remove it afterwards (iptables -t raw -F PREROUTING): a TRACE rule left behind is noisy. (On the legacy backend the same job is done with the kernel log; not run in the lab.)
IPv6: the firewall you forgot
iptables is IPv4 only. IPv6 has its own command, ip6tables, with its own tables and its own empty, wide-open defaults. The most common firewall hole on a modern server is a careful IPv4 rule set next to an IPv6 stack nobody configured. In the lab, the IPv4 firewall drops port 3306 and the same port, on the same machine, over IPv6:
$ probe c1 192.168.60.1 3306
c1 -> 192.168.60.1 port 3306 no answer (timeout)
$ probe c1 fd00:60::1 3306 22
c1 -> fd00:60::1 port 3306 open: port 3306: you are fd00:60::20
c1 -> fd00:60::1 port 22 open: port 22: you are fd00:60::20
$ ip6tables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
Wide open, as soon as an address exists. The fix is the same shape with one big difference: IPv6 needs ICMPv6 for basic operation (neighbor discovery is how a host finds its neighbors; without it, IPv6 stops working), so do NOT drop it:
$ ip6tables -P INPUT DROP
$ ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$ ip6tables -A INPUT -i lo -j ACCEPT
$ ip6tables -A INPUT -p ipv6-icmp -j ACCEPT
$ ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
$ probe c1 fd00:60::1 3306 22 80
c1 -> fd00:60::1 port 3306 no answer (timeout)
c1 -> fd00:60::1 port 22 open: port 22: you are fd00:60::20
c1 -> fd00:60::1 port 80 no answer (timeout)
$ ip netns exec c1 ping -6 -c1 -W1 fd00:60::1 | grep received
1 packets transmitted, 1 received, 0% packet loss, time 0ms
There is a matching ip6tables-save, ip6tables-restore and a rules.v6 file next to rules.v4. If you have no use for IPv6, either configure it as above or turn it off in the system, but never leave it unconsidered. Test the machine from outside with nmap -6 as well as nmap -4.
Routing and NAT: MASQUERADE, SNAT, DNAT, REDIRECT
A machine can also act as a router between two networks. Three things have to be true, and iptables is only one of them:
1. the kernel forwards packets: sysctl net.ipv4.ip_forward=1 (and the same under /etc/sysctl.d to make it stay; IPv6 has its own, net.ipv6.conf.all.forwarding). The lab container already had it on; check yours, the default on a plain server is 0.
2. the FORWARD chain allows the traffic. Its default policy is where a router quietly blocks everything.
3. if the networks are not reachable from each other (a private LAN and the internet), NAT rewrites the addresses.
Forwarding with FORWARD policy DROP, step by step. A LAN client tries to reach a server on the outside network through the firewall:
$ probe c1 198.51.100.50 9000
c1 -> 198.51.100.50 port 9000 no answer (timeout)
$ iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$ iptables -A FORWARD -i eth1 -o eth2 -j ACCEPT
$ probe c1 198.51.100.50 9000
c1 -> 198.51.100.50 port 9000 open: outside: you are 192.168.60.20
Same shape as INPUT: allow the established replies, then allow the direction you want (-i inside, -o outside). The server saw the LAN address 192.168.60.20. That works only when the outside host knows a way back to the LAN. Take away the outside host's route to the LAN, as the internet would have none for a private address, and it breaks:
$ ip netns exec c2 ip route
198.51.100.0/24 dev c0 proto kernel scope link src 198.51.100.50
$ probe c1 198.51.100.50 9000
c1 -> 198.51.100.50 port 9000 no answer (timeout)
MASQUERADE rewrites the source of everything leaving through an interface to that interface's own address, and remembers it so the replies find their way back. It lives in the nat table, in POSTROUTING:
$ iptables -t nat -A POSTROUTING -o eth2 -j MASQUERADE
$ probe c1 198.51.100.50 9000
c1 -> 198.51.100.50 port 9000 open: outside: you are 198.51.100.1
The outside server sees 198.51.100.1, the firewall, and not the client. Look at it in the connection table (conntrack -L). Each entry has two halves: the original direction (the LAN client talking to 198.51.100.50) and the reply direction, where the answer is addressed to 198.51.100.1, the rewritten address:
$ iptables -t nat -L POSTROUTING -n -v
Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
1 60 MASQUERADE 0 -- * eth2 0.0.0.0/0 0.0.0.0/0
$ conntrack -L -p tcp | grep 9000
tcp 6 119 TIME_WAIT src=192.168.60.20 dst=198.51.100.50 sport=60738 dport=9000 src=198.51.100.50 dst=198.51.100.1 sport=9000 dport=60738 [ASSURED] mark=0 use=1
SNAT does the same with a fixed address you name. It is a little faster (MASQUERADE looks up the interface's address for each new connection) and right for a static public address, whereas MASQUERADE is right for an address that changes (DHCP, PPPoE):
$ iptables -t nat -F POSTROUTING
$ iptables -t nat -A POSTROUTING -o eth2 -s 192.168.60.0/24 -j SNAT --to-source 198.51.100.1
$ probe c1 198.51.100.50 9000
c1 -> 198.51.100.50 port 9000 open: outside: you are 198.51.100.1
DNAT is port forwarding: the other direction, changing the DESTINATION of packets that arrive from outside, so that a service on a private machine is reachable on the firewall's public address. It is in PREROUTING (before the routing decision, because it changes where the packet goes). Here the web server on the LAN client, port 8000, is published on the firewall's port 8443:
$ probe c2 198.51.100.1 8443
c2 -> 198.51.100.1 port 8443 no answer (timeout)
$ iptables -t nat -A PREROUTING -i eth2 -p tcp --dport 8443 -j DNAT --to-destination 192.168.60.20:8000
$ probe c2 198.51.100.1 8443
c2 -> 198.51.100.1 port 8443 no answer (timeout)
Still nothing: the packet now heads for 192.168.60.20, so it goes through FORWARD, where the policy is DROP. NAT never replaces the filter rules; they are separate. Allow it, and say it is the DNAT'ed traffic:
$ iptables -A FORWARD -i eth2 -o eth1 -d 192.168.60.20 -p tcp --dport 8000 -m conntrack --ctstate DNAT -j ACCEPT
$ probe c2 198.51.100.1 8443
c2 -> 198.51.100.1 port 8443 open: lan web: you are 198.51.100.50
$ iptables -t nat -L PREROUTING -n -v
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
4 240 DNAT 6 -- eth2 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8443 to:192.168.60.20:8000
Note the filter rule matches the destination AFTER the rewrite (192.168.60.20 and port 8000, not 8443). And the LAN host saw the true client address, 198.51.100.50, because the way back runs through the firewall. (If the LAN host's reply would NOT go back through the firewall, the connection hangs: the answer comes from the wrong address. A host in the LAN should use the firewall as its gateway.)
REDIRECT is DNAT to the machine itself: the destination becomes one of its own ports. The use is a transparent proxy or sending a privileged port to an unprivileged service. Every LAN client that asks for port 80 here is sent to port 8080:
$ iptables -t nat -A PREROUTING -i eth1 -p tcp --dport 80 -j REDIRECT --to-ports 8080
$ probe c1 192.168.60.1 80
c1 -> 192.168.60.1 port 80 no answer (timeout)
$ iptables -I INPUT 5 -p tcp --dport 8080 -j ACCEPT ; probe c1 192.168.60.1 80
c1 -> 192.168.60.1 port 80 open: port 8080: you are 192.168.60.20
The lesson is in the first probe. PREROUTING comes BEFORE INPUT, so by the time the INPUT chain sees the packet, it is for port 8080 and the "allow 80" rule no longer matches. Rules in the filter table see the NAT'ed packet. Allow the port the packet turns into.
Saving, restoring and making it permanent
Every command so far changed the running kernel only. A reboot returns the machine to its boot-time state, usually no firewall. Making it permanent is a matter of saving the rules in a file and loading it at boot:
$ iptables-save > /tmp/before.v4
$ head -5 /tmp/before.v4
# Generated by iptables-save v1.8.10 (nf_tables) on Thu Oct 8 19:20:29 2026
*filter
:INPUT DROP [24:1440]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [78:4716]
That file IS the rule set: keep it, version it, copy it to another server. The numbers in brackets after each chain are the packet and byte counters of the policy.
On Debian and Ubuntu, the iptables-persistent package loads /etc/iptables/rules.v4 and rules.v6 at boot, and netfilter-persistent saves them:
$ netfilter-persistent save
run-parts: executing /usr/share/netfilter-persistent/plugins.d/15-ip4tables save
run-parts: executing /usr/share/netfilter-persistent/plugins.d/25-ip6tables save
$ ls -l /etc/iptables/
-rw-r----- 1 root root 555 Oct 8 19:20 rules.v4
-rw-r----- 1 root root 359 Oct 8 19:20 rules.v6
$ netfilter-persistent --help
Usage: /usr/sbin/netfilter-persistent (start|stop|restart|reload|flush|save)
Remember that "save" writes what is running NOW over the file: save only when the rules are right, never in the middle of an experiment (and keep the previous copy). On the Red Hat family the package is iptables-services: systemctl enable iptables, rules in /etc/sysconfig/iptables, saved with service iptables save (not run in the lab; and firewalld must not be enabled at the same time). For ipset, the sets must be restored before the rules that name them (ipset save > /etc/iptables/ipsets, loaded first).
Changing a firewall without locking yourself out
The usual disaster: you change the rules over ssh, one of them cuts your own session, and the way back is a trip to the console. Habits that prevent it:
Load a whole rule file with iptables-restore (atomic), not a stack of separate commands. Check it with --test first.
Allow ssh, and your own address, BEFORE the policy goes to DROP. Better still, keep an explicit "allow ssh from the office network" rule at the very top.
Test from a SECOND connection before closing the first one.
Keep a console in reach (hypervisor, cloud serial console, iDRAC).
Save the rules to a file BEFORE the change. A reboot does not restore them unless you saved them, which is also the cheapest rollback: iptables-restore < before.v4.
Use iptables-apply. It loads a new rule file, then asks "can you open a NEW connection?" and puts the OLD rules back if you do not answer in time. If the new rules locked you out, you cannot answer, and the timeout does the rollback. In the lab, applying a file that blocks everything, with nobody answering:
$ iptables -S INPUT | grep -c '^-A INPUT'
6
$ iptables-apply -t 5 /root/risky.v4
Applying new iptables rules from '/root/risky.v4'... done.
Can you establish NEW connections to the machine? (y/N)
Timeout! Something happened (or did not). Better play it safe...
Reverting to old iptables rules... done.
$ iptables -S INPUT | grep -c '^-A INPUT' ; probe c1 192.168.60.1 22
6
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
and the same file when the operator types y (the new rules stay, here they leave nothing open, which is what the client sees):
$ echo y | iptables-apply -t 5 /root/risky.v4
... then my job is done. See you next time.
$ iptables -S INPUT | grep -c '^-A INPUT' ; probe c1 192.168.60.1 22
1
c1 -> 192.168.60.1 port 22 no answer (timeout)
The option -t sets the seconds you have (the default is 10; give yourself 30 on a slow link), and -w FILE saves the rules that were good before. There is an ip6tables-apply for IPv6. Its -c option takes the PATH of a program to run (and prints "runcmd not executable" when given a command with arguments), so put the commands in a script. Over a link that is slow or flaky, use a rule that does not depend on your session at all: an at job or a cron entry that restores the saved file in ten minutes (at now + 10 minutes with iptables-restore < /root/good.v4; not run in the lab), and remove it when all is well.
Two backends: nf_tables and legacy
Back to the two programs. iptables-nft and iptables-legacy put their rules in different places in the kernel. Both sets of rules are active at the same time, and neither command can see the other's. In the lab, the nft rules ALLOWED ssh. One rule is then added with the legacy command:
$ iptables -S INPUT | grep 'dport 22'
-A INPUT -p tcp -m tcp --dport 22 -m comment --comment ssh -j ACCEPT
$ probe c1 192.168.60.1 22
c1 -> 192.168.60.1 port 22 open: port 22: you are 192.168.60.20
$ iptables-legacy -A INPUT -p tcp --dport 22 -j DROP
$ iptables -S INPUT 2>&1 | grep -E 'Warning|dport 22'
# Warning: iptables-legacy tables present, use iptables-legacy to see them
-A INPUT -p tcp -m tcp --dport 22 -m comment --comment ssh -j ACCEPT
$ probe c1 192.168.60.1 22
c1 -> 192.168.60.1 port 22 no answer (timeout)
$ iptables-legacy-save | grep '^-A'
-A INPUT -p tcp -m tcp --dport 22 -j DROP
The ordinary "iptables -S" shows ssh allowed, and ssh does not work: a rule is blocking it that the command does not show. The only hint is the Warning line at the top ("iptables-legacy tables present, use iptables-legacy to see them"), which iptables prints once a legacy table exists. When rules behave in a way the listing does not explain, ask both: iptables-legacy-save and iptables-nft-save, and nft list ruleset. After the legacy rule was flushed (iptables-legacy -F INPUT) ssh worked again.
The causes in real life: an old script that calls iptables-legacy, an application (some container runtimes or VPN tools) that brings its own iptables, or a distribution where the alternative was switched after rules were created. The rule is: use ONE backend, check which with iptables -V, and make the alternative match (update-alternatives --set iptables /usr/sbin/iptables-nft, and the same for ip6tables, arptables, ebtables). Warning line seen with a rule set that you KNOW is in nft? Something wrote legacy rules once; flush them with iptables-legacy -F and iptables-legacy -t nat -F, and switch the old software.
The related fact about nf_tables mode: it can be looked at with the nft command, but the table is marked as owned by iptables, and the warning is serious:
$ nft list chain ip filter INPUT
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
chain INPUT {
type filter hook input priority filter; policy drop;
ct state related,established counter packets 0 bytes 0 accept
...
Look, do not edit. Use nft for reading and for its own tables (see the nftables guide); change the iptables-nft tables with iptables.
Translating to nftables
iptables is being retired slowly, and moving to nftables rules is one command per rule, to learn the new syntax or to migrate a whole file:
$ iptables-translate -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -m limit --limit 3/min -j ACCEPT
nft 'add rule ip filter INPUT tcp dport 22 ct state new limit rate 3/minute burst 5 packets counter accept'
$ iptables-translate -t nat -A POSTROUTING -o eth2 -j MASQUERADE
nft 'add rule ip nat POSTROUTING oifname "eth2" counter masquerade'
$ iptables-restore-translate -f /root/rules.v4 | head -9
# Translated by iptables-restore-translate v1.8.10 on Thu Oct 8 19:20:29 2026
add table ip filter
add chain ip filter INPUT { type filter hook input priority 0; policy drop; }
add chain ip filter FORWARD { type filter hook forward priority 0; policy drop; }
add chain ip filter OUTPUT { type filter hook output priority 0; policy accept; }
add rule ip filter INPUT ct state invalid counter drop
add rule ip filter INPUT ct state related,established counter accept
add rule ip filter INPUT iifname "lo" counter accept
add rule ip filter INPUT icmp type echo-request limit rate 5/second burst 5 packets counter accept
Read the output as a study aid, and review it before use (some matches have no equivalent, and the tool says so). Rules for ipset-based matches become nftables sets, which are built in.
Cheat sheet
iptables -L -n -v --line-numbers [-t TABLE] list with counters and numbers (-n: no name lookups)
iptables -S [CHAIN] the rules as commands
iptables-save > FILE / iptables-restore < FILE dump and load (atomic). --test checks the syntax
iptables -A CHAIN RULE / -I CHAIN N RULE append / insert at N
iptables -D CHAIN N / -R CHAIN N RULE delete / replace rule N (list again after every delete)
iptables -C CHAIN RULE does it exist (exit status)
iptables -F [CHAIN] / -X NAME / -N NAME / -Z flush / delete chain / new chain / zero counters
iptables -P INPUT DROP the policy: LAST, after the allow rules
-p tcp --dport 22 -s NET -d ADDR -i IF -o IF the basic matches
-m conntrack --ctstate NEW,ESTABLISHED,RELATED,INVALID
-m multiport --dports 80,443 -m comment --comment "text"
-m limit --limit 5/min --limit-burst 5 rate (shared)
-m recent --name N --set | --update --seconds S --hitcount H
-m connlimit --connlimit-above N --connlimit-mask 32
-m set --match-set NAME src|dst ipset lists
-m owner --uid-owner UID OUTPUT only
-j ACCEPT | DROP | REJECT [--reject-with tcp-reset] | LOG | NFLOG | RETURN | CHAIN
-t nat -A POSTROUTING -o IF -j MASQUERADE | -j SNAT --to-source IP
-t nat -A PREROUTING -i IF -p tcp --dport P -j DNAT --to-destination IP:PORT
-t nat -A PREROUTING -i IF -p tcp --dport P -j REDIRECT --to-ports Q
-t raw -A PREROUTING ... -j TRACE ; xtables-monitor --trace
ip6tables ... everything above, for IPv6
ipset create NAME hash:net | add NAME ADDR | del | list | save
iptables-apply -t SECONDS FILE apply, confirm, or roll back
netfilter-persistent save Debian/Ubuntu: write /etc/iptables/rules.v4 and .v6
iptables -V / update-alternatives --display iptables which backend
conntrack -L the connection table (with NAT)
iptables-translate RULE the same rule in nftables syntax
Common problems
My new DROP rule does nothing an ACCEPT above it matches first. iptables -L -n -v --line-numbers: the counter of the DROP rule stays at 0. Move it up with -I
I locked myself out console: iptables -F ; iptables -P INPUT ACCEPT, or restore the saved file. Next time: iptables-apply, or an at job that restores the old rules
Everything is open although rules exist the policy is ACCEPT and the rules are only exceptions. Or the rules are in the other backend (see the two backends section), or in another table (iptables -t nat -L), or IPv6
The server cannot reach anything after the firewall the ESTABLISHED,RELATED rule is missing: replies are dropped. Also check OUTPUT and DNS (UDP/TCP 53 to the resolver, answered via the same rule)
Rules are gone after a reboot never saved. netfilter-persistent save (Debian, Ubuntu) or service iptables save (RHEL)
iptables -L hangs for a long time DNS lookups of every address. Use -n
"unknown option --dport" a missing -p tcp (or udp) before it, or a typo ("--dprot")
"Couldn't load match" / "No chain/target/match by that name" a typo in a match or target, or a missing kernel module, or a chain that does not exist: check with iptables -S and iptables -N
Chain 'ACCEPTT' does not exist a typo in the target (-j). iptables-restore --test does NOT catch it: only the real load does
CHAIN_DEL failed (Device or resource busy) a rule still jumps to that chain: delete the jump, flush the chain, then delete it
Could not fetch rule set generation id: Permission denied (you must be root) run it as root (sudo)
iptables -Z fails with RULE_REPLACE failed seen in the lab on this nf_tables build after many edits; zero per rule (-Z CHAIN N) or rebuild the chain from a saved file
Rule exists, but shows nothing for a port that is blocked look at the other backend: iptables-legacy-save, nft list ruleset. "iptables-legacy tables present" in the output is the hint
A forwarded service does not work four places to check: ip_forward, the FORWARD chain, the nat rule (-t nat -L -n -v: are the counters going up?), and the way back (the LAN host must use the firewall as gateway)
DNAT rule matches but the connection hangs FORWARD does not allow the translated packet (match the NEW destination, port 8000, not 8443), or the reply does not return through the firewall
REDIRECT works only if I open the target port correct: the INPUT chain sees the packet AFTER NAT, with the new port
Docker published a port the firewall should block Docker inserts its own rules in the nat and FORWARD (DOCKER-USER chain is where yours go). Test with the container running (not run in the lab)
fail2ban and my rules disagree fail2ban adds and removes rules in its own chains and jumps to them from INPUT; do not flush the table underneath it, and load your rules before it starts (not run in the lab)
Another app is currently holding the xtables lock two iptables commands at once (legacy backend; a script running while you type): wait, or use iptables -w SECONDS. The lab could not reproduce this message, with either backend
Rate limit rule blocks everyone -m limit is shared by all clients of that rule. Use hashlimit (per address) or recent
IPv6 reachable although IPv4 is closed ip6tables was never set up: copy the baseline with ip6tables, and allow ICMPv6
conntrack table full ("nf_conntrack: table full, dropping packet" in dmesg) a busy server runs out of tracked connections: sysctl net.netfilter.nf_conntrack_max, or exempt heavy traffic in the raw table with -j NOTRACK/CT (not run in the lab)
Tips
Default deny, but build it in the right order: ESTABLISHED,RELATED first, loopback, ssh, your services, then the DROP policy last. Or load the whole file at once with iptables-restore.
Keep the rules in a file under version control, and load the file. A firewall that exists only in the kernel exists until the next reboot.
Always -n with -L. Always -v when debugging: the counters tell you which rule a packet really hit.
Put exceptions above the general rules. Re-list before every numbered delete.
Before a change on a remote machine: save the current rules, have a console ready, use iptables-apply or an at job to roll back.
Comment every rule that is not obvious (-m comment). Future you will not remember why 8443 is open.
Use ipset for lists longer than a handful of addresses.
Do ip6tables at the same time as iptables. Always. And allow ICMPv6.
Know which backend you are on (iptables -V) and never mix: if "iptables -S" and reality disagree, check iptables-legacy-save and nft list ruleset.
Test from the outside: probe from another machine with nmap, or with a client in the right network, and read the response: refused (a REJECT), timeout (a DROP), open.
Log with NFLOG or a limited LOG, and TRACE one packet when a rule set is too big to read.
When you have a choice for a new design, look at nftables (iptables-translate shows the way), or use firewalld or ufw on top, and do not edit by hand below them.