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
sudoprivileges. - Access to the server console from your provider's panel, in case a mistake blocks SSH.
WarningUFW, iptables rules and nftables rules all end up in the same kernel packet filter. Use only one tool to manage the firewall. This guide replaces UFW with plain iptables rules. If the server runs Docker, do not follow this guide as is: Docker creates its own iptables chains and a full
iptables-restorewould remove them.
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:
| Table | Chains used here | Purpose |
|---|---|---|
filter | INPUT, FORWARD, OUTPUT | Accept or drop packets addressed to, routed through or sent by the server |
nat | PREROUTING, POSTROUTING | Rewrite 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:
INPUTandFORWARDdrop by default,OUTPUTaccepts, so the server can still start outbound connections (updates, DNS, APIs). - Loopback and conntrack: local services talking to each other over
loare allowed, and so are packets belonging to connections that are already established.RELATEDalso admits ICMP errors such as "fragmentation needed", which path MTU discovery depends on. Packets conntrack classifies asINVALIDare dropped. - SSH rate limit: the
recentmodule 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
LOGrule sits just before the policy, so it records packets that are about to be dropped. Thelimitmatch 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 INPUTto open the chain, then fix the file. Usingiptables-applyavoids this situation. - Rules disappear after reboot:
netfilter-persistentis disabled or the files in/etc/iptables/are outdated. Runsudo netfilter-persistent saveandsudo systemctl enable netfilter-persistent. iptables-restore: line N failed: the rule on that line has a typo, or the file is missing aCOMMITline for a table.- IPv6 stops working after loading rules.v6: ICMPv6 is blocked. Keep the
-p ipv6-icmp -j ACCEPTrule above anything that drops. - A new rule has no effect: an earlier rule already matched the packet. Compare the
pktscounters insudo iptables -L -n -v --line-numbersto 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.
