Firewalld: Zones, Services & Advanced Rules

Firewalld is the default firewall management tool on RHEL, CentOS, AlmaLinux, Rocky Linux, and Fedora. It uses zones to group network rules and makes it straightforward to manage complex rulesets without hand-writing iptables chains. This guide covers everything from the basics to advanced zone workflows, scripted IP whitelisting, and day-to-day administration.

Getting Started

Install and enable firewalld

On most RHEL-based systems firewalld is already installed. If not:

sudo dnf install firewalld -y
sudo systemctl enable --now firewalld

Verify it is running:

sudo firewall-cmd --state

You should see running.

How firewalld works

Firewalld separates its configuration into two layers:

  • Runtime — rules that are active right now but lost on reload/reboot.
  • Permanent — rules written to disk that survive reloads and reboots.

Almost every command you run should include --permanent, followed by a --reload to apply the changes to the runtime. This two-step pattern is deliberate: it lets you stage changes and apply them atomically.

sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

Zones

Zones are the core concept. Each zone defines a trust level for network connections assigned to it. A connection can be matched to a zone by its source IP/subnet or by its network interface.

List available zones

sudo firewall-cmd --get-zones

Common built-in zones: drop, block, public, external, internal, dmz, work, home, trusted.

Check the default zone

sudo firewall-cmd --get-default-zone

Check the active zones

sudo firewall-cmd --get-active-zones

This shows which zones have interfaces or sources assigned and are therefore processing traffic.

Change the default zone

sudo firewall-cmd --set-default-zone=internal

Built-in zone behaviour

Zone Default policy
drop Drops all incoming. No reply at all (stealth).
block Rejects incoming with an ICMP prohibited message.
public For untrusted networks. Only selected services allowed.
external For NAT/masqueraded external networks.
dmz For DMZ servers. Limited incoming.
work / home / internal Progressively more trusted.
trusted Accepts everything. Use with extreme care.

Creating Custom Zones

This is where firewalld gets powerful. Instead of dumping every rule into public, you create purpose-built zones and bind source IPs or subnets to them. Traffic from those sources gets evaluated by that zone's ruleset instead of the default.

Create a zone

sudo firewall-cmd --permanent --new-zone=cloudflare
sudo firewall-cmd --permanent --new-zone=monitoring
sudo firewall-cmd --permanent --new-zone=office
sudo firewall-cmd --reload

After creating new zones you must reload before you can add rules to them.

Delete a zone

sudo firewall-cmd --permanent --delete-zone=monitoring
sudo firewall-cmd --reload

Zone evaluation order

When a packet arrives, firewalld checks zones in this order:

  1. Zones with a source that matches the packet's source IP.
  2. Zones bound to the interface the packet arrived on.
  3. The default zone (fallback).

This means source-based zones always take priority. That is exactly why creating a cloudflare zone with Cloudflare's IP ranges as sources works so well — traffic from Cloudflare hits the cloudflare zone, everything else falls through to public.

Services

Services are named rule collections that firewalld ships for common protocols. They live in /usr/lib/firewalld/services/ (system defaults) and /etc/firewalld/services/ (custom overrides).

List all available services

sudo firewall-cmd --get-services

Add a service to a zone

sudo firewall-cmd --permanent --zone=public --add-service=http
sudo firewall-cmd --permanent --zone=public --add-service=https
sudo firewall-cmd --reload

Remove a service

sudo firewall-cmd --permanent --zone=public --remove-service=http
sudo firewall-cmd --reload

Check services in a zone

sudo firewall-cmd --zone=public --list-services

Create a custom service

If your application runs on a non-standard port and you want a reusable name:

sudo firewall-cmd --permanent --new-service=myapp
sudo firewall-cmd --permanent --service=myapp --set-description="My custom application"
sudo firewall-cmd --permanent --service=myapp --add-port=8443/tcp
sudo firewall-cmd --reload

Then add it to a zone:

sudo firewall-cmd --permanent --zone=public --add-service=myapp
sudo firewall-cmd --reload

Ports

Sometimes you just need to open a port without creating a whole service definition.

Open a port

sudo firewall-cmd --permanent --zone=public --add-port=8080/tcp
sudo firewall-cmd --permanent --zone=public --add-port=5000-5100/tcp
sudo firewall-cmd --reload

Remove a port

sudo firewall-cmd --permanent --zone=public --remove-port=8080/tcp
sudo firewall-cmd --reload

List open ports

sudo firewall-cmd --zone=public --list-ports

Managing Source IPs and Subnets

Binding source addresses to zones is how you create segmented access policies.

Add a source to a zone

sudo firewall-cmd --permanent --zone=office --add-source=203.0.113.0/24
sudo firewall-cmd --reload

Now any traffic from 203.0.113.0/24 will be evaluated against the office zone rules.

Remove a source

sudo firewall-cmd --permanent --zone=office --remove-source=203.0.113.0/24
sudo firewall-cmd --reload

List sources in a zone

sudo firewall-cmd --zone=office --list-sources

Practical Example: Cloudflare Zone

A very common scenario — you run a web server behind Cloudflare's proxy and want port 443 accessible only from Cloudflare's IP ranges, not from the entire internet. This keeps your origin locked down.

Step 1: Create the zone

sudo firewall-cmd --permanent --new-zone=cloudflare
sudo firewall-cmd --reload

Step 2: Allow HTTPS in the zone

sudo firewall-cmd --permanent --zone=cloudflare --add-port=443/tcp

Step 3: Add all Cloudflare IPv4 ranges

Cloudflare publishes their IP ranges at https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6. Loop through them:

for ip in $(curl -s https://www.cloudflare.com/ips-v4); do
  sudo firewall-cmd --permanent --zone=cloudflare --add-source=$ip
done

Step 4: Add IPv6 ranges too

for ip in $(curl -s https://www.cloudflare.com/ips-v6); do
  sudo firewall-cmd --permanent --zone=cloudflare --add-source=$ip
done

Step 5: Remove port 443 from the public zone

Make sure 443 is not open in your default zone so only Cloudflare can reach it:

sudo firewall-cmd --permanent --zone=public --remove-service=https
sudo firewall-cmd --permanent --zone=public --remove-port=443/tcp

Step 6: Reload

sudo firewall-cmd --reload

Step 7: Verify

sudo firewall-cmd --zone=cloudflare --list-all

You should see port 443/tcp open and all the Cloudflare subnets listed as sources. Any request to 443 from a non-Cloudflare IP will hit your default zone where 443 is closed.

Keeping Cloudflare IPs updated

Cloudflare occasionally adds new ranges. A naive approach would be to remove all sources and re-add them, but that creates a window where Cloudflare traffic is blocked mid-script — anyone hitting your server during the update gets dropped.

The correct approach: fetch the new list first, add all new IPs (firewalld silently skips duplicates), then remove only the stale IPs that are no longer in the current list. The reload happens once at the end, so the permanent config is always complete before it goes live.

#!/bin/bash
ZONE="cloudflare"

# Fetch the current IP lists up front
NEW_V4=$(curl -s https://www.cloudflare.com/ips-v4)
NEW_V6=$(curl -s https://www.cloudflare.com/ips-v6)

if [[ -z "$NEW_V4" ]]; then
  echo "ERROR: Failed to fetch Cloudflare IPv4 list. Aborting." >&2
  exit 1
fi

NEW_IPS=$(echo -e "${NEW_V4}\n${NEW_V6}" | sort)
OLD_IPS=$(sudo firewall-cmd --permanent --zone=$ZONE --list-sources | tr ' ' '\n' | sort)

# Add any new IPs (duplicates are silently ignored by firewalld)
for ip in $NEW_IPS; do
  sudo firewall-cmd --permanent --zone=$ZONE --add-source="$ip" 2>/dev/null
done

# Remove only stale IPs that are no longer in the new list
for ip in $OLD_IPS; do
  if ! echo "$NEW_IPS" | grep -qxF "$ip"; then
    sudo firewall-cmd --permanent --zone=$ZONE --remove-source="$ip"
  fi
done

# Single reload — the permanent config was complete the entire time
sudo firewall-cmd --reload
echo "Cloudflare zone updated: $(date) — $(echo "$NEW_IPS" | wc -l) ranges"

This is safe because:

  • The permanent config always has at least the old IPs until the reload.
  • New IPs are added before stale ones are removed, so coverage only grows.
  • The reload applies the final correct state atomically.
  • If curl fails, the script exits before touching anything.

Save it as /usr/local/bin/update-cloudflare-fw.sh, make it executable, and add a weekly cron entry:

chmod +x /usr/local/bin/update-cloudflare-fw.sh
echo "0 3 * * 0 root /usr/local/bin/update-cloudflare-fw.sh >> /var/log/cloudflare-fw.log 2>&1" > /etc/cron.d/cloudflare-fw

Rich Rules

For anything more specific than "allow this service/port for an entire zone", rich rules give you granular control with conditions.

Allow a specific IP to access SSH

sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="10.0.0.50" service name="ssh" accept'
sudo firewall-cmd --reload

Drop all traffic from a specific IP

sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.1.100" drop'
sudo firewall-cmd --reload

Rate-limit SSH connections

sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule service name="ssh" accept limit value="3/m"'
sudo firewall-cmd --reload

Log and drop traffic on a port

sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" port port="4444" protocol="tcp" log prefix="BLOCKED-4444: " level="warning" drop'
sudo firewall-cmd --reload

Remove a rich rule

Use the exact same rule string but with --remove-rich-rule:

sudo firewall-cmd --permanent --zone=public --remove-rich-rule='rule family="ipv4" source address="192.168.1.100" drop'
sudo firewall-cmd --reload

List rich rules

sudo firewall-cmd --zone=public --list-rich-rules

Scripted IP Management with Loops

Beyond the Cloudflare example, loops are useful any time you have a list of IPs or subnets to manage.

Whitelist a list of office IPs

OFFICE_IPS="203.0.113.10 203.0.113.11 198.51.100.0/24"

for ip in $OFFICE_IPS; do
  sudo firewall-cmd --permanent --zone=office --add-source=$ip
done
sudo firewall-cmd --reload

Read IPs from a file

Create a file /etc/firewalld/ip-lists/blocked.txt with one IP per line:

mkdir -p /etc/firewalld/ip-lists

while IFS= read -r ip; do
  [[ -z "$ip" || "$ip" == \#* ]] && continue
  sudo firewall-cmd --permanent --zone=drop --add-source="$ip"
done < /etc/firewalld/ip-lists/blocked.txt

sudo firewall-cmd --reload

Bulk remove sources from a zone

for ip in $(sudo firewall-cmd --permanent --zone=cloudflare --list-sources); do
  sudo firewall-cmd --permanent --zone=cloudflare --remove-source=$ip
done
sudo firewall-cmd --reload

Port Forwarding

Redirect traffic from one port to another, either locally or to a different host.

Forward port 80 to 8080 locally

sudo firewall-cmd --permanent --zone=public --add-forward-port=port=80:proto=tcp:toport=8080
sudo firewall-cmd --reload

Forward port 80 to another server

sudo firewall-cmd --permanent --zone=public --add-forward-port=port=80:proto=tcp:toport=80:toaddr=192.168.1.50
sudo firewall-cmd --permanent --zone=public --add-masquerade
sudo firewall-cmd --reload

Masquerading (NAT)

Enable masquerading on a zone to allow internal hosts to reach the internet through your server:

sudo firewall-cmd --permanent --zone=external --add-masquerade
sudo firewall-cmd --reload

Check if masquerading is active:

sudo firewall-cmd --zone=external --query-masquerade

Listing and Inspecting Rules

List everything in a zone

sudo firewall-cmd --zone=public --list-all

List everything in all zones

sudo firewall-cmd --list-all-zones

List only the permanent config

sudo firewall-cmd --permanent --zone=public --list-all

Show runtime vs permanent differences

Compare the outputs of:

sudo firewall-cmd --zone=public --list-all
sudo firewall-cmd --permanent --zone=public --list-all

Any differences mean you have runtime-only rules that will be lost on reload.

Reloading and Resetting

Reload firewalld

Applies all permanent rules to the runtime without dropping existing connections:

sudo firewall-cmd --reload

Complete reload

Drops all connections and rebuilds from permanent config (use with caution):

sudo firewall-cmd --complete-reload

Reset to defaults

If things go sideways, you can reset a zone to its defaults:

sudo firewall-cmd --permanent --load-zone-defaults=public
sudo firewall-cmd --reload

ICMP Filtering

Control which ICMP types are allowed or blocked:

Block ping requests

sudo firewall-cmd --permanent --zone=public --add-icmp-block=echo-request
sudo firewall-cmd --reload

List ICMP blocks

sudo firewall-cmd --zone=public --list-icmp-blocks

List available ICMP types

sudo firewall-cmd --get-icmptypes

Putting It All Together — Multi-Zone Server Setup

Here is a real-world example for a web server behind Cloudflare with monitoring and admin access from specific networks:

# Create custom zones
sudo firewall-cmd --permanent --new-zone=cloudflare
sudo firewall-cmd --permanent --new-zone=monitoring
sudo firewall-cmd --permanent --new-zone=admin
sudo firewall-cmd --reload

# --- Cloudflare zone: only HTTPS from CF IPs ---
sudo firewall-cmd --permanent --zone=cloudflare --add-port=443/tcp
for ip in $(curl -s https://www.cloudflare.com/ips-v4); do
  sudo firewall-cmd --permanent --zone=cloudflare --add-source=$ip
done
for ip in $(curl -s https://www.cloudflare.com/ips-v6); do
  sudo firewall-cmd --permanent --zone=cloudflare --add-source=$ip
done

# --- Monitoring zone: allow Prometheus / Node Exporter ---
sudo firewall-cmd --permanent --zone=monitoring --add-port=9090/tcp
sudo firewall-cmd --permanent --zone=monitoring --add-port=9100/tcp
sudo firewall-cmd --permanent --zone=monitoring --add-source=10.0.0.0/24

# --- Admin zone: SSH only from office ---
sudo firewall-cmd --permanent --zone=admin --add-service=ssh
sudo firewall-cmd --permanent --zone=admin --add-source=203.0.113.0/24

# --- Lock down the default public zone ---
sudo firewall-cmd --permanent --zone=public --remove-service=ssh
sudo firewall-cmd --permanent --zone=public --remove-service=https
sudo firewall-cmd --permanent --zone=public --remove-service=http

# Apply everything
sudo firewall-cmd --reload

# Verify
sudo firewall-cmd --list-all-zones

With this setup:

  • Cloudflare IPs can reach port 443 (and nothing else).
  • Monitoring subnet (10.0.0.0/24) can reach Prometheus and Node Exporter.
  • Office network (203.0.113.0/24) can SSH in.
  • Everyone else hits the public zone where nothing is open.

That is a properly segmented firewall that is easy to read, easy to maintain, and easy to extend.

Quick Reference

Task Command
Check state sudo firewall-cmd --state
List zones sudo firewall-cmd --get-zones
Active zones sudo firewall-cmd --get-active-zones
Default zone sudo firewall-cmd --get-default-zone
List all for zone sudo firewall-cmd --zone=public --list-all
Add service sudo firewall-cmd --permanent --zone=public --add-service=https
Remove service sudo firewall-cmd --permanent --zone=public --remove-service=http
Add port sudo firewall-cmd --permanent --zone=public --add-port=8080/tcp
Add source sudo firewall-cmd --permanent --zone=office --add-source=10.0.0.0/24
New zone sudo firewall-cmd --permanent --new-zone=myzone
Reload sudo firewall-cmd --reload
List rich rules sudo firewall-cmd --zone=public --list-rich-rules