firewalld vs ufw: How Firewall Rules Actually Chain (and Where They Break)

root@rhel:~# firewall-cmd –list-all –zone=public
root@rhel:~# firewall-cmd –list-all –zone=public # what is actually open, in the zone that actually applies

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.

# two models, not two flavours
firewalld (RHEL, AlmaLinux, Fedora) thinks in zones. A packet belongs to a zone, and the zone decides what happens to it. An interface is assigned to a zone; a source address can be assigned to a zone too, and a source assignment beats an interface assignment. If neither matches, the packet falls to the default zone — 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.
# the port table — who should expose what, and to whom
Before any command: a port is not “open” or “closed” in the abstract. It is open to someone. The same port 22 that is correct to expose to your office is wrong to expose to the whole internet without a rate limit, and port 3306 that is correct between an app server and its database is never correct on a public interface. The table is the policy; the commands below are just how each tool expresses it.
22/tcp — SSH
Expose, but narrowly: your office / VPN / jump host only, and rate-limited. The most probed port on the internet — the threat map’s SSH guessing numbers are the proof. Never “allow 22 from anywhere” on a box that holds anything.
25/tcp — SMTP
Only on a mail server, inbound only, and only if the box is actually meant to receive mail. Most VPS providers block outbound 25 by default, and a web server that “also sends mail” should be submitting on 587, not listening on 25.
53/udp+tcp — DNS
Only on a DNS server, and both protocols — UDP for queries, TCP for large responses and zone transfers. An open resolver on a box that is not a DNS server is a DDoS amplifier waiting to be found.
80/tcp, 443/tcp — HTTP, HTTPS
The genuinely public ones, on a web server. 80 exists to redirect to 443, not to serve content. If the box is not a web server, neither should be open.
123/udp — NTP
Outbound only, for almost everyone — the box asks a time server, it does not answer time queries. Inbound 123 belongs only on a box that is deliberately an NTP server, and note it is UDP, not TCP: opening 123/tcp does nothing and is the classic copy-paste error.
3306/tcp — MySQL / MariaDB
Never on a public interface. The database listens on loopback, or the firewall allows it only from the app server’s address. A 3306 that answers the internet is a breach report waiting to happen.
587/tcp, 465/tcp — mail submission
587 (STARTTLS) and 465 (implicit TLS) are how clients submit mail. Inbound on a mail server only; on anything else they are just more open ports.
993/tcp, 995/tcp — IMAPS, POP3S
Inbound on a mail server only, and only the TLS versions — the plaintext 143/110 should not be exposed at all in 2026.
389/tcp, 636/tcp — LDAP, LDAPS
Internal only, and 636 (TLS) in preference to 389. Same rule as the database: allow it from the hosts that authenticate against it, nobody else.
21/tcp, 445/tcp — FTP, SMB
Do not expose either to the internet. FTP is plaintext and SMB is a standing invitation to the internet’s scanning population. Both belong behind a VPN, or not at all.

# port 22, done properly — the same intent, two syntaxes
The intent: SSH reachable from the office network only, and rate-limited so a password-guessing bot burns out instead of hammering forever.
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 --reload
ufw:
ufw allow from 203.0.113.0/24 to any port 22 proto tcp
ufw limit 22/tcp
The 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.
A rule that is correct in isolation and wrong in context is the most common firewall bug there is. The context is the zone on firewalld and the order on ufw — and both are visible in one command each.
# port 25 and 587 — inbound mail is not outbound mail
The intent: a mail server receives mail on 25 and accepts submissions on 587; a web server sends mail and receives none.
On the mail server, firewalld:
firewall-cmd --permanent --zone=public --add-service=smtp --add-service=submission
firewall-cmd --reload
On the mail server, ufw:
ufw allow 25/tcp
ufw allow 587/tcp
On 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.
# port 123 and 53 — UDP, and the protocol you forgot
The intent: the box keeps time with NTP, and a DNS server answers queries. Both are UDP-first, and that is where the errors live.
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.
# port 3306 and the internal ports — the rule is “nobody”, with exceptions
The intent: the database, LDAP, and Redis answer only the hosts that are supposed to talk to them — and nobody else, including the internet at large.
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-net
firewall-cmd --permanent --zone=trusted-net --add-source=10.0.0.0/24
firewall-cmd --permanent --zone=trusted-net --add-service=mysql
firewall-cmd --reload
Now 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 tcp
ufw allow from 10.0.0.0/24 to any port 636 proto tcp
And 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.
For internal ports, the default posture is deny — and the exception list is a list of addresses, not a list of ports. If your rule mentions a port but no source, you have not restricted anything yet.
# the two mistakes that eat an afternoon — one per tool, both silent
firewalld: runtime vs permanent. 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.
# debugging: which rule matched — stop guessing, start reading
firewalld: 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.
# the cheat sheet — keep this, not the man page
firewalld: what is open, where
firewall-cmd --list-all --zone=public (add --permanent to see what survives a reboot)
firewalld: make it stick
--permanent + --reload, every time
firewalld: narrow a service
rich rule with source address=, and remove the broad service from the zone
ufw: what is open, in what order
ufw status numbered — first match wins
ufw: enable safely
allow SSH first, then ufw enable, verify from a second session
ufw: rate-limit SSH
ufw limit 22/tcp, with no plain allow 22 above it
both: internal ports
allow from a source address only — never a bare port
both: UDP services (123, 53)
open the protocol the service actually uses, not just the port number
both: verify from outside
ss -tlnp shows listening, not allowed — test from another host

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

Scroll to Top