TL;DR
A firewalld zone is a named rule set with three things bound to it: source addresses, interfaces, and a trust target. Every incoming packet is handled by exactly one zone, chosen in this order: a zone whose source matches wins, then the zone bound to the incoming interface, then the default zone. There is no fallthrough to a second zone.
You do not need nine zones. Most servers need one. Some need two. Start by reading what you already have:
firewall-cmd --state
running
firewall-cmd --get-default-zone
public
firewall-cmd --get-active-zones
public
interfaces: ens3
firewall-cmd --list-all
public (active)
target: default
ingress-priority: 0
egress-priority: 0
icmp-block-inversion: no
interfaces: ens3
sources:
services: cockpit dhcpv6-client ssh
ports:
protocols:
forward: yes
masquerade: no
forward-ports:
source-ports:
icmp-blocks:
rich rules:
The two priority lines only appear on firewalld 2.x (RHEL 10). On RHEL 9 the same command prints the same block without them. Everything below is run as root: add sudo if you are not.
A zone is a trust level, not a firewall
The confusion with firewalld zones comes from reading the list of nine shipped zones as a taxonomy you have to obey. It is not. home, work, dmz and friends are presets that Red Hat ships with sensible service lists, nothing more. A zone is just four things glued together:
- Bindings: which sources (CIDRs, MAC addresses, ipsets) and which interfaces belong to it.
- Allowed traffic: services, ports, protocols, rich rules.
- A target:
default(reject what is not allowed, with ICMP),ACCEPT,REJECTorDROP. - A forward flag: whether packets can be forwarded between interfaces inside the same zone.
One thing that trips up people carrying habits from firewalld 0.x: since firewalld 1.0, a zone applies almost exclusively to traffic destined for the host running firewalld. Filtering traffic that merely passes through the box is a job for a policy, not a zone. That change also killed "zone drifting", the old behavior where a packet could fall from a source zone into an interface zone. Today it cannot.
Everything you set lands in one of two places. The shipped definitions live in /usr/lib/firewalld/zones/ and are read-only. The moment you change a zone permanently, firewalld writes your version to /etc/firewalld/zones/ and that copy wins:
ls /etc/firewalld/zones/
mgmt.xml public.xml
How firewalld picks the zone for a packet
This is the whole model, and it is the part the listicles skip. For an incoming packet, firewalld classifies in this order:
- If the packet's source address matches a source bound to a zone, that zone handles it.
- Otherwise, if the interface the packet arrived on is bound to a zone, that zone handles it.
- Otherwise, the default zone handles it (
publicon a fresh install).
Exactly one zone handles the packet. If the chosen zone does not allow the service, the packet is rejected, even if some other zone would have allowed it. This is the single most common source of "but I opened the port" tickets.
Two commands tell you what firewalld thinks, which is often not what you think:
firewall-cmd --get-zone-of-interface=ens3
public
firewall-cmd --get-zone-of-source=203.0.113.0/24
mgmt
If two zones have overlapping source ranges, Red Hat's documentation is blunt about the result: "In case you add multiple zones with an overlapping network range, they are ordered alphanumerically by zone name and only the first one is considered." Do not build a design that depends on that. On RHEL 10 you can take control of the order explicitly, which I cover at the end.
Before you touch a production box
Zone work is the kind of change that logs you out of the machine you are changing. I keep two throwaway cloud instances for this, because you cannot prove that a source based zone rejects everyone else without a second host outside the allowed range. I use Vultr for that, two of the smallest instances in different regions, destroyed when I am done. Any provider with a serial console works.
Disclosure: that Vultr link is a referral. You get their signup trial credit, and if you become a paying customer I get a commission. Your price is the same either way.
Scenario 1: one NIC facing the internet
The most common case, and the one where people overthink zones. A cloud VPS with a single interface. Leave everything in public, then make public honest. On a fresh RHEL 9 or 10 install it ships with cockpit and dhcpv6-client open, which is more than most servers need.
firewall-cmd --permanent --zone=public --remove-service=cockpit
success
firewall-cmd --permanent --zone=public --add-service=http
success
firewall-cmd --permanent --zone=public --add-service=https
success
firewall-cmd --reload
success
firewall-cmd --zone=public --list-services
dhcpv6-client http https ssh
Keep cockpit if you actually use the RHEL web console. Keep dhcpv6-client if the host gets its IPv6 address over DHCPv6, which many providers do: removing it there costs you address renewal, not security. If you already removed a service, firewalld tells you instead of failing silently:
firewall-cmd --permanent --zone=public --remove-service=cockpit
Warning: NOT_ENABLED: cockpit
success
For a port that is not a named service, add the port, not a rich rule:
firewall-cmd --permanent --zone=public --add-port=8443/tcp
success
firewall-cmd --reload
success
firewall-cmd --zone=public --query-port=8443/tcp
yes
If you are setting the box up from scratch, the firewalld section of my initial server setup on AlmaLinux 10 and Rocky Linux 10 guide covers this in the right order, along with the user and SSH work that has to happen first.
Scenario 2: SSH only from the admin subnet, HTTP for the world
Here is where a second zone earns its place. You want the web open to everyone and SSH open only to your office range or your VPN. Two ways to do it, and the difference matters.
The source zone way. Create a zone with a name that means something, give it only SSH, bind your admin range to it:
firewall-cmd --permanent --new-zone=mgmt
success
firewall-cmd --permanent --zone=mgmt --add-service=ssh
success
firewall-cmd --permanent --zone=mgmt --add-source=203.0.113.0/24
success
firewall-cmd --reload
success
firewall-cmd --get-active-zones
mgmt
sources: 203.0.113.0/24
public
interfaces: ens3
--new-zone only exists as a permanent operation, so the --reload is not optional here. The file firewalld wrote is small enough to read, and reading it is the fastest way to confirm what you built:
cat /etc/firewalld/zones/mgmt.xml
<?xml version="1.0" encoding="utf-8"?>
<zone>
<source address="203.0.113.0/24"/>
<service name="ssh"/>
</zone>
At this point SSH is allowed twice: from 203.0.113.0/24 through mgmt, and from everywhere else through public. Closing the second door is the step that locks people out, so do it with a dead man switch. Schedule a reload five minutes out, make the change in runtime only, and if you get it wrong the scheduled reload restores the permanent config and gives you your session back:
systemd-run --on-active=5m /usr/bin/firewall-cmd --reload
Running timer as unit: run-r4f1a0c9b2d7e4a51c8e6f0b3d9a2c7e4.timer
Will run service as unit: run-r4f1a0c9b2d7e4a51c8e6f0b3d9a2c7e4.service
firewall-cmd --zone=public --remove-service=ssh
success
Now open a second SSH session from inside the admin range and a connection attempt from outside it. When you are satisfied, commit and cancel the safety net:
firewall-cmd --runtime-to-permanent
success
systemctl stop run-r4f1a0c9b2d7e4a51c8e6f0b3d9a2c7e4.timer
(no output on success)
The rich rule way. One zone, one exception:
firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.0/24" service name="ssh" accept'
success
Rich rules are fine for one or two exceptions. A source bound zone is better when the list grows, because it shows up in --get-active-zones, it is one file someone else can read in six months, and it is not family specific. That last point is a real trap: the rich rule above says family="ipv4", so if sshd is also listening on IPv6 and you removed the ssh service from public, your IPv6 admins are locked out and your IPv4 rule says nothing about it. Write the IPv6 twin, or use a zone.
While you are here: if you are also moving SSH off port 22, the firewall is only half the job. SELinux owns the port label and will refuse to let sshd bind. The fix is semanage port, and I walked through how to read the denial that tells you so in fixing AVC denials without disabling SELinux. Turning SELinux off to "solve" a port label is how servers end up unauditable.
Scenario 3: a second NIC on a backend network
Two interfaces, one public, one on a private network where the database lives. This is the textbook zone case, and the textbook gets one detail wrong: on RHEL 9 and 10 you do not bind the interface with firewall-cmd, you set it on the NetworkManager profile. NetworkManager owns the device and pushes connection.zone to firewalld every time the profile activates, so a binding written only into the zone file can be undone on the next reboot.
nmcli -f NAME,DEVICE connection show
NAME DEVICE
ens3 ens3
ens224 ens224
nmcli connection modify ens224 connection.zone internal
(no output on success)
nmcli connection up ens224
Connection successfully activated (D-Bus active path: /org/freedesktop/NetworkManager/ActiveConnection/4)
firewall-cmd --get-zone-of-interface=ens224
internal
Then look at what internal actually opens before you trust the name:
firewall-cmd --zone=internal --list-services
cockpit dhcpv6-client mdns samba-client ssh
That is mDNS and a Samba client service on your database network because the preset was written for a trusted office LAN. Trim it, then add what the backend actually serves:
firewall-cmd --permanent --zone=internal --remove-service=mdns
success
firewall-cmd --permanent --zone=internal --remove-service=samba-client
success
firewall-cmd --permanent --zone=internal --add-service=postgresql
success
firewall-cmd --reload
success
Resist the urge to reach for trusted here. trusted has target: ACCEPT, which means every port on that interface is open, including the ones a package opens next month without telling you. A zone with target: default and two services is the same amount of typing and a much smaller blast radius.
Scenario 4: the box routes traffic, so zones stop being enough
A gateway or VPN concentrator forwards packets rather than receiving them. Since firewalld 1.0, zone rules apply to traffic aimed at the host, so adding a service to a zone does nothing for traffic passing through. Forwarded traffic is filtered by policy objects, and interzone traffic is denied by default until a policy says otherwise.
Masquerading still belongs to the egress zone, and external ships with it on:
firewall-cmd --permanent --zone=external --query-masquerade
yes
The policy is what actually lets the internal network out:
firewall-cmd --permanent --new-policy=int-to-ext
success
firewall-cmd --permanent --policy=int-to-ext --add-ingress-zone=internal
success
firewall-cmd --permanent --policy=int-to-ext --add-egress-zone=external
success
firewall-cmd --permanent --policy=int-to-ext --set-target=ACCEPT
success
firewall-cmd --reload
success
firewall-cmd --get-policies
allow-host-ipv6 int-to-ext
The two special zone names in policies are worth memorizing: HOST means the machine itself, ANY means every zone. An ingress zone of HOST with egress ANY is how you filter the server's own outbound traffic, which zones never did.
Check kernel forwarding separately rather than assuming firewalld handled it:
sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 1
If it reads 0, set it in a file under /etc/sysctl.d/ so it survives a reboot instead of typing it after every incident.
Scenario 5: Docker publishes a port and your zone does not apply
You set public to allow only SSH and HTTPS, you run docker run -p 5432:5432, and Postgres answers from the internet. Nothing is broken in firewalld. Docker programs its own rules and the port publishing DNAT happens before your zone's filtering, so the packet never gets classified the way you expect.
The fix that costs nothing and survives upgrades is to stop publishing on all addresses:
docker run -d --name pg -e POSTGRES_PASSWORD=redacted -p 127.0.0.1:5432:5432 postgres:17
7f3c0b2a9d41e6c85b0d2f1a4e9c7d3b6a5e8f0c1d2b3a4956e7f8091a2b3c4d
ss -tlnp | grep 5432
LISTEN 0 4096 127.0.0.1:5432 0.0.0.0:* users:(("docker-proxy",pid=1482,fd=7))
Bind published ports to the loopback or to the backend interface address, then put a reverse proxy in front of the services that really need to be public. Upstream firewalld also documents a strict path, setting "iptables": false in /etc/docker/daemon.json and recreating container networking with a docker zone plus policies, but it is a real commitment and it changes how container networking behaves. Decide that before you install, not after. My Docker on AlmaLinux 10 and Rocky Linux 10 guide sets the daemon up with this problem already in mind.
Zone priorities: the RHEL 10 escape hatch
Sometimes you genuinely need an interface bound zone to be considered before a source bound zone, which the default classification order will not do. firewalld 2.0 added zone priorities for exactly this, and a lower value wins. Check which side of that line you are on before designing around it:
firewall-cmd --version
2.3.0
firewall-cmd --permanent --zone=mgmt --set-priority=-10
success
firewall-cmd --reload
success
firewall-cmd --permanent --zone=mgmt --get-priority
-10
RHEL 10.0 ships firewalld 2.3.0, so priorities are there. RHEL 9.6 ships 1.3.4, which predates the feature, so on the 9 family the flag does not exist and firewall-cmd rejects it. On 2.x, ingress and egress can also be set independently with --set-ingress-priority and --set-egress-priority. Most people will never need any of this. If you reach for it in week one, the zone layout is probably wrong.
Save it, reload it, do not lock yourself out
firewalld keeps two configurations, and half of all firewalld confusion lives in that gap. Commands without --permanent change the running firewall and are lost on reload or reboot. Commands with --permanent change the files and do nothing until a reload. The useful workflow is the one the man page recommends: change runtime, test, then promote.
firewall-cmd --zone=public --add-service=https --timeout=5m
success
--timeout removes the rule automatically when it expires, and it cannot be combined with --permanent. It is the cheapest safety net in the tool. When the change is proven:
firewall-cmd --runtime-to-permanent
success
--reload applies the permanent configuration and keeps connection state, so established sessions survive. It also discards runtime only changes, which is the property the dead man switch in scenario 2 relies on. --complete-reload reloads kernel modules too and will break established connections: keep it for the day the firewall is genuinely wedged.
If this fails
Your rule disappeared after a reboot or a reload. It was a runtime change. Confirm by comparing the two views:
firewall-cmd --zone=public --list-services
dhcpv6-client http https ssh
firewall-cmd --permanent --zone=public --list-services
dhcpv6-client ssh
If the runtime side is the one you want, promote it with firewall-cmd --runtime-to-permanent.
SSH broke after you bound a source zone. Usually the source you allowed is not the address the server sees, because of NAT, a VPN or a jump host. Ask the server, from a session you still have:
ss -tn 'sport = :22'
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 0 10.0.0.5:22 203.0.113.44:51022
Use that peer address to fix the zone. If you are already locked out, get in through the provider's serial or VNC console and run firewall-cmd --reload: if the bad change was runtime only, that alone restores access. Do not "fix" it with systemctl stop firewalld. A box that boots with no firewall because someone was in a hurry is a worse outcome than the lockout, and it never gets turned back on.
The interface went back to public after a reboot. The NetworkManager profile has no zone set, so firewalld put the device in the default zone. Check and fix on the profile, not in the zone file:
nmcli -f connection.zone connection show ens224
connection.zone: --
nmcli connection modify ens224 connection.zone internal
(no output on success)
The port is open in the zone and the service still refuses connections. The firewall is not your problem, the port label is. Look at the denial rather than guessing:
ausearch -m AVC -ts recent
type=AVC msg=audit(1789936000.123:456): avc: denied { name_bind } for pid=1520 comm="httpd" src=8443 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=0
semanage port -a -t http_port_t -p tcp 8443
(no output on success)
That is the correct fix for a name_bind denial, and it takes one line. setenforce 0 is not a fix, it is a decision to stop being told when something is wrong.
What I actually deploy
On a single homed server: public, trimmed, nothing else. On anything with a management network or an admin range: public plus one purpose named source zone, with the lockout safety net every single time, because the day you skip it is the day the CIDR has a typo. Policies only when the box forwards traffic. Priorities almost never.
The nine shipped zones are a menu, not an instruction. Pick one, understand why the packet lands there, and you have understood firewalld.
Primary sources: Red Hat, Configuring firewalls and packet filters (RHEL 10); firewalld upstream, 1.0.0 release notes (intra-zone forwarding and the end of zone drifting), Zone Priorities, Strict filtering of Docker containers, and the firewall-cmd man page.