Host firewalls: ufw and firewalld

Default deny with the distro front-end.

Intermediate16 min · lesson 16 of 24

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:

deploy@web01 · Ubuntu 26.04 LTS (default install)
$ sudo ufw status verbose
Status: inactive
$ systemctl is-enabled ufw.service systemctl is-active ufw.service grep -E "^(ENABLED|LOGLEVEL)" /etc/ufw/ufw.conf
enabled active ENABLED=no LOGLEVEL=low
$ grep -E "^DEFAULT_(INPUT|OUTPUT|FORWARD)" /etc/default/ufw
DEFAULT_INPUT_POLICY="DROP" DEFAULT_OUTPUT_POLICY="ACCEPT" DEFAULT_FORWARD_POLICY="DROP"
$ iptables --version dpkg-query -W ufw nftables iptables
iptables v1.8.11 (nf_tables) iptables 1.8.11-2ubuntu3 nftables 1.1.6-1 ufw 0.36.2-9build1
$ systemctl is-enabled nftables.service
disabled

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):

~/hard-hfw-lab
#!/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 -eu
case "${1:-}" in
up)
for n in hard-hfw-web hard-hfw-adm hard-hfw-out; do
ip netns add $n
ip -n $n link set lo up
done
ip link add hfw-web-a netns hard-hfw-web type veth peer name hfw-adm netns hard-hfw-adm
ip link add hfw-web-b netns hard-hfw-web type veth peer name hfw-out netns hard-hfw-out
ip -n hard-hfw-web addr add 192.0.2.1/24 dev hfw-web-a
ip -n hard-hfw-web addr add 203.0.113.1/24 dev hfw-web-b
ip -n hard-hfw-adm addr add 192.0.2.10/24 dev hfw-adm
ip -n hard-hfw-out addr add 203.0.113.50/24 dev hfw-out
ip -n hard-hfw-web link set hfw-web-a up
ip -n hard-hfw-web link set hfw-web-b up
ip -n hard-hfw-adm link set hfw-adm up
ip -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-web
cp -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 || true
for n in hard-hfw-web hard-hfw-adm hard-hfw-out; do ip netns del $n || true; done
rm -rf /etc/netns/hard-hfw-web
;;
*)
echo "usage: $0 up|down" >&2
exit 2
;;
esac
deploy@web01 · Ubuntu 26.04 LTS
$ sudo ~/hard-hfw-lab up ip netns list
hard-hfw-out hard-hfw-adm hard-hfw-web
The lab network
Admin host (hard-hfw-adm)
192.0.2.10
management network 192.0.2.0/24
Server (hard-hfw-web)
192.0.2.1 and 203.0.113.1
one interface on each network
listeners on 22, 80, 8080
SSH, web, internal admin console
Outside host (hard-hfw-out)
203.0.113.50
any other network
All three are network namespaces on the lab VM, linked by veth pairs.
deploy@web01 · Ubuntu 26.04 LTS
$ sudo ip netns exec hard-hfw-web ss -tln
State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 5 0.0.0.0:22 0.0.0.0:* LISTEN 0 5 0.0.0.0:80 0.0.0.0:* LISTEN 0 5 0.0.0.0:8080 0.0.0.0:*
$ sudo ip netns exec hard-hfw-web ufw status verbose
Status: inactive
$ sudo ip netns exec hard-hfw-out nc -zv -w 3 203.0.113.1 22 sudo ip netns exec hard-hfw-out nc -zv -w 3 203.0.113.1 8080
Connection to 203.0.113.1 22 port [tcp/ssh] succeeded! Connection to 203.0.113.1 8080 port [tcp/http-alt] succeeded!

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:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemd-run --on-active=45s --unit=hard-hfw-rollback ip netns exec hard-hfw-web ufw disable systemctl list-timers --no-legend hard-hfw-rollback.timer | wc -l
Running timer as unit: hard-hfw-rollback.timer Will run service as unit: hard-hfw-rollback.service 1
$ sudo ip netns exec hard-hfw-web ufw enable
Firewall is active and enabled on system startup
$ tail -n 2 /var/tmp/hard-hfw-session.log
09:53:49 09:53:50
$ sudo ip netns exec hard-hfw-adm nc -zv -w 3 192.0.2.1 22
nc: connect to 192.0.2.1 port 22 (tcp) timed out: Operation now in progress

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:

deploy@web01 · Ubuntu 26.04 LTS
$ journalctl -u hard-hfw-rollback.service -n 2 --no-hostname -o cat sudo ip netns exec hard-hfw-web ufw status sudo ip netns exec hard-hfw-adm nc -zv -w 3 192.0.2.1 22
Firewall stopped and disabled on system startup hard-hfw-rollback.service: Deactivated successfully. Status: inactive Connection to 192.0.2.1 22 port [tcp/ssh] succeeded!

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).

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ip netns exec hard-hfw-web ufw limit 22/tcp sudo ip netns exec hard-hfw-web ufw allow 80/tcp
Rules updated Rules updated (v6) Rules updated Rules updated (v6)
$ sudo ip netns exec hard-hfw-web ufw show added
Added user rules (see 'ufw status' for running firewall): ufw limit 22/tcp ufw allow 80/tcp
$ sudo systemd-run --on-active=5min --unit=hard-hfw-rollback ip netns exec hard-hfw-web ufw disable sudo ip netns exec hard-hfw-web ufw enable
Running timer as unit: hard-hfw-rollback.timer Will run service as unit: hard-hfw-rollback.service Firewall is active and enabled on system startup
$ sudo ip netns exec hard-hfw-adm nc -zv -w 3 192.0.2.1 22
Connection to 192.0.2.1 22 port [tcp/ssh] succeeded!
$ sudo systemctl stop hard-hfw-rollback.timer systemctl list-timers --no-legend hard-hfw-rollback.timer | wc -l
0

The rollback was armed again, the new login worked, and only then was the timer stopped. Now test from outside:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ip netns exec hard-hfw-out nc -zv -w 3 203.0.113.1 22 sudo ip netns exec hard-hfw-out nc -zv -w 3 203.0.113.1 8080 sudo ip netns exec hard-hfw-out nc -zv -w 3 203.0.113.1 80
Connection to 203.0.113.1 22 port [tcp/ssh] succeeded! nc: connect to 203.0.113.1 port 8080 (tcp) timed out: Operation now in progress Connection to 203.0.113.1 80 port [tcp/http] succeeded!

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:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ip netns exec hard-hfw-web ufw status verbose
Status: active Logging: on (low) Default: deny (incoming), allow (outgoing), disabled (routed) New profiles: skip To Action From -- ------ ---- 22/tcp LIMIT IN Anywhere 80/tcp ALLOW IN Anywhere 22/tcp (v6) LIMIT IN Anywhere (v6) 80/tcp (v6) ALLOW IN Anywhere (v6)
$ sudo ip netns exec hard-hfw-web ufw status numbered
Status: active To Action From -- ------ ---- [ 1] 22/tcp LIMIT IN Anywhere [ 2] 80/tcp ALLOW IN Anywhere [ 3] 22/tcp (v6) LIMIT IN Anywhere (v6) [ 4] 80/tcp (v6) ALLOW IN Anywhere (v6)

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.

Before you enable a firewall remotely
Add the rule for your own access, run 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:

deploy@web01 · Ubuntu 26.04 LTS
$ for i in 1 2 3 4 5 6 7 8; do sudo ip netns exec hard-hfw-adm nc -z -w 3 192.0.2.1 22 && echo "$i open" || echo "$i refused" done
Connection to 192.0.2.1 22 port [tcp/ssh] succeeded! 1 open Connection to 192.0.2.1 22 port [tcp/ssh] succeeded! 2 open Connection to 192.0.2.1 22 port [tcp/ssh] succeeded! 3 open Connection to 192.0.2.1 22 port [tcp/ssh] succeeded! 4 open Connection to 192.0.2.1 22 port [tcp/ssh] succeeded! 5 open 6 refused 7 refused 8 refused

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:

deploy@web01 · Ubuntu 26.04 LTS
$ journalctl -k --since '-2min' --no-hostname --grep 'SRC=203.0.113.50' | tail -n 2
Sep 27 09:54:36 kernel: [UFW BLOCK] IN=hfw-web-b OUT= MAC=0a:10:ad:33:02:9d:5e:aa:8f:eb:a4:c7:08:00 SRC=203.0.113.50 DST=203.0.113.1 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=55244 DF PROTO=TCP SPT=50196 DPT=8080 WINDOW=64240 RES=0x00 SYN URGP=0 Sep 27 09:54:37 kernel: [UFW BLOCK] IN=hfw-web-b OUT= MAC=0a:10:ad:33:02:9d:5e:aa:8f:eb:a4:c7:08:00 SRC=203.0.113.50 DST=203.0.113.1 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=55245 DF PROTO=TCP SPT=50196 DPT=8080 WINDOW=64240 RES=0x00 SYN URGP=0
$ journalctl -k --since '-2min' --no-hostname --grep 'UFW LIMIT BLOCK' | tail -n 1
Sep 27 09:55:09 kernel: [UFW LIMIT BLOCK] IN=hfw-web-a OUT= MAC=26:98:b8:af:62:de:76:f4:40:24:56:56:08:00 SRC=192.0.2.10 DST=192.0.2.1 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=43908 DF PROTO=TCP SPT=51942 DPT=22 WINDOW=64240 RES=0x00 SYN URGP=0

[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:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ip netns exec hard-hfw-web nft list tables
table ip filter table ip6 filter
$ sudo ip netns exec hard-hfw-web nft list chain ip filter ufw-user-input
# Warning: table ip filter is managed by iptables-nft, do not touch! table ip filter { chain ufw-user-input { tcp dport 22 ct state new xt match "recent" counter packets 10 bytes 600 tcp dport 22 ct state new xt match "recent" counter packets 3 bytes 180 jump ufw-user-limit tcp dport 22 counter packets 7 bytes 420 jump ufw-user-limit-accept tcp dport 80 counter packets 1 bytes 60 accept } }

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:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ip netns exec hard-hfw-web ufw delete limit 22/tcp sudo ip netns exec hard-hfw-web ufw limit proto tcp from 192.0.2.0/24 to any port 22
Rule deleted Rule deleted (v6) Rule added
$ sudo ip netns exec hard-hfw-out nc -zv -w 3 203.0.113.1 22 sudo ip netns exec hard-hfw-adm nc -zv -w 3 192.0.2.1 22
nc: connect to 203.0.113.1 port 22 (tcp) timed out: Operation now in progress Connection to 192.0.2.1 22 port [tcp/ssh] succeeded!

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:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ip netns exec hard-hfw-web sysctl net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_count
net.netfilter.nf_conntrack_max = 65536 net.netfilter.nf_conntrack_count = 7

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).

Container hosts: published ports bypass these rules
On a host running Docker, or rootful Podman with published ports, traffic to a port published with -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:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ip netns exec hard-hfw-web ufw delete allow 80/tcp sudo ip netns exec hard-hfw-web ufw status numbered
Rule deleted Rule deleted (v6) Status: active To Action From -- ------ ---- [ 1] 22/tcp LIMIT IN 192.0.2.0/24
$ sudo ip netns exec hard-hfw-web ufw disable sudo ip netns exec hard-hfw-web ufw status
Firewall stopped and disabled on system startup Status: inactive
$ sudo ip netns exec hard-hfw-web ufw --force reset
Backing up 'user.rules' to '/etc/ufw/user.rules.20260927_095549' Backing up 'before.rules' to '/etc/ufw/before.rules.20260927_095549' Backing up 'after.rules' to '/etc/ufw/after.rules.20260927_095549' Backing up 'user6.rules' to '/etc/ufw/user6.rules.20260927_095549' Backing up 'before6.rules' to '/etc/ufw/before6.rules.20260927_095549' Backing up 'after6.rules' to '/etc/ufw/after6.rules.20260927_095549'

And the proof that all of this happened in the namespace's own copy: the VM's ufw is exactly as it was.

deploy@web01 · Ubuntu 26.04 LTS
$ grep ENABLED /etc/ufw/ufw.conf sudo ufw status
ENABLED=no Status: inactive

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.

deploy@rocky10 · Rocky Linux 10.2
$ systemctl status firewalld --lines 0 | head -n 3
● firewalld.service - firewalld - dynamic firewall daemon Loaded: loaded (/usr/lib/systemd/system/firewalld.service; enabled; preset: enabled) Active: active (running) since Sun 2026-09-27 09:27:23 UTC; 1s ago
$ sudo firewall-cmd --get-default-zone sudo firewall-cmd --get-active-zones
public public (default) interfaces: eth0
$ sudo firewall-cmd --list-all
public (default, active) target: default ingress-priority: 0 egress-priority: 0 icmp-block-inversion: no interfaces: eth0 sources: services: cockpit dhcpv6-client ssh ports: protocols: forward: yes masquerade: no forward-ports: source-ports: icmp-blocks: rich rules:
$ sudo ip netns exec hard-hfw-cli nc -zv -w 3 192.0.2.1 22 sudo ip netns exec hard-hfw-cli nc -zv -w 3 192.0.2.1 8080
Ncat: Version 7.92 ( https://nmap.org/ncat ) Ncat: Connected to 192.0.2.1:22. Ncat: 0 bytes sent, 0 bytes received in 0.01 seconds. Ncat: Version 7.92 ( https://nmap.org/ncat ) Ncat: No route to host.

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:

deploy@rocky10 · Rocky Linux 10.2
$ sudo firewall-cmd --add-rich-rule='rule family="ipv4" source address="192.0.2.0/24" port port="8080" protocol="tcp" accept' --timeout=90s
success
$ sudo firewall-cmd --list-rich-rules sudo firewall-cmd --permanent --list-rich-rules
rule family="ipv4" source address="192.0.2.0/24" port port="8080" protocol="tcp" accept
$ sudo ip netns exec hard-hfw-cli nc -zv -w 3 192.0.2.1 8080
Ncat: Version 7.92 ( https://nmap.org/ncat ) Ncat: Connected to 192.0.2.1:8080. Ncat: 0 bytes sent, 0 bytes received in 0.01 seconds.
$ sudo nft list chain inet firewalld filter_IN_public_allow
table inet firewalld { chain filter_IN_public_allow { tcp dport 22 accept ip6 daddr fe80::/64 udp dport 546 accept tcp dport 9090 accept ip saddr 192.0.2.0/24 tcp dport 8080 accept } }

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:

deploy@rocky10 · Rocky Linux 10.2
$ sudo firewall-cmd --list-rich-rules sudo ip netns exec hard-hfw-cli nc -zv -w 3 192.0.2.1 8080
Ncat: Version 7.92 ( https://nmap.org/ncat ) Ncat: No route to host.

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):

deploy@rocky10 · Rocky Linux 10.2
$ sudo firewall-cmd --permanent --add-port=8080/tcp sudo firewall-cmd --list-ports sudo firewall-cmd --permanent --list-ports
success 8080/tcp
$ sudo firewall-cmd --reload sudo firewall-cmd --list-ports
success 8080/tcp
$ sudo grep port /etc/firewalld/zones/public.xml
<port port="8080" protocol="tcp"/>
$ sudo firewall-cmd --permanent --remove-port=8080/tcp sudo firewall-cmd --reload sudo firewall-cmd --list-ports
success success

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:

deploy@web01 · Ubuntu 26.04 LTS
$ cat /etc/nftables.conf
#!/usr/sbin/nft -f flush ruleset table inet filter { chain input { type filter hook input priority filter; } chain forward { type filter hook forward priority filter; } chain output { type filter hook output priority filter; } }
$ sudo ip netns exec hard-hfw-web nft -f /etc/nftables.conf sudo ip netns exec hard-hfw-web nft list tables
table inet filter
$ sudo ip netns exec hard-hfw-web ufw status | head -n 1 sudo ip netns exec hard-hfw-out nc -zv -w 3 203.0.113.1 8080
Status: inactive Connection to 203.0.113.1 8080 port [tcp/http-alt] succeeded!
$ sudo ip netns exec hard-hfw-web ufw reload sudo ip netns exec hard-hfw-out nc -zv -w 3 203.0.113.1 8080
Firewall reloaded nc: connect to 203.0.113.1 port 8080 (tcp) timed out: Operation now in progress

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:

deploy@rocky10 · Rocky Linux 10.2
$ systemctl show -p Conflicts firewalld.service
Conflicts=shutdown.target ipset.service iptables.service ebtables.service ip6tables.service

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.

Quick check
01Over SSH you run sudo ufw default deny incoming and sudo ufw enable on a server with no allow rules. Your session keeps working. What happens next?
Incorrect — ufw has no implicit SSH rule; the lab's new connection to port 22 timed out.
Correct — ufw's built-in rules accept packets of existing connections, which is why the session survives; any new login falls to the default deny.
Incorrect — Connection tracking does not re-check established connections against new rules; the lab's session kept receiving data.
Incorrect — ufw only asks when it detects sshd among its ancestors, and --force or a non-interactive shell skips the question; in the lab it enabled at once.
02On a RHEL server you want to test whether opening port 8443 to 10.0.8.0/24 breaks anything, without leaving a rule behind if you lose access. Which command fits?
Correct — A runtime rule with --timeout removes itself; making it permanent is a separate, deliberate step.
Incorrect — A permanent rule survives reloads and reboots; if you lose access, nothing removes it for you.
Incorrect — Panic mode drops all traffic, your own session included; it is an emergency stop, not a test mode.
Incorrect — Editing firewalld's table behind its back works until the next reload, but it has no timer and bypasses firewalld's own configuration.
03A colleague runs sudo systemctl enable --now nftables on an Ubuntu server that already uses ufw, planning to add one custom rule later. Right after, ufw status says inactive and the internal admin port is reachable from outside. Why?
Incorrect — Both write rules into the same netfilter; the kernel has no notion of which tool is allowed.
Incorrect — nftables.service only runs nft; it never touches ufw's configuration files.
Incorrect — ufw creates IPv6 rules as well (Rule added (v6)); the exposure is the same for both families.
Correct — The shipped file flushes every table and leaves an empty table that accepts everything, as the lab showed; ufw then reports itself inactive.

Related