iptables Firewall Practical Guide for Linux

People love declaring iptables dead every few years. The kernel moved on, nftables is the direction of travel, and higher-level tools like UFW and firewalld are what many admins touch day to day. All of that is true. It is also true that I still run into iptables constantly: older hosts, minimal cloud images, vendor scripts, rescue work on broken systems, containers, and distributions where the compatibility layer is doing more than most people realize.

So I still think every Linux sysadmin should understand iptables directly. Not because it is fashionable, but because when a server is half-broken at 2 a.m., a clear mental model of chains, policies, and rule order is usually more useful than clicking through a frontend.

This guide is the practical version: what the tables do, how I add rules safely over SSH, what usually causes lockouts, how NAT fits in, and how I think about the path toward nftables without pretending iptables vanished.

Why iptables still matters

Modern distributions may ship iptables-nft, which means the iptables command is translating rules into nftables underneath. Older systems may still use the legacy backend. Either way, admins, scripts, and documentation still speak iptables everywhere.

That matters for a few reasons:

  • many production hosts were not built yesterday
  • third-party install scripts still insert iptables rules
  • containers and bootstrap tools often expect iptables
  • rescue environments and minimal images usually include it
  • learning iptables makes nftables easier to understand later

I do prefer newer systems to converge on nftables. But when I inherit an existing box, I work with what is already there before planning a migration.

If you want a simpler frontend for smaller hosts, the UFW firewall guide is a good complement. Under the hood, though, the packet filtering logic still helps to understand.

Tables and chains without the hand-waving

The default table is filter. That is where most packet-allow and packet-block rules live.

Main chains you will use most:

  • INPUT — packets destined for the local machine
  • OUTPUT — packets originating from the local machine
  • FORWARD — packets passing through the machine to somewhere else

Other important tables:

  • nat — address translation, port forwarding, masquerading
  • mangle — packet alterations and advanced handling
  • raw — exemptions from connection tracking in special cases

Most day-to-day host firewalls only need filter, and maybe nat if the server is routing traffic.

To see the current rules with counters and line numbers:

sudo iptables -L -n -v --line-numbers
sudo iptables -t nat -L -n -v --line-numbers
sudo iptables -t mangle -L -n -v --line-numbers

I always use -n so DNS lookups do not slow down or confuse the output. I also like --line-numbers because once I need to delete or insert a rule, line references are faster than counting by eye.

For a ruleset dump that can be restored later:

sudo iptables-save

That output is closer to how I think about persistence and change review than the human-friendly -L view.

Default policies: DROP vs REJECT

This is one of the first design choices that actually affects behavior.

A default policy of DROP silently discards matching packets. REJECT actively returns an error, such as an ICMP unreachable or TCP reset depending on the traffic.

Examples:

sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT

Or with a final reject rule instead of a blanket drop policy:

sudo iptables -A INPUT -j REJECT --reject-with icmp-host-prohibited

I usually prefer DROP on internet-facing hosts because it reveals less and behaves predictably against random scanning. But I do use REJECT on internal networks when I want clients and troubleshooting tools to fail fast instead of hanging.

The semantic difference matters in real troubleshooting:

  • DROP often looks like a timeout
  • REJECT usually looks like a quick refusal

Neither is universally correct. Choose based on user experience and exposure. What I do not do is mix them arbitrarily without understanding what clients will see.

The first rules I add on a remote server

If I am changing a live host over SSH, I am conservative. The fastest way to ruin your own night is to set a default DROP policy before allowing loopback, established connections, and SSH.

My safe baseline order looks like this:

sudo iptables -F
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT
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

Older documentation often uses the state module:

sudo iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

That still appears everywhere, and the prompt syntax is familiar, but on current systems I prefer conntrack. It is the modern form you should reach for unless you are maintaining old scripts.

If the SSH daemon listens on a different port, change --dport accordingly. Before I continue, I test from a second session:

ssh -p 22 user@server.example.com

Do not trust the session you already have open. Existing traffic may continue because of the established-connection rule, while new connections fail.

The loopback rule is not optional. Forgetting it breaks local services that expect to talk to 127.0.0.1 or ::1, including health checks, databases, and reverse proxies.

Listing, inserting, appending, and deleting rules

Two options matter a lot:

  • -A appends a rule to the end of a chain
  • -I inserts a rule near the top, optionally at a specific position

Examples:

sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
sudo iptables -I INPUT 1 -p tcp --dport 443 -j ACCEPT

If your default policy is DROP, rule order is everything. Appending a useful allow rule below an earlier blanket drop rule changes nothing.

To inspect with line numbers:

sudo iptables -L INPUT -n -v --line-numbers

Delete a rule by number:

sudo iptables -D INPUT 4

Or delete using the full specification:

sudo iptables -D INPUT -p tcp --dport 443 -j ACCEPT

I usually delete by number after listing the chain immediately beforehand. It is less typing and reduces mistakes.

Allowing specific services, IPs, and subnets

Common allow rules are straightforward.

Allow HTTPS and HTTP:

sudo iptables -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT

Allow SSH from a single management IP:

sudo iptables -A INPUT -p tcp -s 203.0.113.10 --dport 22 -j ACCEPT

Allow a monitoring subnet to scrape node metrics:

sudo iptables -A INPUT -p tcp -s 10.10.20.0/24 --dport 9100 -j ACCEPT

Allow ICMP echo requests carefully:

sudo iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT

I do not automatically block ping. Being able to test reachability quickly is often useful, and ICMP is about more than echo requests anyway. Blocking all ICMP usually causes more confusion than protection.

For SSH hardening beyond the firewall rules, I still rely on key-based auth and server-side config. The SSH key authentication guide pairs well with firewall restrictions.

Blocking IPs and subnets cleanly

To block a single abusive IP:

sudo iptables -I INPUT 1 -s 198.51.100.23 -j DROP

To block a subnet:

sudo iptables -I INPUT 1 -s 198.51.100.0/24 -j DROP

If I know I never want outbound traffic to a destination either:

sudo iptables -I OUTPUT 1 -d 198.51.100.0/24 -j DROP

The -I INPUT 1 form matters because I want the block near the top, before broad allow rules. Appending a drop rule below a catch-all accept rule is useless.

For larger lists, plain iptables becomes tedious. On busy systems I move to ipset or, better yet, native nftables sets. But for a handful of problem addresses, direct rules are perfectly fine.

NAT and MASQUERADE when the server is routing

Host firewalling and routing are different jobs, but people often mix them together. If the machine forwards packets between networks, you usually need both filter rules and NAT rules.

First enable IP forwarding temporarily:

sudo sysctl -w net.ipv4.ip_forward=1

Persist it:

printf 'net.ipv4.ip_forward = 1\n' | sudo tee /etc/sysctl.d/99-ip-forward.conf
sudo sysctl --system

Then add a typical masquerade rule on the outbound interface:

sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

Allow forwarding between interfaces:

sudo iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT
sudo iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

That is a basic pattern for a Linux box acting as a gateway from an internal network on eth1 to an external network on eth0.

For destination NAT, such as forwarding TCP 443 from the gateway to an internal host:

sudo iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.10:443
sudo iptables -A FORWARD -p tcp -d 10.0.0.10 --dport 443 -m conntrack --ctstate NEW,ESTABLISHED,RELATED -j ACCEPT

When NAT stops working, I verify routes, forwarding, and rule counters before assuming the firewall is magical or broken.

Saving and restoring rules properly

A common beginner mistake is assuming iptables changes survive reboot automatically. They usually do not unless your distribution provides a persistence mechanism and you use it.

To save the current ruleset manually:

sudo iptables-save | sudo tee /etc/iptables/rules.v4 >/dev/null
sudo ip6tables-save | sudo tee /etc/iptables/rules.v6 >/dev/null

On Debian and Ubuntu, I normally install netfilter-persistent:

sudo apt update
sudo apt install iptables-persistent netfilter-persistent
sudo netfilter-persistent save
sudo netfilter-persistent reload

On RHEL-like systems, persistence depends more on the release and toolchain in use. Old habits like service iptables save are legacy behavior, not something I rely on for current systems unless I know the package layout.

To restore directly from a file:

sudo iptables-restore < /etc/iptables/rules.v4

I like iptables-save and iptables-restore because they make rule review reproducible. A flat file is easier to version, compare, and recover from than a shell history full of incremental commands.

IPv6: use ip6tables or you do not really have a firewall policy

One of the easiest mistakes to make is securing IPv4 and forgetting that the host also has IPv6 connectivity. If IPv6 is enabled and routed, iptables alone does nothing for that traffic.

Use ip6tables for the IPv6 ruleset:

sudo ip6tables -L -n -v --line-numbers
sudo ip6tables -P INPUT DROP
sudo ip6tables -P FORWARD DROP
sudo ip6tables -P OUTPUT ACCEPT
sudo ip6tables -A INPUT -i lo -j ACCEPT
sudo ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT

Then add service rules the same way, but with IPv6 addresses and ranges where needed.

I strongly recommend checking listening sockets after firewall changes:

ss -tulpn

The Linux network troubleshooting guide goes deeper into validating what is listening and what path packets are taking. Firewall work without validation is guesswork.

Common mistakes that cause self-inflicted outages

These are the mistakes I see most often:

Setting DROP before allowing SSH

This is the classic lockout. The server is not wrong; the order is wrong.

Forgetting established traffic

Without an established/related rule, reply packets can get blocked and the box behaves strangely.

Forgetting loopback

Local services break in ways that do not obviously point to the firewall.

Appending instead of inserting

A good rule below a bad rule does not help.

Ignoring IPv6

The host ends up protected on IPv4 and wide open on IPv6.

Mixing frontends blindly

If UFW, firewalld, Docker, Kubernetes, or custom scripts all touch netfilter, the resulting ruleset can be confusing. I avoid making direct iptables changes on hosts where a frontend is actively managing policy unless I understand how it will persist.

iptables with UFW, firewalld, and Docker

UFW and firewalld are frontends. They are not separate firewall engines. They manage netfilter rules on your behalf. Docker also inserts rules for published ports and network isolation.

That means three practical things:

  1. your manual rule may be overwritten later
  2. the displayed ruleset may include chains you did not create
  3. troubleshooting often requires understanding who owns which chain

On Docker hosts, I am especially careful with published ports because people assume “the app only listens internally” while Docker has already opened the host port. The Docker networking guide and TLS in Docker guide are relevant here.

A sensible migration path to nftables

I would not start a brand-new long-lived platform on handcrafted iptables unless compatibility forced the decision. nftables is cleaner, supports sets much better, and is where Linux networking policy is headed.

My migration approach is simple:

  • learn the existing iptables ruleset first
  • export and document what it actually does
  • identify whether the system uses iptables-legacy or iptables-nft
  • migrate in maintenance windows, not during firefighting
  • validate IPv4 and IPv6 behavior after every step

Check which backend is active on Debian-like systems:

sudo update-alternatives --display iptables

If the system is already using the nft backend, you are closer to the modern model than the command syntax suggests.

A practical baseline ruleset for a simple server

For a public web server with SSH, HTTP, and HTTPS only, this baseline is a reasonable starting point:

sudo iptables -F
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
sudo iptables -P OUTPUT ACCEPT
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

Validate it with:

sudo iptables -L -n -v --line-numbers
ss -tulpn
curl -I http://127.0.0.1
curl -kI https://127.0.0.1

And from another machine:

nc -vz server.example.com 22
nc -vz server.example.com 80
nc -vz server.example.com 443

Those simple checks catch a surprising number of mistakes before users do.

Conclusion

iptables is no longer the shiny new tool, but it is still deeply relevant in real Linux operations. I treat it as a skill every sysadmin should keep sharp: know the tables, respect rule order, always allow established traffic, never forget loopback, and think about IPv6 every single time.

When I need a quick host firewall on an existing Linux server, iptables is still practical and dependable. When I build something new, I think about nftables. Both things can be true. The mistake is pretending the old tool disappeared before the old systems did.

Scroll to Top