Plain firewalld commands open a service to everyone in a zone. Rich rules add conditions: a source address, a rate limit, logging, or an explicit reject. They are the right tool when you want MySQL reachable only from your app server, or SSH reachable only from your office, without creating a separate zone for each case.
Before you start
- A Linux VPS with firewalld running, usually AlmaLinux or Rocky Linux 8 or 9. See Set up firewalld on AlmaLinux and Rocky Linux if it is not set up.
- Root or a sudo user.
- The IP addresses you want to allow. Examples here use
203.0.113.10(your office) and198.51.100.20(an app server). - Access to the browser console in your VPS provider's control panel, in case a rule blocks SSH.
Warning: If you plan to restrict SSH with a rich rule, add and test the allow rule for your IP before you remove the general ssh service from the zone. Otherwise you can lock yourself out. The browser console in your VPS provider's control panel still works if that happens.
Rich rule syntax
A rich rule is a single quoted string with this shape:
rule [family="ipv4|ipv6"]
[source address="IP or CIDR" [invert="true"]]
[destination address="IP or CIDR"]
service name="NAME" | port port="N" protocol="tcp|udp"
[log [prefix="TEXT"] [level="info"] [limit value="RATE"]]
accept | reject | drop [limit value="RATE"]A few points that trip people up:
familyis required whenever you give an address.- You can use a firewalld service name (
ssh,mysql,http) or a port with protocol, not both. - A rule is added to one zone. Without
--zone, the default zone is used.
Allow a port from one IP
Open MySQL (TCP 3306) only to the app server:
sudo firewall-cmd --permanent --zone=public \
--add-rich-rule='rule family="ipv4" source address="198.51.100.20/32" port port="3306" protocol="tcp" accept'
sudo firewall-cmd --reloadMake sure the zone does not also have the mysql service or port 3306/tcp open to everyone, or the rich rule adds nothing:
sudo firewall-cmd --zone=public --list-allTo limit SSH to your office IP, add the rich rule, test a new SSH session from that IP, and only then remove the open service:
sudo firewall-cmd --permanent --zone=public \
--add-rich-rule='rule family="ipv4" source address="203.0.113.10/32" service name="ssh" accept'
sudo firewall-cmd --reload
# open a NEW ssh session from 203.0.113.10 and confirm it works, then:
sudo firewall-cmd --permanent --zone=public --remove-service=ssh
sudo firewall-cmd --reloadRate-limit new connections
A limit on the action caps how often the rule matches. Packets above the rate fall through to the next rule, and in a zone without SSH open they are then rejected. This allows at most 10 new SSH connections per minute to the whole server:
sudo firewall-cmd --permanent --zone=public \
--add-rich-rule='rule service name="ssh" accept limit value="10/m"'
sudo firewall-cmd --permanent --zone=public --remove-service=ssh
sudo firewall-cmd --reloadThe rate is global, not per IP. It slows brute-force bursts but one noisy bot can also use up your allowance. For per-IP banning, pair it with Fail2ban. See Use Fail2ban with firewalld or nftables ban actions.
Rate units are /s, /m, /h and /d.
Log and reject traffic
Reject SSH from a noisy subnet and log a sample of the attempts:
sudo firewall-cmd --permanent --zone=public \
--add-rich-rule='rule family="ipv4" source address="198.51.100.0/24" service name="ssh" log prefix="ssh-blocked " level="info" limit value="5/m" reject'
sudo firewall-cmd --reloadrejectsends back an ICMP error.dropsends nothing, so the client waits until it times out.- The
limitonlogstops the log from flooding.
Read the entries in the kernel log:
sudo journalctl -k | grep ssh-blockedfirewalld applies rich rules in a fixed order inside a zone: log rules first, then deny (reject and drop), then allow. A reject rule therefore wins over a broader accept for the same traffic.
Runtime vs permanent
--add-rich-rulewithout--permanentis live at once but disappears on reload or reboot. Good for testing.- With
--permanentit is saved, and you must run--reloadto make it live.
A careful workflow is to add the rule at runtime, test it, then save everything:
sudo firewall-cmd --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.10/32" port port="8080" protocol="tcp" accept'
# test it
sudo firewall-cmd --runtime-to-permanentIf the test goes wrong, sudo firewall-cmd --reload throws away runtime changes and restores the saved config.
List and remove rules
sudo firewall-cmd --zone=public --list-rich-rulesTo remove a rule, pass the exact same string you used to add it. The easiest way is to copy the line from the list output:
sudo firewall-cmd --permanent --zone=public \
--remove-rich-rule='rule family="ipv4" source address="198.51.100.20/32" port port="3306" protocol="tcp" accept'
sudo firewall-cmd --reloadCheck it works
From an allowed IP, test the port:
nc -vz 203.0.113.50 3306Replace 203.0.113.50 with your server's IP. Run the same test from a different network. The allowed source should connect, and every other source should be refused or time out. Confirm the rules are saved:
sudo firewall-cmd --permanent --zone=public --list-rich-rulesTroubleshooting
- "INVALID_RULE" error: usually a missing
familywhen you used an address, or straight quotes replaced by smart quotes when copying. Retype the quotes. - Port still open to everyone: the zone also lists the service or port in plain form. Remove it with
--remove-serviceor--remove-port. - Rule vanished after reboot: it was added without
--permanent. Use--runtime-to-permanent. - Cannot remove a rule: the string differs slightly, for example
/32missing. Copy it exactly from--list-rich-rules. - Locked out of SSH: log in through the browser console and run
sudo firewall-cmd --reload, or add the correct source IP.
Next steps
- Group trusted IPs in their own zone: firewalld zones explained.
- Block large lists of addresses efficiently: Block thousands of IPs with firewalld ipsets.
