iptables is the classic command-line interface to the Linux kernel's packet filter. On Ubuntu 24.04 the iptables command uses the nf_tables backend under the hood, but it keeps the familiar syntax and the iptables-save file format that countless scripts and tutorials rely on. In this tutorial you will write a stateful firewall for a web server as a rules file, apply it with an automatic rollback so you cannot lock yourself out, add rate limiting and logging, cover IPv6, and make the rules survive reboots.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS. Debian 12 works the same way.
  • A non-root user with sudo privileges.
  • Access to the server console from your provider's panel, in case a mistake blocks SSH.

How iptables processes packets

Rules live in tables, and each table has chains that the kernel walks at a specific point of a packet's life:

TableChains used herePurpose
filterINPUT, FORWARD, OUTPUTAccept or drop packets addressed to, routed through or sent by the server
natPREROUTING, POSTROUTINGRewrite destination or source addresses (port forwarding, masquerading)

Rules in a chain are checked top to bottom. The first rule whose conditions match decides the packet's fate with a target such as ACCEPT, DROP or LOG (LOG records the packet and lets it continue). If no rule matches, the chain's policy applies. The firewall in this guide sets the INPUT policy to DROP and explicitly allows what the server needs.

Step 1 - Checking the current state

Confirm the iptables version and backend:

sudo iptables -V
iptables v1.8.10 (nf_tables)

List the current rules with packet counters and line numbers:

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

On a fresh server with UFW enabled you will see many ufw-* chains. Check whether UFW is active:

sudo ufw status

If the output is Status: active, disable it. This removes its rules and leaves the default ACCEPT policies in place until you load your own:

sudo ufw disable

Step 2 - Installing iptables-persistent

The rules you load with iptables live only in kernel memory. The iptables-persistent package installs the netfilter-persistent service, which loads /etc/iptables/rules.v4 and /etc/iptables/rules.v6 at boot:

sudo apt update
sudo apt install iptables-persistent

The installer asks whether to save the current IPv4 and IPv6 rules. Answer No to both, since you will write the files yourself. If apt proposes removing ufw, accept: the two packages conflict because both want to manage the firewall at boot.

Check that the directory exists:

ls /etc/iptables/

Step 3 - Writing the IPv4 rules file

Writing the rules in a file, instead of typing iptables commands one by one, makes the ruleset reviewable and lets you apply it in a single atomic operation. Open the file:

sudo nano /etc/iptables/rules.v4

Paste this ruleset for a server that offers SSH, HTTP and HTTPS:

*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]

# Loopback and replies to connections the server started
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP

# Ping, limited to 5 per second
-A INPUT -p icmp --icmp-type echo-request -m limit --limit 5/second --limit-burst 10 -j ACCEPT

# SSH, dropping sources that open more than 5 new connections in 60 seconds
-A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --name SSH --set
-A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -m recent --name SSH --update --seconds 60 --hitcount 6 -j DROP
-A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT

# Web traffic
-A INPUT -p tcp -m multiport --dports 80,443 -m conntrack --ctstate NEW -j ACCEPT
-A INPUT -p udp --dport 443 -m comment --comment "HTTP/3 (QUIC)" -j ACCEPT

# Log what is about to be dropped by the policy, at most 5 lines per minute
-A INPUT -m limit --limit 5/min --limit-burst 10 -j LOG --log-prefix "iptables-drop: " --log-level 4
COMMIT

What each part does:

  • Policies: INPUT and FORWARD drop by default, OUTPUT accepts, so the server can still start outbound connections (updates, DNS, APIs).
  • Loopback and conntrack: local services talking to each other over lo are allowed, and so are packets belonging to connections that are already established. RELATED also admits ICMP errors such as "fragmentation needed", which path MTU discovery depends on. Packets conntrack classifies as INVALID are dropped.
  • SSH rate limit: the recent module records every new SSH connection per source address. A source that opens a sixth connection within 60 seconds is dropped, which slows down brute-force bots without affecting normal logins.
  • Logging: the LOG rule sits just before the policy, so it records packets that are about to be dropped. The limit match prevents a flood from filling the logs.

Remove the UDP 443 line if your web server does not serve HTTP/3. If SSH listens on another port, change 22 in the three SSH rules.

Test the syntax without applying anything:

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

The command prints nothing when the file is valid, or the line number of the first error.

Step 4 - Applying the rules safely with iptables-apply

A single wrong rule can cut your SSH session. iptables-apply loads a rules file, then asks you to confirm that you can still connect. If you do not answer before the timeout, it restores the previous rules automatically:

sudo iptables-apply -t 60 /etc/iptables/rules.v4
Applying new ruleset... done.
Can you establish NEW connections to the machine? (y/N)

Before you answer, open a new SSH session to the server from another terminal. The current session is already established and would survive even a broken ruleset, so it proves nothing. If the new session works, answer y:

... then my job is done. See you next time.

If it fails or you do not answer within 60 seconds, the old rules come back and you can fix the file.

Check the loaded rules and their counters:

sudo iptables -L INPUT -n -v --line-numbers
Chain INPUT (policy DROP 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 ACCEPT     all  --  lo     *       0.0.0.0/0            0.0.0.0/0
2      312 24893 ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
3        0     0 DROP       all  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate INVALID
...

From another machine, confirm that a port you did not open is now filtered (the connection times out instead of being refused):

nc -zv -w 3 your_server_ip 3306

Step 5 - Adding IPv6 rules

iptables rules only affect IPv4. If your server has an IPv6 address, an empty IPv6 ruleset leaves every service exposed over IPv6. Create the matching file:

sudo nano /etc/iptables/rules.v6
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]

-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP

# ICMPv6 is required for neighbour discovery and path MTU discovery
-A INPUT -p ipv6-icmp -j ACCEPT

-A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
-A INPUT -p tcp -m multiport --dports 80,443 -m conntrack --ctstate NEW -j ACCEPT
-A INPUT -p udp --dport 443 -j ACCEPT
COMMIT

Do not filter ICMPv6 the way you would ICMP on IPv4: without neighbour discovery the server loses its IPv6 connectivity entirely. Test and load the file:

sudo ip6tables-restore --test < /etc/iptables/rules.v6
sudo ip6tables-restore < /etc/iptables/rules.v6

Verify from a machine with IPv6 that SSH still works over IPv6 (ssh -6 your_user@your_server_ipv6).

Step 6 - Making the rules persistent

netfilter-persistent loads the files in /etc/iptables/ at boot. Make sure the service is enabled:

sudo systemctl enable netfilter-persistent

Since you edited the files directly, they already contain what should load at boot. When you later change rules with iptables commands instead, write the running rules back to the files:

sudo netfilter-persistent save

Reboot once during a maintenance window and confirm that the rules are back:

sudo iptables -S INPUT | head -5
-P INPUT DROP
-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -p icmp -m icmp --icmp-type 8 -m limit --limit 5/sec --limit-burst 10 -j ACCEPT

Step 7 - Managing rules day to day

For small changes you can use iptables commands directly. Rule order matters, so insert new ACCEPT rules above the LOG rule rather than appending them after it. Find the line numbers first:

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

Insert a rule at a given position, for example to allow MySQL only from an application server at 203.0.113.25:

sudo iptables -I INPUT 9 -p tcp -s 203.0.113.25 --dport 3306 -m conntrack --ctstate NEW -m comment --comment "MySQL from app01" -j ACCEPT

Block a single abusive address at the top of the chain:

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

Check whether a rule exists (exit code 0 if it does), and delete a rule by its line number:

sudo iptables -C INPUT -s 198.51.100.66 -j DROP && echo present
sudo iptables -D INPUT 1

Save after every change you want to keep:

sudo netfilter-persistent save

To read the log entries produced by the LOG rule:

sudo journalctl -k --grep 'iptables-drop' -n 20
Sep 24 11:02:17 web01 kernel: iptables-drop: IN=eth0 OUT= MAC=... SRC=192.0.2.200 DST=198.51.100.20 LEN=44 TOS=0x00 PREC=0x00 TTL=242 ID=54321 PROTO=TCP SPT=40112 DPT=3389 WINDOW=1024 RES=0x00 SYN URGP=0

Step 8 - Forwarding a port to another host (optional)

The nat table can turn the server into a gateway. This example forwards TCP port 8080 on the public interface eth0 to a web server at 10.0.0.5:80 on a private network.

First, allow the kernel to route packets between interfaces:

sudo nano /etc/sysctl.d/99-ip-forward.conf
net.ipv4.ip_forward = 1
sudo sysctl --system

Then add a nat table and FORWARD rules to /etc/iptables/rules.v4. Append the nat block after the COMMIT of the filter table, and add the two FORWARD rules inside the filter table, before its COMMIT:

# Inside *filter, before COMMIT
-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
-A FORWARD -i eth0 -p tcp -d 10.0.0.5 --dport 80 -m conntrack --ctstate NEW -j ACCEPT

# New block at the end of the file
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
-A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80
-A POSTROUTING -d 10.0.0.5 -p tcp --dport 80 -j MASQUERADE
COMMIT

The DNAT rule rewrites the destination of incoming packets. The MASQUERADE rule rewrites their source to the gateway's address, so the backend sends replies back through the gateway even if its default route points elsewhere. Apply the file with sudo iptables-apply -t 60 /etc/iptables/rules.v4 as before, then test from outside with curl -I http://your_server_ip:8080.

Troubleshooting

  • Locked out after applying rules: log in through the provider's console and run sudo iptables -P INPUT ACCEPT && sudo iptables -F INPUT to open the chain, then fix the file. Using iptables-apply avoids this situation.
  • Rules disappear after reboot: netfilter-persistent is disabled or the files in /etc/iptables/ are outdated. Run sudo netfilter-persistent save and sudo systemctl enable netfilter-persistent.
  • iptables-restore: line N failed: the rule on that line has a typo, or the file is missing a COMMIT line for a table.
  • IPv6 stops working after loading rules.v6: ICMPv6 is blocked. Keep the -p ipv6-icmp -j ACCEPT rule above anything that drops.
  • A new rule has no effect: an earlier rule already matched the packet. Compare the pkts counters in sudo iptables -L -n -v --line-numbers to see which rule is catching the traffic.

Conclusion

You built a stateful iptables firewall for IPv4 and IPv6, applied it with an automatic rollback, added SSH rate limiting and logging, and made it persistent with netfilter-persistent. As next steps, consider moving to native nftables syntax (iptables-restore-translate converts your rules file), add Fail2ban for log-based blocking of abusive clients, and keep /etc/iptables/ under version control so every change is reviewable.