Host firewalls: ufw and firewalld
Default deny with the distro front-end.
A host firewall is the second lock behind "only run what you need": it decides which of the ports that are still open can be reached, and from where. In this lesson you set up a default-deny firewall with ufw on Ubuntu and firewalld on RHEL without locking yourself out, check it from other hosts instead of trusting its status output, read the nftables rules both generate, and see why only one tool may own a host's ruleset. The next lesson writes an nftables ruleset by hand.
One packet filter, several managers
The filtering happens in the kernel, in netfilter, and its rules are nftables rules on both platforms. The tools you type into are managers that write those rules: ufw ("uncomplicated firewall") is Ubuntu's default firewall tool, firewalld is RHEL's, and nftables.service loads a hand-written file. On a default Ubuntu Server 26.04 install ufw is present but switched off:
ufw.service is enabled and active, but that unit only runs ufw's boot script, which loads rules when /etc/ufw/ufw.conf says ENABLED=yes; it says no, so no firewall is loaded. The defaults waiting in /etc/default/ufw are the right ones: drop incoming, allow outgoing, drop forwarded. ufw is built on the iptables commands, which on 26.04 write nftables rules (nf_tables in the version). nftables.service is disabled. So a fresh Ubuntu server has no host firewall. CIS RHEL 10 Level 1 (scap-security-guide 0.1.82) requires firewalld enabled; SecOpsLog advice is a host firewall on every server.
The lab: a server between two networks
The lab never touches the VM's own firewall. Its "server" is a network namespace, hard-hfw-web: a separate copy of the kernel's network stack with its own interfaces, ports and ruleset. Two more namespaces play an admin workstation on the management network and a host on the outside network, linked to the server by veth pairs (virtual cables). Commands for the server start with sudo ip netns exec hard-hfw-web; on a real server you type only what follows. ip netns exec also bind-mounts anything in /etc/netns/hard-hfw-web/ over the same name in /etc, so a copy of /etc/ufw there receives ufw's changes. This script builds the whole network (it needs socat, sudo apt install socat):
#!/bin/sh# SecOpsLog lab network for the host firewall lessons: a server namespace# (hard-hfw-web) between an admin host (hard-hfw-adm, 192.0.2.0/24) and an# outside host (hard-hfw-out, 203.0.113.0/24). Needs socat. Run as root.set -eucase "${1:-}" inup)for n in hard-hfw-web hard-hfw-adm hard-hfw-out; doip netns add $nip -n $n link set lo updoneip link add hfw-web-a netns hard-hfw-web type veth peer name hfw-adm netns hard-hfw-admip link add hfw-web-b netns hard-hfw-web type veth peer name hfw-out netns hard-hfw-outip -n hard-hfw-web addr add 192.0.2.1/24 dev hfw-web-aip -n hard-hfw-web addr add 203.0.113.1/24 dev hfw-web-bip -n hard-hfw-adm addr add 192.0.2.10/24 dev hfw-admip -n hard-hfw-out addr add 203.0.113.50/24 dev hfw-outip -n hard-hfw-web link set hfw-web-a upip -n hard-hfw-web link set hfw-web-b upip -n hard-hfw-adm link set hfw-adm upip -n hard-hfw-out link set hfw-out up# ufw run through "ip netns exec hard-hfw-web" uses this copy of /etc/ufw.mkdir -p /etc/netns/hard-hfw-webcp -a /etc/ufw /etc/netns/hard-hfw-web/ufw# Listeners on the server. The SSH stand-in sends the time every second,# so a connection to it stays open like a login session.u="systemd-run --quiet -p NetworkNamespacePath=/run/netns/hard-hfw-web"$u --unit=hard-hfw-l22 socat TCP-LISTEN:22,fork,reuseaddr SYSTEM:'while date +%T; do sleep 1; done'$u --unit=hard-hfw-l80 socat TCP-LISTEN:80,fork,reuseaddr SYSTEM:'echo web'$u --unit=hard-hfw-l8080 socat TCP-LISTEN:8080,fork,reuseaddr SYSTEM:'echo admin-console';;down)systemctl stop hard-hfw-l22 hard-hfw-l80 hard-hfw-l8080 || truefor n in hard-hfw-web hard-hfw-adm hard-hfw-out; do ip netns del $n || true; donerm -rf /etc/netns/hard-hfw-web;;*)echo "usage: $0 up|down" >&2exit 2;;esac
Every listener is on every address, and the outside host reaches SSH and the admin console. nc -z only opens a TCP connection; -w 3 waits three seconds at most. The lab also keeps one connection open from the admin host to port 22, standing in for your SSH session.
ufw: a rollback first, then rules, then enable
The classic mistake is to enable the firewall on a remote server before allowing SSH. ufw has no timer of its own, so arm one before any risky change: systemd-run --on-active= starts a transient timer that runs a command later, here ufw disable. On a real server the command is sudo systemd-run --on-active=5min --unit=ufw-rollback /usr/sbin/ufw disable, and sudo systemctl stop ufw-rollback.timer cancels it once you have logged in again. The lab arms a short one and then makes the mistake on purpose, enabling the firewall with only its default deny:
The existing session keeps ticking, because ufw's built-in rules accept packets of established connections. A new connection from the same admin host times out. ufw warns "Command may disrupt existing ssh connections" only when it finds a process named sshd among its ancestors (under_ssh() in its source), so here it did not ask; treat the prompt as a reminder, not a safeguard. Then the timer fires:
The rollback service ran ufw disable (its output is the first journal line), ufw is inactive, and a new connection from the admin host gets through.
The safe order is rules first. ufw accepts rules while inactive, and ufw show added lists them. A single server with key-only SSH, reached from a home connection whose address changes (and may be IPv6), cannot restrict SSH by source; ufw limit 22/tcp allows it from anywhere with a rate limit, for IPv4 and IPv6. Restrict by source only when you have fixed management addresses in both families (shown further down).
The rollback was armed again, the new login worked, and only then was the timer stopped. Now test from outside:
The outside host reaches SSH (rate-limited, as for anyone) and the web port, and the admin console on 8080 is closed to it without a rule of its own, which is what default deny buys. Both rules got an IPv6 twin (Rules updated (v6)). The status shows the result:
Logging: on (low) is Ubuntu's default level: blocked packets are logged with rate limiting. disabled (routed) means this host does not forward packets at all, so the forwarding policy has nothing to act on. The rule numbers are what ufw delete NUM takes.
sudo ufw show added, arm the systemd-run --on-active rollback, keep a second session open, and only then sudo ufw enable. Log in again from a new session before you stop the rollback timer. Have your provider's console at hand either way.Rate limits, logs and the rules underneath
ufw limit allows a connection unless the source has tried to open 6 or more within 30 seconds (ufw(8)), which slows down password guessing. After a 30-second pause, open eight connections in a row from the admin host:
Five connections succeed, and from the sixth on the admin host is refused until its 30 seconds have passed. Mind the impact: automation that opens many SSH connections from one address (Ansible with several forks, a shared jump host) hits the same limit. Blocked and limited packets appear in the kernel log with ufw's prefixes:
[UFW BLOCK] shows the outside host's attempt on 8080 (one SYN, retransmitted), [UFW LIMIT BLOCK] the admin host's sixth connection. (Logging from inside a namespace needs net.netfilter.nf_log_all_netns=1, which the lab set and restored; a real server does not need it.) Now look at what ufw actually wrote:
ufw's rules are ordinary nftables tables, ip filter and ip6 filter, created through iptables-nft, which is why the listing warns not to edit them with nft. The xt match "recent" lines are the limit, an old iptables module nftables runs in compatibility mode, and the counters account for every new SSH connection since the rules went in: ten matched the rate check, three were sent on to ufw-user-limit and refused, and seven were accepted (the admin host's test, the outside host's check and the first five of the eight in a row).
When you do have fixed management addresses, restrict SSH to them. Replace the open limit rule with one for the management network:
The outside host now times out on port 22 while the admin network still gets in. ufw added only an IPv4 rule this time (Rule added, no v6 line), because the source is an IPv4 network: over IPv6, SSH is now closed to everyone. On a real server, list every management network in both address families, or the first login over IPv6 is the one that fails.
A stateful firewall has a capacity cost: every flow is an entry in the connection tracking table, and when it is full the kernel logs "nf_conntrack: table full, dropping packet" and drops new connections. Before enabling a firewall on a load balancer, DNS server or proxy, compare the count with the limit:
The table on this lab VM holds up to 65536 entries and holds 7 right now; the kernel sizes the default from the machine's memory. Size the table for the peak, alert on the log message, and for very high-volume stateless traffic consider notrack rules (the kernel's nf_conntrack-sysctl documentation).
-p 8080:80 is redirected in the nat table and passes through the forwarding path, not INPUT. ufw's rules and its deny (incoming) default never see it, so the port is open to everyone while ufw status lists only 22 and 80 (Docker's "Packet filtering and firewalls" documentation). Filter published ports where the runtime expects it (Docker's DOCKER-USER chain), or publish them on 127.0.0.1 only, and verify from another host. On such hosts the runtime is always a second manager of the ruleset, so never run nft flush ruleset casually.Rolling back comes in three strengths. ufw delete removes one rule, ufw disable unloads the firewall and sets ENABLED=no so it stays off after a reboot, and ufw reset returns to the installed defaults after saving every rules file with a timestamp:
And the proof that all of this happened in the namespace's own copy: the VM's ufw is exactly as it was.
firewalld on RHEL 10
No RHEL 10 document says outright that installation enables firewalld; the firewall guide only names public as the default zone. The RHEL 10 release notes do list a known issue, that Kickstart's services --disabled=firewalld fails to disable it (use firewall --disabled), which implies a standard installation enables it, and the package's preset is enabled (below). The Rocky cloud image the lab uses leaves firewalld out; the lab installed it, the preset enabled it, and the lab started it. A zone is a named trust level with its own allowed services; each interface or source address belongs to one zone, and anything not assigned to a zone uses the default zone. A test web server listens on 192.0.2.1:8080 on the VM, reached from a client namespace at 192.0.2.10; the rules below never touch SSH and were removed afterwards.
public allows ssh, dhcpv6-client and cockpit (port 9090, the web console) and nothing else, and its default target rejects the rest: ncat's "No route to host" is its reaction to the ICMP reject firewalld sends, a faster and clearer failure than a timeout. The lab's veth interface is in no zone, so the default zone applies to it too.
firewalld keeps two configurations. The runtime configuration is what is loaded now; the permanent one is on disk and becomes the runtime at the next --reload or restart. A runtime change can also carry --timeout, after which firewalld removes it on its own, which makes it the safest way to try a rule on a remote host. A rich rule is firewalld's syntax for a rule with a source, port and action in one line:
The rule exists at runtime and not in the permanent configuration (the second command printed nothing), the client gets through, and nft shows the rule firewalld generated inside its own table, inet firewalld, next to the zone's service rules (22 for ssh, 546 for DHCPv6, 9090 for cockpit). Ninety seconds later:
The rule removed itself and the port is closed again. Once a rule has proved itself, make it permanent, either by repeating it with --permanent or with sudo firewall-cmd --runtime-to-permanent (which saves the whole runtime state, so check it first):
A --permanent change does nothing until --reload, and it lives in /etc/firewalld/zones/public.xml. The rollback is the matching --remove-... with --permanent and a reload, or deleting the file under /etc/firewalld/zones to fall back to the packaged zone. Two cautions: --reload discards runtime-only rules, and firewall-cmd --panic-on drops all traffic, including your session.
One manager per host
Every manager assumes it owns the ruleset. Ubuntu ships an /etc/nftables.conf for nftables.service that starts with flush ruleset. Load it into the lab server where ufw is active, as systemctl start nftables would on a real host:
ufw's tables are gone, replaced by an empty inet filter table that accepts everything. ufw notices and reports itself inactive, but nothing else does: ufw.conf still says enabled, nobody ran ufw disable, and the admin console is reachable from outside until someone runs ufw reload. The same happens on RHEL, where stopping nftables.service runs nft flush ruleset and deletes firewalld's table, and systemd does not prevent the two from running together:
firewalld declares conflicts with the old iptables services, not with nftables.service. Container engines and virtualisation tools add their own tables as well (see the container callout above), and a flush ruleset deletes them too. The rule, which is SecOpsLog advice: pick one manager per host (ufw on Ubuntu, firewalld on RHEL, or a hand-written ruleset when you need what the next lesson shows), disable the others, and after any change verify from another host rather than from the manager's status output.
Try this
On your Ubuntu lab machine, save the script above as ~/hard-hfw-lab, make it executable, and run sudo ~/hard-hfw-lab up. Arm the rollback timer for two minutes, add allow from 192.0.2.0/24 to any port 22 proto tcp, check it with ufw show added, enable, confirm from the admin host with sudo ip netns exec hard-hfw-adm nc -zv -w 3 192.0.2.1 22, and stop the timer. Then load /etc/nftables.conf in the namespace, run ufw status, confirm the client now reaches every port, and repair it with ufw reload. Finish with sudo ~/hard-hfw-lab down.
Takeaway
Use the distribution's firewall manager, add the rule for your own access before you enable it, prefer self-expiring rules (--timeout in firewalld, a systemd-run rollback for ufw) when working remotely, and let only one tool own the ruleset. Believe the test from another host, not the status output.