🧱 Firewall Basics

nftables, ufw, firewalld and iptables: default deny, safe changes, a complete ruleset, testing and fail2ban

Firewall Basics: nftables, ufw, firewalld and iptables
sudo nft list ruleset

A host firewall decides which network packets reach your programs. Done well it is a few lines: drop everything by default, allow the traffic you meant to offer, and log the rest. Done badly it is a pile of rules nobody understands, or one typo that locks you out of your own server.
This guide explains the model, the four tools you will meet (they sit on top of the same kernel feature), how to change rules without locking yourself out, a complete minimal server ruleset, and how to test and troubleshoot.
Most firewall commands need root and change a live machine, so they are shown without output. What did run for real: the nftables syntax checker (it reports errors without root) and iptables-translate (it converts old iptables rules into nftables syntax), both on Ubuntu 24.04.

The model in five lines

1. Packets are checked against RULES in CHAINS. A chain has a hook (where in the packet's journey it runs) and a POLICY (what happens if no rule matches).
2. The three questions: INPUT (traffic to this machine), OUTPUT (from this machine), FORWARD (passing through, for routers and containers).
3. STATEFUL: the firewall tracks connections (conntrack). Once an outgoing connection is allowed, its replies are accepted automatically with "established,related". You never write rules for reply traffic.
4. Rules are read top to bottom and the first match wins. So: accept established connections first, then specific allows, and finish with the default (drop).
5. DEFAULT DENY: set the input policy to drop and allow only what you serve. A firewall is not a substitute for not running unneeded services, but it is a second wall behind it.
Drop or reject? DROP silently discards (the sender waits and times out: slows scanners and hides that anything is there). REJECT answers "connection refused" (polite and quick: better for your own LAN, easier to debug). On the internet edge, drop is the usual choice.

Which tool is in charge here?

The kernel feature is Netfilter. Its modern interface is nftables (nft). The others are front ends:
  nft          the native tool and language. Default framework on current Debian, Ubuntu, RHEL, Fedora.
  iptables     the old interface. On current systems "iptables" is usually iptables-nft: the old commands, translated into nftables behind the scenes.
  ufw          "Uncomplicated Firewall": simple commands, the Ubuntu default (installed, disabled until you enable it).
  firewalld    zone-based manager with a daemon; the RHEL, Fedora, Rocky and Alma default.
Use ONE manager. ufw and firewalld both write rules into the kernel and will overwrite each other (and your hand-written nft rules). Find out which is active BEFORE you change anything:
$ systemctl is-active ufw firewalld nftables netfilter-persistent 2>/dev/null
$ sudo ufw status verbose            # ufw
$ sudo firewall-cmd --state          # firewalld
$ sudo nft list ruleset              # what is actually in the kernel, whoever put it there
$ sudo iptables -S                  # the old interface's view of the rules
("nf_tables" in the iptables version below means the old commands are talking to nftables behind the scenes.)
$ iptables --version
iptables v1.8.10 (nf_tables)
Cloud networks add an OUTER firewall (AWS security groups, Azure NSGs, GCP firewall rules, the router at home). Traffic must pass both: if "it works from the LAN but not from the internet", check the outer one first. For Kubernetes see NetworkPolicy in the container hardening guide.

Before you change anything: do not lock yourself out

The one rule of firewall work: keep a way back in.
  1. Keep the SSH session you are using OPEN, and test new rules from a SECOND session before closing the first. Existing connections survive because of the established rule.
  2. Have console access (hypervisor console, cloud serial console, a keyboard on the box).
  3. Allow SSH BEFORE you turn the firewall on or set the default to drop.
  4. Use a timed rollback so that a mistake undoes itself:
$ echo 'nft flush ruleset' | sudo at now + 5 minutes        # a safety net: in 5 minutes the rules are cleared (cancel it with atrm once you are happy)
$ sudo systemd-run --on-active=5min --unit=fw-rollback /usr/sbin/nft flush ruleset      # the same with systemd; cancel: sudo systemctl stop fw-rollback.timer
Even better, a small script that applies a rules file, asks you to confirm, and restores the old rules by itself if you do not (if the new rules cut your connection you cannot answer, so they roll back):
$ cat > apply-firewall.sh <<'EOF'
> #!/usr/bin/env bash
> # usage: sudo ./apply-firewall.sh new-rules.nft
> set -euo pipefail
> new=${1:?usage: $0 rules.nft}
> backup=$(mktemp)
> nft list ruleset > "$backup"          # remember what is active now
> nft -c -f "$new"                      # syntax check first: stops here on errors
> nft -f "$new"                         # apply atomically: all rules or none
> echo "New rules are active. Type YES within 30 seconds to keep them."
> if read -r -t 30 answer && [ "$answer" = YES ]; then
>   echo "kept."; rm -f "$backup"
> else
>   echo "no confirmation: rolling back"; nft flush ruleset; nft -f "$backup"
> fi
> EOF
$ bash -n apply-firewall.sh && echo "syntax OK"
syntax OK

ufw: the quick, readable way (Ubuntu, Debian)

$ sudo ufw status verbose                       # starts as "inactive"
$ sudo ufw default deny incoming                # the policy
$ sudo ufw default allow outgoing
$ sudo ufw allow OpenSSH                        # SSH FIRST (an application profile; or: sudo ufw allow 22/tcp)
$ sudo ufw limit 22/tcp                         # allow SSH but throttle repeated connection attempts (6 in 30 seconds)
$ sudo ufw allow 80,443/tcp                     # web server (list multiple ports with "proto" to say which)
$ sudo ufw allow from 192.0.2.0/24 to any port 22 proto tcp     # only the admin network may use SSH
$ sudo ufw allow from 192.0.2.7 to any port 5432 proto tcp      # one client may reach the database
$ sudo ufw deny from 203.0.113.9                # block one address
$ sudo ufw enable                               # turn it on (it warns that SSH connections may drop: you allowed SSH, so they will not)
$ sudo ufw status numbered                      # rules with numbers
$ sudo ufw delete 3                             # delete by number (numbers shift after each delete: list again);  sudo ufw delete allow 80/tcp  also works
$ sudo ufw logging medium; sudo tail /var/log/ufw.log      # blocked packets are logged to the kernel log and /var/log/ufw.log
$ sudo ufw app list                             # profiles installed by packages (nginx, OpenSSH, ...)
$ sudo ufw reset                                # throw everything away (disables it)
Check IPV6=yes in /etc/default/ufw if you use IPv6, so the same rules apply to it. ufw writes its rules into /etc/ufw/*.rules and loads them with iptables-nft.
Docker publishes ports around ufw: a classic and dangerous surprise
Docker writes its own rules (the DOCKER chains) that run BEFORE ufw's rules for forwarded traffic. A container started with -p 8080:80 is reachable from everywhere even though "ufw deny 8080" is in place. Fixes, in order of preference:
$ docker run -p 127.0.0.1:8080:80 myimage       # publish ONLY on loopback (reach it through a reverse proxy)
$ docker compose: ports: ["127.0.0.1:8080:80"]  # the same in a compose file
$ sudo iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.0/24 -j DROP       # filter in the DOCKER-USER chain, which Docker never overwrites
Check what is really exposed from outside with a scanner on ANOTHER machine, not by trusting ufw status.

firewalld: zones (RHEL, Fedora, Rocky, Alma)

firewalld groups interfaces and source networks into ZONES (public, internal, trusted, dmz...) and attaches services to zones. There is a RUNTIME configuration (live, lost at reload) and a PERMANENT one (saved): change permanent and reload.
$ sudo firewall-cmd --state; sudo firewall-cmd --get-default-zone; sudo firewall-cmd --get-active-zones
$ sudo firewall-cmd --list-all                                     # what the default zone allows
$ sudo firewall-cmd --get-services | tr ' ' '\n' | head           # the named services it knows
$ sudo firewall-cmd --permanent --add-service=https               # allow a service by name
$ sudo firewall-cmd --permanent --add-port=8080/tcp               # or a port
$ sudo firewall-cmd --permanent --remove-service=cockpit
$ sudo firewall-cmd --reload                                      # make permanent changes live
$ sudo firewall-cmd --permanent --zone=internal --add-source=192.0.2.0/24     # treat a network as "internal"
$ sudo firewall-cmd --permanent --zone=internal --add-service=ssh
$ sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.0.2.0/24" port port="5432" protocol="tcp" accept'
$ sudo firewall-cmd --add-service=http --timeout=10m              # temporary: removed automatically after 10 minutes (the rollback safety net, built in)
$ sudo firewall-cmd --runtime-to-permanent                        # keep what you tested live
$ sudo firewall-cmd --check-config                                # validate the permanent files
$ sudo firewall-cmd --panic-on                                    # EMERGENCY: drop all traffic (you will lose your session: console only)

nftables: the language underneath

Concepts: a TABLE (family inet = IPv4 and IPv6 together) holds CHAINS; a chain with a "type filter hook input" runs on incoming packets; RULES match fields and end in a verdict (accept, drop, reject, counter, log...); SETS hold addresses or ports that many rules can share.
A complete, minimal ruleset for a web server with SSH restricted to an admin network. This is the file you would keep in /etc/nftables.conf:
$ cat <<'EOF'
> #!/usr/sbin/nft -f
> flush ruleset
> 
> table inet filter {
>     set admins {                                  # who may reach SSH
>         type ipv4_addr
>         flags interval
>         elements = { 192.0.2.0/24, 198.51.100.7 }
>     }
> 
>     chain input {
>         type filter hook input priority 0; policy drop;
> 
>         ct state invalid drop
>         ct state established,related accept       # replies to connections WE or an allowed client started
>         iif "lo" accept                           # loopback
> 
>         ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } limit rate 10/second accept
>         ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
> 
>         tcp dport 22 ip saddr @admins ct state new limit rate 6/minute accept
>         tcp dport { 80, 443 } accept
> 
>         limit rate 5/minute log prefix "nft-drop: " counter drop
>     }
>     chain forward { type filter hook forward priority 0; policy drop; }
>     chain output  { type filter hook output priority 0; policy accept; }
> }
> EOF
#!/usr/sbin/nft -f
flush ruleset

table inet filter {
    set admins {                                  # who may reach SSH
        type ipv4_addr
        flags interval
        elements = { 192.0.2.0/24, 198.51.100.7 }
    }

    chain input {
        type filter hook input priority 0; policy drop;

        ct state invalid drop
        ct state established,related accept       # replies to connections WE or an allowed client started
        iif "lo" accept                           # loopback

        ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } limit rate 10/second accept
        ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept

        tcp dport 22 ip saddr @admins ct state new limit rate 6/minute accept
        tcp dport { 80, 443 } accept

        limit rate 5/minute log prefix "nft-drop: " counter drop
    }
    chain forward { type filter hook forward priority 0; policy drop; }
    chain output  { type filter hook output priority 0; policy accept; }
}
What it does, line by line: the policy is DROP, so only listed traffic gets in. "ct state established,related" lets replies back. ICMP echo and the error messages are allowed (with a rate limit), and for IPv6 the neighbor-discovery messages MUST be allowed or IPv6 stops working. SSH is accepted only from the admins set and only 6 new connections a minute. Web ports are open to everyone. Anything else is logged (at most 5 per minute, so the log cannot flood) and dropped. Outgoing traffic is unrestricted.
Check the syntax before loading. Here a file with a typo (dprot instead of dport): the checker shows the exact spot. (A VALID file also needs root for the final step, so run nft -c -f file as root for the full check.)
$ nft -c -f bad.nft 2>&1 | head -3
bad.nft:4:7-11: Error: syntax error, unexpected string
  tcp dprot 22 accept
      ^^^^^
$ sudo nft -c -f /etc/nftables.conf             # check (no changes)
$ sudo nft -f /etc/nftables.conf                # load atomically: all of it or nothing
$ sudo systemctl enable --now nftables          # load /etc/nftables.conf at every boot
$ sudo nft list ruleset                         # what is active;  sudo nft list ruleset > backup.nft  saves it
Everyday nft commands
$ sudo nft -a list chain inet filter input            # -a shows a handle number for each rule
$ sudo nft delete rule inet filter input handle 12    # remove one rule by its handle
$ sudo nft insert rule inet filter input tcp dport 8080 accept     # at the TOP;  add rule  appends at the end
$ sudo nft list counters; sudo nft list chain inet filter input     # how many packets hit each rule (counter keyword)
A dynamic blocklist: a set whose entries expire by themselves (great with scripts and log watchers)
$ cat <<'EOF'
> table inet filter {
>     set blocked { type ipv4_addr; flags timeout; }
>     chain input {
>         type filter hook input priority -10; policy accept;
>         ip saddr @blocked drop
>     }
> }
> EOF
table inet filter {
    set blocked { type ipv4_addr; flags timeout; }
    chain input {
        type filter hook input priority -10; policy accept;
        ip saddr @blocked drop
    }
}
$ sudo nft add element inet filter blocked { 203.0.113.9 timeout 1h }
$ sudo nft list set inet filter blocked
NAT and port forwarding (router, gateway or container host): needs forwarding switched on
$ cat <<'EOF'
> table ip nat {
>     chain prerouting  { type nat hook prerouting  priority dstnat; tcp dport 8080 dnat to 10.0.0.5:80 }
>     chain postrouting { type nat hook postrouting priority srcnat; oifname "eth0" masquerade }
> }
> EOF
table ip nat {
    chain prerouting  { type nat hook prerouting  priority dstnat; tcp dport 8080 dnat to 10.0.0.5:80 }
    chain postrouting { type nat hook postrouting priority srcnat; oifname "eth0" masquerade }
}
$ sudo sysctl -w net.ipv4.ip_forward=1; echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-forward.conf
And you must also allow the forwarded traffic in the forward chain (the minimal ruleset above drops it).

iptables: reading and translating the old syntax

You will still meet iptables in tutorials and old servers. The same ideas, different words: -A appends, -I inserts (at the top), -D deletes, -P sets the default policy, -s and -d match addresses, -p tcp --dport matches ports, -j says what to do (ACCEPT, DROP, REJECT, LOG).
$ sudo iptables -S                                  # the rules, as commands
$ sudo iptables -L -n -v --line-numbers             # numbered, with packet counters
$ sudo iptables -C INPUT -p tcp --dport 22 -j ACCEPT && echo "that rule exists"      # -C CHECKS before adding: no duplicates
$ sudo iptables-save > /etc/iptables/rules.v4        # save;  sudo apt install iptables-persistent  loads it at boot (Debian, Ubuntu)
The translator turns an iptables rule into the nft equivalent without touching the system (no root needed), which is a quick way to learn nft or to migrate:
$ iptables-translate -A INPUT -p tcp --dport 22 -s 192.0.2.0/24 -m conntrack --ctstate NEW -j ACCEPT
nft 'add rule ip filter INPUT ip saddr 192.0.2.0/24 tcp dport 22 ct state new counter accept'
$ iptables-translate -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
nft 'add rule ip filter INPUT ip protocol tcp tcp dport { 80, 443 } counter accept'
$ iptables-translate -A INPUT -m limit --limit 5/min -j LOG --log-prefix "drop: "
nft 'add rule ip filter INPUT limit rate 5/minute burst 5 packets counter log prefix "drop: "'
$ iptables-translate -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80
nft 'add rule ip nat PREROUTING tcp dport 8080 counter dnat to 10.0.0.5:80'
$ sudo iptables-save | sudo iptables-restore-translate -f - > converted.nft      # convert a whole old ruleset
Order matters in iptables too: an ACCEPT added with -A AFTER a broad DROP never matches. Insert it with -I, or put the drop last.

Test it from outside, not from inside

The only proof that a port is closed is trying it from another machine.
$ ss -tulpn                                          # on the server: which programs LISTEN (the real list of what could be exposed)
$ nmap -Pn -p 1-1024 server.example.com              # from ANOTHER host: which ports answer (open), refuse (closed) or stay silent (filtered/dropped)
$ nmap -Pn -p- --reason server.example.com           # all 65535 ports, with the reason (syn-ack, reset, no-response)
$ nmap -sU --top-ports 20 server.example.com         # common UDP ports (slow)
$ nc -zv -w3 server.example.com 22 443               # quick check of single ports
$ curl -sS -m5 -o /dev/null -w '%{http_code}\n' https://server.example.com/
Open = something answered; closed = the host answered "no" (reset); filtered = silence (a firewall dropped it). If a port you expect to be filtered shows closed, the packet got through to the machine (no program listening there, but the firewall let it pass).
Watch the rules work
$ sudo nft list ruleset | grep -E 'counter|drop' | head
$ sudo journalctl -k --since "10 min ago" | grep 'nft-drop' | tail          # the log prefix you set
$ sudo tcpdump -ni eth0 'tcp port 22 and not src net 192.0.2.0/24' -c 10    # who is knocking and not allowed
$ sudo nft monitor trace            # then mark a test packet:  sudo nft add rule inet filter input tcp dport 8080 meta nftrace set 1   (follow it through every chain; remove the rule afterwards)

Common policies, in plain words

Web server:           allow 80 and 443 from anywhere; SSH from the admin network only; everything else dropped.
Database:             allow the database port only from the application servers' addresses; SSH from admin only. Never expose a database port to the internet.
Bastion/jump host:    SSH from anywhere with rate limiting and keys only; nothing else.
Outbound control:     servers that should only fetch updates: set the OUTPUT policy to drop and allow DNS (udp/tcp 53), NTP (udp 123), HTTPS to your package mirrors. Strong against malware calling home, but you must maintain it.
Admin network only:   put admin addresses in a set and refer to it (@admins): one place to update.
Log carefully:        log only what you need and rate-limit it, or a scan fills the disk.
Allowed ICMP:         keep echo-request (ping) and the error types (destination-unreachable, time-exceeded, packet-too-big): blocking all ICMP breaks path MTU discovery and IPv6.

Reactive blocking with fail2ban

fail2ban reads logs and bans addresses that fail repeatedly, by adding firewall rules for a while. It reduces noise; it does not replace key-only SSH and a closed firewall.
$ sudo apt install fail2ban          # or: sudo dnf install fail2ban (EPEL)
$ cat <<'EOF'
> # /etc/fail2ban/jail.d/local.conf
> [DEFAULT]
> bantime  = 1h
> findtime = 10m
> maxretry = 5
> ignoreip = 127.0.0.1/8 ::1 192.0.2.0/24
> 
> [sshd]
> enabled = true
> EOF
# /etc/fail2ban/jail.d/local.conf
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1 192.0.2.0/24

[sshd]
enabled = true
$ sudo systemctl enable --now fail2ban
$ sudo fail2ban-client status sshd                       # currently banned addresses and counts
$ sudo fail2ban-client set sshd unbanip 203.0.113.9      # you banned yourself? lift it
Always put your own admin network in ignoreip.

When it does not work

Symptom                                              Check
Locked out after enabling the firewall               console or the timed rollback; you allowed SSH after the default drop. Allow SSH first, always.
Works from the LAN, not from the internet            the outer firewall (router port forward, cloud security group), then NAT; ss -tulpn to be sure the program listens on the right address (0.0.0.0 or ::, not 127.0.0.1)
Rule added but traffic still blocked (or allowed)    order: an earlier rule matched first (nft -a list ... / iptables -L --line-numbers); or another manager (ufw, firewalld, Docker, fail2ban) has rules that run earlier
Rules gone after a reboot                            runtime only: nft rules need /etc/nftables.conf and the nftables service enabled; iptables needs iptables-persistent; firewalld needs --permanent
IPv6 broken after the firewall                       ICMPv6 neighbor discovery was dropped (see the ruleset above), or only IPv4 rules were written (use table inet, or ip6tables as well)
A container is reachable although ufw blocks it      Docker bypasses ufw: publish on 127.0.0.1 or use the DOCKER-USER chain
SSH connections drop after a few minutes             a stateful firewall expired an idle connection in the middle of the path: ServerAliveInterval 30 in ~/.ssh/config
"Connection refused" versus a timeout                refused = reached the host and nothing is listening (or REJECT); timeout = dropped on the way: that difference points at the firewall
Two firewall tools fighting                          pick one: sudo systemctl disable --now ufw (if you use firewalld or nft), and keep only one in charge of the rules

Tips

Default deny, allow SSH first, test from a second session, and use a timed rollback for every change.
Use sets for addresses and ports you refer to more than once, and comments for the reason behind each rule.
Keep the ruleset in a file under version control, check it with nft -c -f, load it atomically, and test from another machine.
Check what is LISTENING (ss -tulpn) as well as what is filtered: a service you do not need should not be running at all.
Remember the other layers: the cloud security group, the router, Docker's own rules, and the application's bind address.
"We turned the firewall off to test and forgot." Put an expiry on every exception (a timeout on an element, firewall-cmd --timeout, an at job).