Listing Rules
Everything needs root. skips DNS lookups, adds packet counters, gives you the numbers used for deleting.
| Command | What it does |
|---|---|
| All rules in the filter table, numeric, with counters | |
| Same, with rule numbers for deleting | |
| One chain only | |
| Rules printed as the commands that made them | |
| Rules in the nat table (NAT rules never show in the default listing) | |
| Check whether an exact rule exists; exit code 0 means yes | |
| Zero the packet counters, leaving the rules alone | |
| Find rules touching port 22 | |
| Watch packet counters move, live |
SSH Lockout
This is the one that costs you the server. with no rule allowing port 22 kills the session you are typing in, not just the next login, because the policy applies to the packets carrying your keystrokes. The same thing happens when you flush a ruleset while the policy is already DROP, since removes the SSH allow rule and leaves the DROP in place. Add the allow rules first, set the policy last.
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo iptables -L INPUT -n -v --line-numbers # confirm the SSH rule is live
# only now, once port 22 is open:
sudo iptables -P INPUT DROP| Command | What it does |
|---|---|
| Deadman switch: opens the firewall in 5 minutes if you lose the session | |
| Find the pending job; cancels it | |
| Apply a saved ruleset and roll it back in 30 seconds unless you confirm | |
| from a second terminal | Test with a fresh connection and keep the old one open |
| What to run from the provider's console if you are already locked out |
Set up the deadman switch before the first DROP, not after, and cancel it once a new login works. If sshd listens on a non-standard port, open that port instead and check it from outside with the SSH port checker. A DROP policy shows up as a timeout (Network error: Connection timed out), a REJECT rule as connection refused. The nftables and UFW sheets have the same trap in their own syntax, and the firewall rule generator writes a ruleset that already has the SSH rule in it.
Allowing Traffic
Order matters: rules match top down, first match wins. Allow SSH before anything drops.
| Command | What it does |
|---|---|
| Allow SSH | |
| Allow HTTP ( for HTTPS) | |
| Both in one rule | |
| Allow replies to connections this box started (put this near the top) | |
| Allow loopback (many things break without it) | |
| Allow everything from one IP | |
| Allow one subnet to reach Postgres | |
| Allow a UDP port (WireGuard here) | |
| Allow a port range | |
| Only on one interface | |
| Allow ping | |
| Match a MAC address (same LAN only, and easy to spoof) | |
| Attach a comment that shows up in the listing |
A minimal safe INPUT policy, in the right order:
sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo iptables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
sudo iptables -P INPUT DROPThe firewall rule generator builds rule sets like this from a checklist.
Blocking Traffic
| Command | What it does |
|---|---|
| Block one IP | |
| Block a range | |
| Block it ahead of every allow rule | |
| Block a port silently | |
| Block a port with an immediate error | |
| Stop this box talking to an IP | |
| Unblock: delete the rule that blocked it |
DROP discards the packet silently, so the client hangs until it times out; REJECT answers with an error, so the client fails fast with connection refused. DROP gives port scanners nothing, REJECT is kinder for services your own users hit. If SSH suddenly gets connection refused, look for a REJECT rule. For blocklists of more than a handful of addresses, or a whole country, see IP Sets below.
Adding and Deleting Rules
appends to the bottom, inserts at the top (or a position). Since first match wins, a rule appended after a DROP does nothing.
| Command | What it does |
|---|---|
| Append at the end of the chain | |
| Insert at position 1, the very top | |
| Insert at position 3 | |
| Delete by repeating the rule exactly | |
| Get the numbers, then... | |
| Delete rule number 3 | |
| Replace rule number 3 | |
| Set the chain policy, the fate of packets no rule matched | |
| Wait up to 5 seconds for the xtables lock instead of failing |
Rule numbers shift after every delete. Deleting several by number goes highest first, or re-list between deletes.
Rule Order
Inside a chain the first matching rule wins and evaluation stops, so a rule you append below an existing ACCEPT or DROP never runs. Chains run in a fixed order too, which is why NAT happens before your filter rules ever see the packet.
| Path | Order of chains |
|---|---|
| Arriving for this box | then |
| Routed through this box | then then |
| Sent by this box | then |
| Within one hook | then then then |
Two consequences worth remembering: a DNAT rewrite in PREROUTING happens first, so a FORWARD rule has to match the internal address, not the public one; and a packet accepted in INPUT never reaches FORWARD, which is why Docker's published ports are invisible to your INPUT rules.
Tables and Chains
A rule lives in a chain, a chain lives in a table. is the default, which is why NAT rules seem to disappear until you ask for their table.
| Table / chain | What it is for |
|---|---|
| table | Accept or drop traffic; the default table |
| table | Rewrite addresses and ports (MASQUERADE, DNAT) |
| table | Alter packet headers (TTL, marks); rarely needed |
| table | Runs before conntrack; where and go |
| chain | Traffic addressed to this machine |
| chain | Traffic this machine sends |
| chain | Traffic routed through this machine (routers, Docker) |
| chain | Before routing; where DNAT and port forwards go |
| chain | After routing; where MASQUERADE goes |
| Create a chain of your own | |
| Send matching traffic into it | |
| Delete it once it is empty and nothing jumps to it |
Flushing Rules
deletes all rules but does not touch chain policies. If the policy is DROP, flushing locks you out of SSH instantly. Safe order:
# 1. Open the policies first, so an empty ruleset allows everything:
sudo iptables -P INPUT ACCEPT
sudo iptables -P FORWARD ACCEPT
sudo iptables -P OUTPUT ACCEPT
# 2. Now flush:
sudo iptables -F
sudo iptables -X # delete custom chains
sudo iptables -t nat -F # nat and mangle have their own rules
sudo iptables -t mangle -FKeep the current SSH session open and test a fresh login before closing it. When rebuilding, the first rule you add back is the SSH allow, and the deadman switch in SSH Lockout above costs nothing.
NAT and Port Forwarding
NAT rules live in the table and need forwarding enabled in the kernel first:
sudo sysctl -w net.ipv4.ip_forward=1
# permanent: echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-forward.conf| Command | What it does |
|---|---|
| Share this box's IP with everything it routes (the home router rule) | |
| Masquerade one subnet only (typical VPN setup) | |
| Source NAT to a fixed address; cheaper than MASQUERADE on a static IP | |
| Redirect a port on this box | |
| Forward a port to an internal host | |
| See what NAT is doing |
Forward port 80 arriving on this box to an internal server:
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 10.0.0.5:80
sudo iptables -A FORWARD -p tcp -d 10.0.0.5 --dport 80 -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT
sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADEA forward that does nothing is almost always left at 0, a FORWARD chain with no matching ACCEPT, or a test run from the box itself: locally generated traffic never passes through PREROUTING, so test from another host or with the SSH port checker.
Saving Rules
Rules live in kernel memory and are gone after a reboot unless something restores them.
| Command | What it does |
|---|---|
| Dump current rules to a file | |
| Load them back, replacing everything | |
| Merge a file into the running rules instead of replacing them | |
| Keep the packet counters in the dump | |
| Debian/Ubuntu: restores rules at boot | |
| Save current rules for that boot restore | |
| RHEL/CentOS with the iptables-services package | |
| IPv6 rules are saved separately | |
| Apply with an automatic rollback if you do not confirm (lockout insurance) |
IP Sets
keeps thousands of addresses in one kernel hash, so a blocklist costs you one rule instead of one rule per address, and matching stays fast as it grows.
sudo apt install ipset
sudo ipset create blocklist hash:net
sudo ipset add blocklist 198.51.100.0/24
sudo iptables -I INPUT -m set --match-set blocklist src -j DROP| Command | What it does |
|---|---|
| Show a set and everything in it | |
| Add an address that expires on its own | |
| Remove one | |
| Ask whether an address is in the set | |
| A set of single addresses rather than networks | |
| Allow only what is in a set | |
| Sets are saved separately from iptables rules | |
| Load them back; do this before the iptables rules | |
| Delete a set; the rules referencing it have to go first |
Country blocking works the same way: build a set from an aggregated zone file and point one DROP rule at it. The module from xtables-addons does it without a set, but the database needs updating by hand and it is a per-packet lookup. nftables has sets built into the ruleset, with no second tool to install or save; see the nftables cheat sheet.
Logging and Rate Limiting
| Command | What it does |
|---|---|
| Rate limit new SSH connections | |
| Track new SSH sources... | |
| ...and drop an IP with 5 attempts in a minute | |
| Log what falls through (place just before the end or a DROP) | |
| Same, without flooding the log | |
| Trace a packet through every table and chain ( first) | |
| The addresses fail2ban is blocking right now | |
| fail2ban's own view of the same bans |
Logged packets land in the kernel log: or . fail2ban inserts its bans into its own chain at the top of INPUT, so its rules are why a chain sometimes has entries you did not add.
IPv6
is a second, independent firewall with the same syntax. Locking down IPv4 on a service that also listens on IPv6 closes half the door.
| Command | What it does |
|---|---|
| List the IPv6 rules | |
| Allow SSH over IPv6 | |
| Required: IPv6 breaks without ICMPv6 (neighbour discovery, path MTU) | |
| Same lockout risk as IPv4; open 22 first | |
| Saved separately from | |
| Whether sshd is listening on IPv6 at all ( or ) | |
| Turn IPv6 off rather than firewall it |
nftables' family covers both stacks in one rule, which is the main day-to-day reason to move.
Docker
Docker writes its own chains and publishes container ports through FORWARD and the DOCKER chain, bypassing INPUT entirely. A container run with is reachable from the internet even when your INPUT rules say only 22 is open.
| Command | What it does |
|---|---|
| The port publishing rules Docker wrote | |
| The chain Docker leaves for you; it runs before Docker's own rules | |
| Block outside traffic to every published port | |
| Publish to localhost only, then reverse proxy in front | |
| Quick check whether Docker is managing rules on this box | |
| Rebuilds Docker's chains; rules you added inside them are lost, DOCKER-USER survives |
Setting in stops the bypass but breaks container networking in other ways, so almost nobody keeps it. Full container commands are on the Docker cheat sheet.
Common Errors
| Message | Cause |
|---|---|
| Not installed (minimal Debian, containers), or is off your PATH: use | |
| Every iptables command needs root, listing included | |
| Two writers at once, usually Docker or fail2ban; add | |
| Missing kernel module, or a chain name typed with the wrong case | |
| needs or earlier in the same rule | |
| on a chain with fewer rules; list with first | |
| , or you are in a container without | |
| Rules exist in the other backend; see nftables and Front Ends below | |
| One bad line, and the whole table is rejected: restore is atomic per table | |
| Rule added but nothing is blocked | An earlier ACCEPT already matched, or the traffic is Docker's and never reaches INPUT |
| DNAT or MASQUERADE does nothing | is 0, no FORWARD ACCEPT matches, or you are testing from the box itself |
| Rules gone after a reboot | Nothing saved them; see Saving Rules |
nftables and Front Ends
nftables is the successor; new setups should use it or a front end rather than raw iptables.
| Command | What it does |
|---|---|
| in the output means your commands become nftables rules | |
| Show a rule in nft syntax | |
| See everything, including rules ufw, firewalld, and Docker added | |
| The other backend's rules, which cannot see | |
| Debian/Ubuntu: switch between iptables-nft and iptables-legacy |
Convert whole rule sets with the iptables to nftables converter, or write one rule for iptables, nft, UFW, and firewalld at once with the firewall rule generator. The nftables cheat sheet has the native syntax side by side with these commands. If all you want is ports opened and closed on one server, UFW is the simpler front end and manages this layer for you.
Gotchas
- Mixing tools bites: ufw, firewalld, and Docker all write their own iptables or nftables rules, so a rule you add by hand can be shadowed by theirs or wiped on their reload. shows the whole truth; pick one manager per box.
- Rules are gone after a reboot unless you set up persistence. The classic failure: firewall works for months, a reboot silently drops it.
- without hides all NAT rules, and without it stalls on DNS lookups.
- iptables-legacy and iptables-nft keep separate rule sets. If rules seem to be missing, the other binary may own them: check and .
- Docker publishes container ports by writing its own FORWARD and DOCKER chain rules, bypassing your INPUT rules entirely. A "closed" port can still be reachable through Docker.
- IPv6 is a separate firewall ( or the nft family). Locking down IPv4 while the service also listens on IPv6 locks down half the door.
- contents are saved by , not by . Restoring rules without restoring the sets leaves you with rules that match nothing, or that refuse to load at all.