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:
- Zones with a source that matches the packet's source IP.
- Zones bound to the interface the packet arrived on.
- 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
curlfails, 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
publiczone 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 |