kde-redhat.org · all guides

PillarServer administration PublishedSeptember 21, 2026 AuthorElton

firewalld Zones Explained: Real Scenarios on RHEL 9 and 10

How firewalld decides which zone handles a packet, plus the five zone layouts that actually show up on RHEL 9 and RHEL 10 servers. Commands with output.

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.

TargetRHEL 10, AlmaLinux 10, Rocky 10 (firewalld 2.3.x) and RHEL 9, AlmaLinux 9, Rocky 9 (firewalld 1.3.4) Checked2026-09-21 MethodSyntax, flags, zone defaults and version differences cross checked against Red Hat "Configuring firewalls and packet filters" for RHEL 10 and RHEL 9, the upstream firewall-cmd and firewalld.zone man pages, and the firewalld release notes. Package versions read from the 10.0 and 9.6 BaseOS repositories (firewalld-2.3.0-2.el10, firewalld-1.3.4-15.el9_6). CaveatThis revision was verified against documentation and package metadata, not re-run end to end on a fresh VM on the date above. Every place where firewalld 1.3 and 2.3 behave differently is called out inline rather than smoothed over.

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:

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:

  1. If the packet's source address matches a source bound to a zone, that zone handles it.
  2. Otherwise, if the interface the packet arrived on is bound to a zone, that zone handles it.
  3. Otherwise, the default zone handles it (public on 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.