RCWRCW IT TrainingFree hands-on labs & simulators← Back to home
Networking · Detailed troubleshooting guide

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.

Published October 6, 2026 · By , Enterprise Infrastructure Architect

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:

  1. Port forwarding and masquerading
  2. Logging
  3. Deny rules
  4. 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:

SymptomUsual meaning
Connection refused (fast)Packet reached the host; nothing listening. Not a firewall issue.
Connection timed outPacket dropped silently — firewall DROP, or routing
No route to hostICMP rejection — firewall REJECT, or genuine routing failure
TLS handshake begins then stallsNot 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

  1. --get-active-zones before anything else; confirm which zone governs the traffic.
  2. Remember source-bound zones outrank interface-bound zones.
  3. Diff runtime against permanent to catch changes that will vanish or never apply.
  4. Check rich-rule precedence: deny is evaluated before allow regardless of entry order.
  5. Confirm the service is bound to the right address with ss -ltnp.
  6. Use tcpdump to establish whether packets arrive at all.
  7. Read nft list ruleset for the authoritative view, and look for container-inserted tables.
  8. On SELinux hosts, open the port in policy too, or the bind fails regardless of the firewall.
Key takeaway: Find the zone before writing a rule — 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.