firewalld and nftables: Zones, Rich Rules and Why Your Port Is Still Blocked
The commonest Linux firewall fault is a rule that was added correctly to the wrong zone, or added to the runtime and never made permanent. This guide shows how to confirm what the kernel is actually enforcing rather than what you intended.
Establish which zone is actually in effect
firewalld applies rules per zone, and every interface or source belongs to exactly one. Adding a port to public while the interface sits in trusted — or in a zone NetworkManager chose for you — is the single most common cause of "I opened the port and it is still blocked".
firewall-cmd --state firewall-cmd --get-active-zones firewall-cmd --get-default-zone firewall-cmd --get-zone-of-interface=ens192
# everything currently enforced in that zone firewall-cmd --zone=public --list-all
Note the precedence: a zone bound to a source address wins over a zone bound to an interface. A host can therefore have its traffic evaluated in a zone its interface is not assigned to, which is bewildering until you know to look.
Runtime versus permanent
firewalld maintains two separate configurations. Omitting --permanent changes only the running state, which is lost on reload or reboot. Including it changes only the on-disk state, which has no effect until reloaded.
# runtime only — disappears on reload firewall-cmd --add-port=8443/tcp # permanent only — not yet active firewall-cmd --permanent --add-port=8443/tcp # the correct pattern: both firewall-cmd --permanent --add-port=8443/tcp firewall-cmd --reload
Compare the two directly to find drift — this is worth doing routinely on any host that has been hand-edited during an incident:
diff <(firewall-cmd --list-all --zone=public) \
<(firewall-cmd --permanent --list-all --zone=public)
There is also --runtime-to-permanent, which promotes the whole running configuration. It is convenient after an incident, but it will also persist anything you added as a temporary test, so review first.
Services, ports and the difference that matters
firewalld ships named service definitions — small XML files listing ports and any helper modules. Using the service name is preferable to a bare port because it documents intent and stays correct if the definition changes.
firewall-cmd --get-services | tr ' ' '\n' | grep -i http firewall-cmd --info-service=https firewall-cmd --permanent --add-service=https firewall-cmd --reload
For a non-standard port, define a service rather than scattering port numbers across zones:
firewall-cmd --permanent --new-service=myapp firewall-cmd --permanent --service=myapp --set-description="Internal app API" firewall-cmd --permanent --service=myapp --add-port=8443/tcp firewall-cmd --reload firewall-cmd --permanent --zone=internal --add-service=myapp firewall-cmd --reload
Remember that on SELinux-enforcing systems, opening the firewall port is only half the job — the port must also carry the right SELinux label or the service will fail to bind at all.
Rich rules and their evaluation order
Rich rules express source-specific, logged or rate-limited policy. They are evaluated in a fixed priority order that is not the order you typed them:
- Port forwarding and masquerading
- Logging
- Deny rules
- Allow rules
Because deny is evaluated before allow, a broad deny will silently defeat a narrower allow added afterwards:
firewall-cmd --permanent --zone=public --add-rich-rule=' rule family="ipv4" source address="10.20.0.0/16" service name="ssh" accept' firewall-cmd --permanent --zone=public --add-rich-rule=' rule family="ipv4" source address="10.20.5.7/32" service name="ssh" reject' firewall-cmd --reload firewall-cmd --zone=public --list-rich-rules
Add logging with a rate limit when you need evidence without filling the journal:
firewall-cmd --permanent --zone=public --add-rich-rule=' rule family="ipv4" source address="192.0.2.0/24" log prefix="BLOCKED-TEST " level="info" limit value="5/m" reject'
Read the nftables ruleset underneath
firewalld is a front end. On any current RHEL, Rocky or Ubuntu release it generates an nftables ruleset, and that ruleset is what the kernel enforces. When firewalld's view and observed behaviour disagree, this is the tie-breaker:
nft list ruleset | less nft list table inet firewalld nft -a list chain inet firewalld filter_IN_public # -a shows rule handles
Watch for rules placed by something other than firewalld — Docker, Kubernetes, Podman and some VPN clients all insert their own tables and chains:
nft list tables # table ip nat # table ip filter # table inet firewalld # table ip docker ← inserted by the container runtime
Docker in particular manipulates the NAT path directly and can publish a container port that bypasses the policy you believe is in force. If a port is reachable that firewalld says is closed, check for a published container before anything else.
Counters tell you whether a rule is being hit at all, which separates "wrong rule" from "traffic never arrived":
nft list ruleset -a | grep -A2 'counter' nft reset counters table inet firewalld # zero, reproduce, re-read
Prove it from both ends
Before changing anything, establish whether the service is listening, whether packets arrive, and where they stop.
# is anything bound, and on which address? ss -ltnp | grep 8443 # 0.0.0.0:8443 is all interfaces; 127.0.0.1:8443 will never answer remotely # do packets reach the host at all? tcpdump -ni ens192 'tcp port 8443' -c 20 # from the client nc -vz host.example.com 8443 curl -v --connect-timeout 5 https://host.example.com:8443/
The distinction in the client's response is informative:
| Symptom | Usual meaning |
|---|---|
| Connection refused (fast) | Packet reached the host; nothing listening. Not a firewall issue. |
| Connection timed out | Packet dropped silently — firewall DROP, or routing |
| No route to host | ICMP rejection — firewall REJECT, or genuine routing failure |
| TLS handshake begins then stalls | Not the firewall — MTU, proxy or certificate |
A timeout with packets visible in tcpdump on the server is a local policy drop. A timeout with nothing visible means the traffic never arrived, and the problem is upstream — a security group, a network ACL or a route.
Panic mode, failed reloads and recovery
Two commands are worth knowing before you need them. --panic-on drops all traffic immediately, including your SSH session, and is occasionally the right containment action:
firewall-cmd --panic-on firewall-cmd --query-panic firewall-cmd --panic-off
When testing remote rule changes, protect yourself with a timed rollback so a mistake self-corrects:
( sleep 300; firewall-cmd --reload ) & # make changes to the runtime only; if locked out, the reload restores permanent config
If --reload fails, the permanent configuration contains an error and the runtime is left untouched. Find it before retrying:
firewall-cmd --check-config journalctl -u firewalld -n 50 --no-pager
Checklist
--get-active-zonesbefore anything else; confirm which zone governs the traffic.- Remember source-bound zones outrank interface-bound zones.
- Diff runtime against permanent to catch changes that will vanish or never apply.
- Check rich-rule precedence: deny is evaluated before allow regardless of entry order.
- Confirm the service is bound to the right address with
ss -ltnp. - Use
tcpdumpto establish whether packets arrive at all. - Read
nft list rulesetfor the authoritative view, and look for container-inserted tables. - On SELinux hosts, open the port in policy too, or the bind fails regardless of the firewall.
firewall-cmd --get-active-zones answers more questions than any other command here. Remember that --permanent changes nothing until reload, and that the generated nft list ruleset is the only authoritative view of what the kernel enforces.