firewalld vs ufw: How Firewall Rules Actually Chain
(and Where They Break)
Most firewall guides stop at “allow port 22 and you’re done.” The part admins actually get wrong is what happens after that: which rule wins, in which zone, and why a rule that looks right does nothing. firewalld and ufw are not two flavours of the same tool. They are two different models of the same problem — and the chaining mistakes in each are different, specific, and worth knowing by name.
public on a stock RHEL install. Inside a zone, the decision is: rich rules, then services, then ports, with the zone’s target deciding what happens to everything that matched nothing. That is the whole chain. When a rule “does nothing”, the usual answer is that it is in the wrong zone — the packet never reached it.ufw (Ubuntu, Debian) thinks in a numbered list. Rules are evaluated in order, first match wins, and the list is what you see in
ufw status numbered. There are no zones. There is a default policy — deny incoming, allow outgoing on a sane setup — and then rules, in order, overriding it. When a rule “does nothing” on ufw, the usual answer is that another rule above it matched first, and evaluation never reached it.Underneath, both are front ends. firewalld drives nftables on current RHEL; ufw drives iptables or nftables depending on the Ubuntu release. You can drop below either of them into raw nftables or iptables rules — and both tools will happily fight you for control of the same tables if you do. Pick one layer and stay in it. The firewall you debug at 2 AM should be the firewall you configured, not a mixture of two.
firewalld:
firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.0/24" service name="ssh" accept'firewall-cmd --permanent --zone=public --add-rich-rule='rule service name="ssh" limit value="5/m" accept'firewall-cmd --reloadufw:
ufw allow from 203.0.113.0/24 to any port 22 proto tcpufw limit 22/tcpThe two classic mistakes, one per tool. On firewalld, the rich rule above opens SSH only from the office range — if the box also has the stock
ssh service enabled in the same zone, SSH is open to everyone anyway, and the rich rule was theatre. Check with firewall-cmd --list-all --zone=public and remove the service if it is there: firewall-cmd --permanent --zone=public --remove-service=ssh. On ufw, ufw limit 22/tcp inserts a rate-limit rule — but if a plain ufw allow 22/tcp already sits above it in the numbered list, the allow matches first and the limit never fires. Check the order with ufw status numbered, delete the plain allow, and re-add the limit. First match wins is not a detail; it is the whole mechanism.On the mail server, firewalld:
firewall-cmd --permanent --zone=public --add-service=smtp --add-service=submissionfirewall-cmd --reloadOn the mail server, ufw:
ufw allow 25/tcpufw allow 587/tcpOn the web server, neither. The web server needs outbound 25 or 587 to reach a mail relay, and both tools allow outbound traffic by default — there is nothing to open inbound, and opening 25 inbound on a web server is how a box becomes an open relay test target. The mistake here is symmetry: admins who open a port inbound because the box uses it outbound. Direction matters, and the firewall’s default policies already encode it — deny incoming, allow outgoing.
The other 25 gotcha is upstream of the firewall entirely: many VPS providers block outbound 25 at their border, so a mail setup that works in the firewall and fails in practice is usually the provider’s block, not your rule. Test with the relay, not with the firewall listing.
NTP, firewalld and ufw: nothing to open inbound on an ordinary box — chrony asks outbound, and outbound is allowed by default. If the box is an NTP server, firewalld has a service for it (
--add-service=ntp, UDP 123); on ufw it is ufw allow 123/udp. The error is opening 123/tcp: NTP does not use TCP, the rule matches nothing, and the admin concludes the firewall is broken when the firewall was never asked the right question.DNS, firewalld and ufw:
firewall-cmd --permanent --zone=public --add-service=dns — the service definition covers both UDP and TCP 53.ufw allow 53 — ufw’s bare allow 53 opens both protocols too.The error is the opposite one: writing
ufw allow 53/udp and stopping there, then wondering why large DNS responses and zone transfers fail — those need TCP 53. When a service uses both protocols, open both, and say so in the rule. The port number alone is not the rule; the port and the protocol are.firewalld: put the database in a separate zone, or use a rich rule with a source address — the same pattern as SSH above. The clean version is a dedicated zone for the trusted network:
firewall-cmd --permanent --new-zone=trusted-netfirewall-cmd --permanent --zone=trusted-net --add-source=10.0.0.0/24firewall-cmd --permanent --zone=trusted-net --add-service=mysqlfirewall-cmd --reloadNow 3306 is reachable from the trusted network and from nowhere else, because the public zone never got the service at all. A source-based zone beats an interface zone, so this holds even on a multi-homed box.
ufw:
ufw allow from 10.0.0.0/24 to any port 3306 proto tcpufw allow from 10.0.0.0/24 to any port 636 proto tcpAnd crucially, no bare
ufw allow 3306/tcp anywhere — check with ufw status numbered and delete it if an earlier, looser rule is sitting above the narrow one. On ufw, the narrow rule only protects you if the broad rule is not above it.The belt to the braces: bind the database itself to loopback or the private interface (
bind-address in MariaDB/MySQL), so the firewall is the second line of defence, not the only one. A service that does not listen on a public interface cannot be reached through it, whatever the firewall says.firewall-cmd --add-service=http without --permanent changes the running firewall only — it works, you test it, it is perfect, and it vanishes on the next reload or reboot. --permanent without --reload writes the config and changes nothing running — it looks done, and the port is still closed. The correct sequence is both: --permanent and --reload, every time, or use --runtime-to-permanent after testing a runtime change you want to keep. Verify with firewall-cmd --list-all --zone=public (runtime) and firewall-cmd --list-all --zone=public --permanent (saved) — if they disagree, you have a change that will not survive a reboot, in one direction or the other.ufw: the firewall that was never enabled.
ufw allow 22/tcp on a fresh Ubuntu box does nothing until ufw enable — and the first enable warns you, correctly, that it may disrupt your SSH session. The safe order is: allow SSH first, then enable, then verify from a second session before closing the first. The matching firewalld foot-gun is firewall-cmd --reload from an SSH session after removing the ssh service from the wrong zone — test zone changes from a console or a second session, the same way.firewall-cmd --list-all --zone=public shows the zone that applies, its services, ports, rich rules, and sources — one screen, the whole story for that zone. If the packet’s source matches a different zone, list that zone instead: firewall-cmd --get-active-zones shows which zones are active and what is bound to each. For packets that are being dropped, a rich rule with log prefix="FW-DROP: " level="info" and limit value="5/m" puts the drops in the journal, where journalctl can find them — the journalctl guide’s filtering applies here directly.ufw:
ufw status verbose shows the default policies and every rule; ufw status numbered shows the order they are evaluated in, which is the only thing that determines which one wins. Enable logging with ufw logging on and the denied packets land in the kernel log — journalctl -k --grep UFW on a systemd box — with the source, destination, port, and protocol of each blocked packet. A blocked packet you can see is a rule you can fix; a blocked packet you cannot see is an afternoon of guessing.The cross-check that catches both tools: test from outside the box, not from it.
ss -tlnp on the box tells you what is listening; it tells you nothing about what the firewall lets through. A port scan from another host — or a plain connection attempt from the network the rule is supposed to serve — is the only test that exercises the chain end to end.firewall-cmd --list-all --zone=public (add --permanent to see what survives a reboot)--permanent + --reload, every timesource address=, and remove the broad service from the zoneufw status numbered — first match winsufw enable, verify from a second sessionufw limit 22/tcp, with no plain allow 22 above itss -tlnp shows listening, not allowed — test from another hostCommands verified against firewalld and ufw documentation and RHEL / Ubuntu behaviour, November 7, 2026. Service names (ssh, smtp, dns, ntp, mysql) are the stock firewalld service definitions; on a minimal install, confirm the service exists with firewall-cmd --get-services before adding it. Example addresses (203.0.113.0/24, 10.0.0.0/24) are documentation ranges — substitute your own networks.
