--- title: "How to Set Up an iptables Firewall on Ubuntu 24.04" description: "Build a stateful iptables firewall on Ubuntu 24.04, apply it safely with iptables-apply, add SSH rate limiting, logging and IPv6 rules, and keep it on reboot." category: "Network Configuration" language: en source: https://cubepath.com/docs/network-configuration/iptables-configuration-complete-guide updated: 2026-09-25 --- # How to Set Up an iptables Firewall on Ubuntu 24.04 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. > Warning: UFW, 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-restore` would 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: ```bash sudo iptables -V ``` ```output iptables v1.8.10 (nf_tables) ``` List the current rules with packet counters and line numbers: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```bash sudo nano /etc/iptables/rules.v4 ``` Paste this ruleset for a server that offers SSH, HTTP and HTTPS: ```text *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: ```bash 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: ```bash sudo iptables-apply -t 60 /etc/iptables/rules.v4 ``` ```output 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`: ```output ... 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: ```bash sudo iptables -L INPUT -n -v --line-numbers ``` ```output 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): ```bash 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: ```bash sudo nano /etc/iptables/rules.v6 ``` ```text *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: ```bash 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: ```bash 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: ```bash sudo netfilter-persistent save ``` Reboot once during a maintenance window and confirm that the rules are back: ```bash sudo iptables -S INPUT | head -5 ``` ```output -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: ```bash 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`: ```bash 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: ```bash 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: ```bash 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: ```bash sudo netfilter-persistent save ``` To read the log entries produced by the `LOG` rule: ```bash sudo journalctl -k --grep 'iptables-drop' -n 20 ``` ```output 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: ```bash sudo nano /etc/sysctl.d/99-ip-forward.conf ``` ```ini net.ipv4.ip_forward = 1 ``` ```bash 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`: ```text # 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.