> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# iptables Fundamentals: Tables, Chains, Real Rules
- URL: https://www.pinglabz.com/iptables-linux/
- Published: 2026-08-19T17:42:21.000Z
- Updated: 2026-08-19T18:24:29.000Z
- Description: Tables, chains and rule order, demonstrated with real captures from a Debian host in a live lab: DROP against REJECT timing, conntrack, logging and NAT.
- Author: Jaime
- Tags: Linux, Firewall, Netfilter, Network Security, Labs

Every Linux box you have ever logged into was already running a packet filter. It had no rules in it, so it did nothing, but the machinery was there: five tables, a fixed set of chains hooked into defined points in the kernel's packet path, and a userspace tool called `iptables` that writes rules into them. Learning that tool is less about memorizing flags and more about knowing which chain a packet passes through and in what order, because a rule in the wrong chain is a rule that never fires.

This article is part of the [Linux networking commands guide](https://www.pinglabz.com/linux-networking-commands/). Everything below was captured on a Debian 13 host bridged into a CML topology. The host sits at 10.77.0.100 on `ens224`, an nginx server (WEB1) is two router hops away at 10.77.2.10, and a server running iperf3 on 5201 and a Python HTTP server on 8080 (SRV1) is three hops away at 10.77.3.10\. Every rule below is scoped to that lab interface, because the management NIC is how the session stays alive.

## The first thing to know: iptables is not iptables anymore

On Debian 13, and on every current mainstream distribution, the binary called `iptables` is a compatibility shim over nftables. It reports this itself if you look:

```
j@llmbits:~$ sudo iptables --version
iptables v1.8.11 (nf_tables)

j@llmbits:~$ ls -l /usr/sbin/iptables
lrwxrwxrwx 1 root root 26 Nov 20  2024 /usr/sbin/iptables -> /etc/alternatives/iptables

j@llmbits:~$ sudo update-alternatives --display iptables | head -5
iptables - auto mode
  link best version is /usr/sbin/iptables-nft
  link currently points to /usr/sbin/iptables-nft
  link iptables is /usr/sbin/iptables
  slave iptables-restore is /usr/sbin/iptables-restore
```

`(nf_tables)` in the version string is the part that matters. The command syntax you are about to learn is unchanged, and rules you write with it behave the way they always did, but they are stored in the nftables engine rather than the old x\_tables one. That has one practical consequence: rules written with `iptables` are visible to `nft`, and vice versa, so mixing the two tools on one host produces a ruleset that neither tool fully explains. More on that in [the nftables article](https://www.pinglabz.com/nftables-linux/).

## Tables and chains

A table is a group of chains that share a purpose. A chain is an ordered list of rules attached to a point in the kernel's packet path. You only ever need three of the five tables in practice.

filter

The default table, and the one you mean when you say "firewall." Chains: INPUT, OUTPUT, FORWARD. Accept, drop or reject packets.

This is where 95% of your rules live.

nat

Address and port translation. Chains: PREROUTING, INPUT, OUTPUT, POSTROUTING. Only the first packet of a flow is evaluated here; conntrack handles the rest.

DNAT in PREROUTING, SNAT and MASQUERADE in POSTROUTING.

mangle

Header rewriting: DSCP marking, TTL, TCP MSS clamping, and fwmark for policy routing. All five hook points available.

Where QoS classification and policy routing meet.

raw

Runs before connection tracking. Its one real use is `NOTRACK`, exempting high-volume traffic from conntrack to save table space.

Rare, but the only place to solve conntrack exhaustion.

security

SELinux context marking. If you are not writing MAC policy, you will never open it.

Ignore it.

Which chain a packet hits depends entirely on where it is going, and this is the single most common source of "my rule does nothing":

INPUT

Packets addressed to this host. An SSH session arriving at your server hits INPUT.

OUTPUT

Packets generated by this host. Your own `curl`, `ping` and DNS queries hit OUTPUT.

FORWARD

Packets routed *through* this host. Empty on a normal server, essential on a router, gateway or container host.

A default Debian install has all three chains present, all policies set to ACCEPT, and no rules at all:

```
j@llmbits:~$ sudo iptables -L -n -v
Chain INPUT (policy ACCEPT 907K packets, 80M 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
```

`-L` lists, `-n` skips DNS and service-name resolution (always use it, otherwise listing a busy ruleset stalls on reverse lookups), and `-v` adds the interface columns and the per-rule packet and byte counters. Those counters are the most useful diagnostic iptables gives you, and half this article is really about reading them.

The other listing format is `-S`, which prints rules as the commands that would recreate them:

```
j@llmbits:~$ sudo iptables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
```

Use `-L -n -v` when you want counters. Use `-S` when you want something you can copy, paste or diff.

## The first rule, and what DROP and REJECT really do

Before touching anything, here is the baseline. SRV1 has two ports open:

```
j@llmbits:~$ sudo nmap -Pn -p 22,80,5201,8080 10.77.3.10
Starting Nmap 7.95 ( https://nmap.org ) at 2026-08-19 10:07 PDT
Nmap scan report for 10.77.3.10
Host is up (0.0058s latency).

PORT     STATE  SERVICE
22/tcp   closed ssh
80/tcp   closed http
5201/tcp open   targus-getdata1
8080/tcp open   http-proxy

j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!
```

Now one rule. Note the scoping: `-o ens224` pins it to the lab NIC and `-d 10.77.3.10` pins it to one destination, so nothing on the management path can possibly be caught by it.

```
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.3.10 -p tcp --dport 5201 -j REJECT

j@llmbits:~$ sudo iptables -S OUTPUT
-P OUTPUT ACCEPT
-A OUTPUT -d 10.77.3.10/32 -o ens224 -p tcp -m tcp --dport 5201 -j REJECT --reject-with icmp-port-unreachable
```

Two things happened that you did not type. iptables expanded `-p tcp --dport` into an explicit `-m tcp` match module load, and it filled in the default rejection method, `icmp-port-unreachable`. Always read a rule back with `-S` after writing it; the expanded form is what the kernel is actually running.

Now the behavior, with timing, because timing is the whole difference:

```
j@llmbits:~$ time nc -zv -w 5 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) failed: Connection refused

real	0m0.006s
```

Six milliseconds. The kernel generated an ICMP port unreachable back to the local socket immediately, and `nc` reported a refusal. Swap the target for DROP and run the identical test:

```
j@llmbits:~$ sudo iptables -R OUTPUT 1 -o ens224 -d 10.77.3.10 -p tcp --dport 5201 -j DROP

j@llmbits:~$ time nc -zv -w 5 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) timed out: Operation now in progress

real	0m5.011s
```

Five seconds, which is exactly the `-w 5` timeout. The SYN went into a black hole, the client retransmitted, and nothing ever came back. Scanners see the same distinction:

```
j@llmbits:~$ sudo nmap -Pn -p 5201,8080 10.77.3.10
PORT     STATE    SERVICE
5201/tcp filtered targus-getdata1
8080/tcp open     http-proxy

Offending packet: TCP 10.77.0.100:48786 > 10.77.3.10:5201 S ttl=59 id=22171 iplen=44  seq=2324308949 win=1024 <mss 1460>
sendto in send_ip_packet_sd: sendto(3, packet, 44, 0, 10.77.3.10, 16) => Operation not permitted
```

`filtered`, not `closed`. That word is nmap telling you a firewall swallowed the probe rather than a host refusing it, and the `Operation not permitted` error underneath is nmap's raw socket being blocked by our own OUTPUT rule. The `-R` in that command, incidentally, is replace: `-R OUTPUT 1` overwrites rule 1 in place rather than appending a second rule that would never be reached.

There is a third option, a TCP reset instead of an ICMP error:

```
j@llmbits:~$ sudo iptables -R OUTPUT 1 -o ens224 -d 10.77.3.10 -p tcp --dport 5201 -j REJECT --reject-with tcp-reset

j@llmbits:~$ time nc -zv -w 5 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) failed: Connection refused

real	0m0.006s
```

DROP

Silence. The client waits out its full connect timeout and gets nothing back.

Use on internet-facing edges, where you do not want to confirm the host exists.

REJECT (ICMP)

Immediate ICMP unreachable. The client fails in milliseconds. A remote scanner still reads the port as `filtered`, because nmap treats an ICMP type 3 unreachable as evidence of a firewall.

Use inside your own network, so applications fail fast instead of hanging.

REJECT (tcp-reset)

A TCP RST, indistinguishable from nothing listening. This is the only variant a remote scanner reads as `closed` rather than `filtered`.

Use when you want a blocked port to look like a closed one.

One caveat about that scanner column, because the lab makes it easy to draw the wrong conclusion. The rule above is on the OUTPUT chain of the scanning host itself, so nmap reports `filtered` for both reject methods and prints `Operation not permitted`: its raw socket send is being blocked and no probe ever leaves the box. The `closed` against `filtered` distinction only shows up when the rule is on the target's INPUT chain and the scanner is somewhere else. Verified both ways in this lab, and it is a useful reminder that a scan result describes the whole path, not just the destination.

The operational rule of thumb: DROP on the perimeter, REJECT internally. A DROP inside your own network turns a five-line config error into a thirty-second application timeout that someone will page you about.

## Counters are the debugger

After a single connection attempt:

```
j@llmbits:~$ sudo iptables -L OUTPUT -n -v
Chain OUTPUT (policy ACCEPT 19 packets, 2644 bytes)
 pkts bytes target     prot opt in     out     source               destination
    1    60 REJECT     tcp  --  *      ens224  0.0.0.0/0            10.77.3.10           tcp dpt:5201 reject-with icmp-port-unreachable
```

One packet, sixty bytes, a single SYN. When somebody tells you a firewall rule is not working, this is the first thing to look at, and the answer is almost always one of two things. Either the counter is zero, meaning the traffic is not matching this rule at all (wrong chain, wrong interface, wrong direction), or the counter is incrementing and the traffic is being blocked exactly as configured and the argument is about intent, not mechanism. Counters turn firewall debugging from an argument into an observation.

## Rules are evaluated top to bottom, first match wins

This is the same first-match logic as a Cisco ACL, and it catches people the same way. Append a broad block, then append a specific allow:

```
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.3.10 -j DROP
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.3.10 -p tcp --dport 8080 -j ACCEPT

j@llmbits:~$ sudo iptables -L OUTPUT -n -v --line-numbers
Chain OUTPUT (policy ACCEPT 220 packets, 31619 bytes)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 DROP       all  --  *      ens224  0.0.0.0/0            10.77.3.10
2        0     0 ACCEPT     tcp  --  *      ens224  0.0.0.0/0            10.77.3.10           tcp dpt:8080

j@llmbits:~$ nc -zv -w 3 10.77.3.10 8080
nc: connect to 10.77.3.10 port 8080 (tcp) timed out: Operation now in progress
```

The allow is unreachable. Rule 1 matches everything to that host and the packet never reaches rule 2\. Delete the allow and insert it at the top instead, which is what `-I` does:

```
j@llmbits:~$ sudo iptables -D OUTPUT 2
j@llmbits:~$ sudo iptables -I OUTPUT 1 -o ens224 -d 10.77.3.10 -p tcp --dport 8080 -j ACCEPT

j@llmbits:~$ sudo iptables -L OUTPUT -n -v --line-numbers
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 ACCEPT     tcp  --  *      ens224  0.0.0.0/0            10.77.3.10           tcp dpt:8080
2        3   180 DROP       all  --  *      ens224  0.0.0.0/0            10.77.3.10

j@llmbits:~$ nc -zv -w 3 10.77.3.10 8080
Connection to 10.77.3.10 8080 port [tcp/http-alt] succeeded!
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) timed out: Operation now in progress
```

Same two rules, opposite order, opposite result. Remember the shape of the four editing flags: `-A` appends to the end, `-I` inserts (at position 1 by default), `-R` replaces a numbered rule, and `-D` deletes either by number or by repeating the full rule specification. `--line-numbers` is what makes the numbered forms usable.

## Direction is not the same as reachability

A rule that drops replies looks exactly like a dead host from the application's point of view, and nothing like one on the wire. Watch:

```
j@llmbits:~$ sudo iptables -A INPUT -i ens224 -s 10.77.3.10 -p icmp --icmp-type echo-reply -j DROP

j@llmbits:~$ ping -c 3 -W 2 10.77.3.10
PING 10.77.3.10 (10.77.3.10) 56(84) bytes of data.

--- 10.77.3.10 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2051ms
```

100% loss. Now capture on the same interface while pinging:

```
j@llmbits:~$ sudo tcpdump -ni ens224 -c 4 icmp
10:09:40.659850 IP 10.77.0.100 > 10.77.3.10: ICMP echo request, id 38, seq 1, length 64
10:09:40.665234 IP 10.77.3.10 > 10.77.0.100: ICMP echo reply, id 38, seq 1, length 64
10:09:41.663150 IP 10.77.0.100 > 10.77.3.10: ICMP echo request, id 38, seq 2, length 64
10:09:41.668447 IP 10.77.3.10 > 10.77.0.100: ICMP echo reply, id 38, seq 2, length 64
```

The replies are arriving. tcpdump sits below netfilter on the receive path, so it sees packets the firewall is about to discard. That gap between "tcpdump sees it" and "the application does not" is the signature of a local INPUT rule, and it is worth internalizing, because it saves you from blaming the network for something happening in your own kernel. The counter confirms it:

```
j@llmbits:~$ sudo iptables -L INPUT -n -v
Chain INPUT (policy ACCEPT 909K packets, 80M bytes)
 pkts bytes target     prot opt in     out     source               destination
    5   420 DROP       icmp --  ens224 *       10.77.3.10           0.0.0.0/0            icmptype 0
```

If you want more on reading captures at this layer, the [tcpdump reference](https://www.pinglabz.com/tcpdump-for-network-engineers/) and [tshark in the terminal](https://www.pinglabz.com/tshark-linux/) cover it properly.

## A real policy: user chains, conntrack and logging

Everything so far has been single rules. A policy that you would actually deploy has three more ingredients: a user-defined chain to keep the scope contained, a connection tracking rule so you match flows instead of packets, and logging so you can see what got denied.

```
j@llmbits:~$ sudo iptables -N LAB-OUT
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.0.0/16 -j LAB-OUT
j@llmbits:~$ sudo iptables -A LAB-OUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
j@llmbits:~$ sudo iptables -A LAB-OUT -p tcp --dport 8080 -j ACCEPT
j@llmbits:~$ sudo iptables -A LAB-OUT -j LOG --log-prefix 'LABFW-DROP ' --log-level 4
j@llmbits:~$ sudo iptables -A LAB-OUT -j DROP

j@llmbits:~$ sudo iptables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N LAB-OUT
-A OUTPUT -d 10.77.0.0/16 -o ens224 -j LAB-OUT
-A LAB-OUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A LAB-OUT -p tcp -m tcp --dport 8080 -j ACCEPT
-A LAB-OUT -j LOG --log-prefix "LABFW-DROP "
-A LAB-OUT -j DROP
```

The pattern is worth stealing. One rule in the built-in chain decides what is in scope (traffic leaving `ens224` for the lab range) and jumps to a named chain; everything else in that named chain is unconditional, so you never have to repeat the interface and subnet match on every line. It reads better, and it means the built-in chain stays short enough to audit.

Testing it:

```
j@llmbits:~$ curl -sS -o /dev/null -w 'HTTP %{http_code} in %{time_total}s\n' http://10.77.3.10:8080/
HTTP 200 in 0.014590s

j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) timed out: Operation now in progress
j@llmbits:~$ ping -c 2 -W 2 10.77.3.10
2 packets transmitted, 0 received, 100% packet loss, time 1005ms
```

And the counters show where each packet landed:

```
j@llmbits:~$ sudo iptables -L LAB-OUT -n -v
Chain LAB-OUT (1 references)
 pkts bytes target     prot opt in     out     source               destination
    5   339 ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
    1    60 ACCEPT     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:8080
    5   348 LOG        all  --  *      *       0.0.0.0/0            0.0.0.0/0            LOG flags 0 level 4 prefix "LABFW-DROP "
    5   348 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0
```

Read that carefully. Exactly one packet matched the port 8080 rule, the SYN. The other five packets of that HTTP exchange matched the conntrack rule instead, because once the flow is established the kernel recognizes it without re-evaluating the port match. That is what stateful means in practice, and it is why the ESTABLISHED rule goes first: it is the cheapest match in the chain and it handles the overwhelming majority of packets.

`LOG` is not a terminating target. The packet continues to the next rule, which is why the LOG and DROP counters are identical. The output goes to the kernel ring buffer:

```
j@llmbits:~$ sudo dmesg | grep LABFW-DROP | tail -4
[239999.964071] LABFW-DROP IN= OUT=ens224 SRC=10.77.0.100 DST=10.77.3.10 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=18170 DF PROTO=TCP SPT=57928 DPT=5201 WINDOW=64240 RES=0x00 SYN URGP=0
[240001.966793] LABFW-DROP IN= OUT=ens224 SRC=10.77.0.100 DST=10.77.3.10 LEN=84 TOS=0x00 PREC=0x00 TTL=64 ID=16269 DF PROTO=ICMP TYPE=8 CODE=0 ID=40 SEQ=1
```

Empty `IN=` and populated `OUT=` is the signature of a locally generated packet. Give every LOG rule a distinct prefix, because on a busy box the kernel log is shared with everything else and grepping for the prefix is the only sane way to find your own entries. And put a rate limit on any LOG rule you leave in production, or a scan will fill the disk for you.

Get the Linux Networking Field Reference - 10 pages, free

Every command in this cluster on ten printable pages: iproute2, sockets, DNS, capture, monitors and all three firewall front ends. Includes a ten-symptom troubleshooting decision tree and annotated real lab output. Free for PingLabz members, just sign up with your email.

[Get the Linux networking cheat sheet](https://www.pinglabz.com/linux-networking-cheatsheet/)

## Matching more than one thing at a time

Two match extensions save a lot of typing. `multiport` collapses a list of ports into one rule:

```
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.3.10 -p tcp -m multiport --dports 22,80,5201,8080 -j REJECT

j@llmbits:~$ sudo iptables -S OUTPUT
-A OUTPUT -d 10.77.3.10/32 -o ens224 -p tcp -m multiport --dports 22,80,5201,8080 -j REJECT --reject-with icmp-port-unreachable

j@llmbits:~$ nc -zv -w 2 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) failed: Connection refused
j@llmbits:~$ nc -zv -w 2 10.77.3.10 8080
nc: connect to 10.77.3.10 port 8080 (tcp) failed: Connection refused
```

And `limit` applies a token bucket, which is how you rate limit ICMP or new connections without dropping everything:

```
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.0.0/16 -p icmp --icmp-type echo-request -m limit --limit 2/sec --limit-burst 2 -j ACCEPT
j@llmbits:~$ sudo iptables -A OUTPUT -o ens224 -d 10.77.0.0/16 -p icmp --icmp-type echo-request -j DROP

j@llmbits:~$ sudo ping -c 20 -i 0.1 -W 1 10.77.3.10 | tail -4
20 packets transmitted, 5 received, 75% packet loss, time 1955ms
rtt min/avg/max/mdev = 5.191/5.478/5.863/0.237 ms

j@llmbits:~$ sudo iptables -L OUTPUT -n -v
 pkts bytes target     prot opt in     out     source               destination
    5   420 ACCEPT     icmp --  *      ens224  0.0.0.0/0            10.77.0.0/16         icmptype 8 limit: avg 2/sec burst 2
   15  1260 DROP       icmp --  *      ens224  0.0.0.0/0            10.77.0.0/16         icmptype 8
```

Twenty pings sent at ten per second, five allowed, fifteen dropped. Note the shape of it: `limit` matches only while there are tokens in the bucket, so the ACCEPT stops matching and the following DROP catches the rest. A limit rule always needs a partner rule beneath it.

## The nat table, briefly

NAT deserves its own article, but one demonstration makes the mechanism clear. A DNAT rule in the OUTPUT chain rewrites the destination of locally generated packets before routing:

```
j@llmbits:~$ sudo iptables -t nat -A OUTPUT -o ens224 -d 10.77.3.10 -p tcp --dport 5201 -j DNAT --to-destination 10.77.3.10:8080

j@llmbits:~$ sudo iptables -t nat -L OUTPUT -n -v
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    0     0 DNAT       tcp  --  *      ens224  0.0.0.0/0            10.77.3.10           tcp dpt:5201 to:10.77.3.10:8080

j@llmbits:~$ curl -sS -m 5 -o /dev/null -w 'HTTP %{http_code} answered on port 5201\n' http://10.77.3.10:5201/
HTTP 200 answered on port 5201
```

curl asked for port 5201 and got an HTTP 200, because the kernel rewrote the destination port on the way out and rewrote the replies back on the way in. The two rules that matter in real deployments are the same idea at different hook points: `-t nat -A PREROUTING ... -j DNAT` to publish an internal service, and `-t nat -A POSTROUTING -o <wan> -j MASQUERADE` to give a subnet outbound access through a single address. Remember that only the first packet of a connection is evaluated in the nat table; conntrack applies the same translation to the rest of the flow, which is why the byte counters on a NAT rule are always far lower than the traffic passing through it. Order matters here too: for a locally generated packet the nat OUTPUT chain runs before filter OUTPUT, so a filter rule does see the rewritten destination, but POSTROUTING runs after filter, so you can never match on a post-SNAT source address in the filter table.

## The same policy on a Cisco router

If you already write ACLs, the mapping is close enough to be useful. Here is the same "block one TCP port to one host" policy on R3, the IOS XE router directly in front of SRV1:

```
R3(config)#ip access-list extended LAB-FILTER
R3(config-ext-nacl)# deny   tcp any host 10.77.3.10 eq 5201
R3(config-ext-nacl)# permit ip any any
R3(config-ext-nacl)#exit
R3(config)#interface Ethernet0/1
R3(config-if)# ip access-group LAB-FILTER out
```

```
j@llmbits:~$ nc -zv -w 4 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) failed: No route to host
j@llmbits:~$ nc -zv -w 4 10.77.3.10 8080
Connection to 10.77.3.10 8080 port [tcp/http-alt] succeeded!
```

```
R3#show ip access-lists LAB-FILTER
Extended IP access list LAB-FILTER
    10 deny tcp any host 10.77.3.10 eq 5201 (1 match)
    20 permit ip any any (16 matches)
```

`show ip access-lists` is `iptables -L -n -v`: same job, same value, hit counters per line. The differences are real, though. The Cisco ACL ends in an implicit deny and iptables chains end in a policy you set explicitly. The Cisco ACL is applied to an interface in a direction, while iptables selects the interface with `-i` or `-o` inside the rule. And a classic IOS ACL is stateless unless you add `established` or reflexive entries, where `-m conntrack` is the default way to write Linux rules. Note also the client-side error. The local REJECT earlier produced "Connection refused" and the router's ACL produced "No route to host" for the same policy, because IOS answers an ACL denial with ICMP type 3 code 13, administratively prohibited, which Linux maps to `EHOSTUNREACH` rather than the `ECONNREFUSED` that a port unreachable produces. Two different messages, one blocked port, and the wording tells you which device did it.

## Saving, restoring and the thing that will bite you

`iptables-save` dumps the live ruleset in a format `iptables-restore` can reload atomically:

```
j@llmbits:~$ sudo iptables-save -t filter
# Generated by iptables-save v1.8.11 (nf_tables) on Wed Aug 19 10:10:50 2026
*filter
:INPUT ACCEPT [908895:79769865]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [334:50632]
:LAB-OUT - [0:0]
-A OUTPUT -d 10.77.0.0/16 -o ens224 -j LAB-OUT
-A LAB-OUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A LAB-OUT -p tcp -m tcp --dport 8080 -j ACCEPT
-A LAB-OUT -j LOG --log-prefix "LABFW-DROP "
-A LAB-OUT -j DROP
COMMIT
# Completed on Wed Aug 19 10:10:50 2026
```

Nothing in that ruleset survives a reboot on its own. iptables rules live in kernel memory and nowhere else. Persistence is a separate package (`iptables-persistent` on Debian and Ubuntu, which reloads from `/etc/iptables/rules.v4`), or a systemd unit, or your configuration management. Every engineer gets caught by this exactly once.

The counterpart trap is losing the box. If you set `-P INPUT DROP` before adding an allow rule for your own SSH session, the session dies with the command. Two habits prevent it: add the allow rules first and set the policy last, and on anything remote, arrange a rollback in advance. A background job is enough:

```
j@llmbits:~$ setsid nohup bash -c 'sleep 300; iptables -F; iptables -P INPUT ACCEPT' &
```

If your new policy works you cancel it; if it does not, the box lets you back in five minutes later. That single line has saved more remote sessions than any amount of care.

Teardown is `-F` to flush rules and `-X` to delete empty user chains:

```
j@llmbits:~$ sudo iptables -F
j@llmbits:~$ sudo iptables -X LAB-OUT
j@llmbits:~$ sudo iptables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT

j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!
j@llmbits:~$ ping -c 2 10.77.3.10 | tail -3
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 4.155/4.979/5.803/0.824 ms
```

`-F` flushes only the table you are looking at. Flushing `filter` leaves `nat` and `mangle` untouched, and flushing IPv4 leaves IPv6 completely alone. A host is not clean until `iptables -S`, `iptables -t nat -S` and `ip6tables -S` are all empty.

## Key takeaways

- The chain is chosen by the packet's destiny: INPUT for traffic to this host, OUTPUT for traffic from it, FORWARD for traffic through it. A rule in the wrong chain never fires.
- `iptables --version` reporting `(nf_tables)` means you are driving the nftables engine through a compatibility layer. Rules are visible to both tools.
- DROP costs the client its full connect timeout, measured here at 5.011s against 0.006s for REJECT. Only `--reject-with tcp-reset` reads as `closed` to a remote scanner; an ICMP reject still reads as `filtered`. DROP outward, REJECT inward.
- Per-rule packet counters from `iptables -L -n -v` settle almost every "the firewall is broken" argument in one command.
- First match wins, so `-I` and `-A` are not interchangeable. Use `--line-numbers` and read the chain back before you trust it.
- If tcpdump sees the packet and the application does not, the packet died in an INPUT rule on this host.
- Put `-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT` first. In the capture above it matched five of the six packets in a single HTTP request.
- `LOG` does not terminate; the packet falls through to the next rule. Always give it a unique prefix and a rate limit.
- Nothing persists across a reboot without `iptables-persistent` or equivalent, and `-F` only clears the one table and one address family you named.

iptables is the syntax the whole industry still writes and reads, and it is not going away, but the engine underneath it moved on. [nftables](https://www.pinglabz.com/nftables-linux/) is what that engine looks like when you address it directly, and [ufw](https://www.pinglabz.com/ufw-linux/) is the two-command front end for the days when you just need port 22 open and nothing else. All three, along with the rest of the toolkit, are indexed in the [complete guide to Linux networking commands](https://www.pinglabz.com/linux-networking-commands/).