The scenario
You are running a development server on Fedora. You need to allow your team to reach a custom application on port 8080, but only from your office network. You also want to log any connection attempts from the rest of the internet so you can audit them later. The standard firewall-cmd --add-service or --add-port commands are too blunt. They open the port to everyone, everywhere. You need conditional logic. You need rich rules.
What rich rules actually do
firewalld uses zones to group interfaces and sources. Each zone has a default policy, usually drop or reject. Services and ports are exceptions to that policy. Rich rules are the exception to the exceptions. They let you attach conditions to a port, a service, a masquerading rule, or a forwarding rule. You can filter by source IP, destination IP, protocol, port range, or even limit the rate of incoming connections.
Think of a zone as a building with a main lobby. The default policy is the security guard at the door who turns everyone away. Adding a service is like handing out keycards to approved visitors. A rich rule is the visitor log that says, "Only people from the 10.0.0.0/8 network get a keycard, and if anyone else tries to enter, write their name in the book and turn them away."
The syntax looks verbose because it is XML under the hood. firewall-cmd translates your command line into that XML and feeds it to the firewall backend. The verbosity is intentional. It forces you to declare every condition explicitly. Ambiguity in firewall rules causes outages. Explicit conditions prevent them.
Run firewall-cmd --help-rich-rules to see the full grammar. Read it once. You will reference it every time you write a complex rule.
How to write and apply a rich rule
Rich rules live in two places. The persistent configuration survives reboots. The runtime configuration applies immediately but disappears when the system restarts. You must always write to persistent first, then reload. Never modify runtime directly unless you are debugging a live issue and plan to discard the changes.
Here is how to check your current zone and verify the default target.
sudo firewall-cmd --get-default-zone # Shows which zone applies to unassigned interfaces
sudo firewall-cmd --zone=public --list-all # Shows current runtime rules for the public zone
The rule string follows a strict order. You declare the rule type, the protocol family, the source or destination, the port or service, and finally the action. The action can be accept, reject, drop, log, or limit.
Here is how to allow TCP port 8080 only from your office subnet and log everything else.
sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="8080" accept' # Adds the allow rule to persistent config
sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" port protocol="tcp" port="8080" log prefix="BLOCKED_8080: " level="info" limit value="5/m"' # Adds a rate-limited log rule for other traffic
sudo firewall-cmd --reload # Applies persistent rules to runtime and restarts the firewall backend
The --reload flag is mandatory after every persistent change. Without it, the runtime configuration and the persistent configuration diverge. The next reboot will silently revert to the persistent state, and you will spend an hour wondering why your rule vanished.
You can also target services instead of raw ports. Services are just named port/protocol combinations defined in /usr/lib/firewalld/services/ or /etc/firewalld/services/. Editing /etc/ is safe. Never edit /usr/lib/. Package updates will overwrite /usr/lib/ and erase your work.
Here is how to restrict SSH access to a single management IP and drop everything else.
sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.5" service name="ssh" accept' # Allows SSH only from the management IP
sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" service name="ssh" drop' # Drops all other SSH attempts silently
sudo firewall-cmd --reload # Commits the changes to the active firewall
Order matters inside the rule set. firewalld evaluates rules top to bottom. The first matching rule wins. If you place a broad accept before a specific drop, the broad rule swallows the traffic first. Always put specific allows first, then broad denies or logs.
Reload the firewall after every syntax change. Guessing whether the rule loaded is how you leave ports open.
Verify the rule is active
Do not assume the rule loaded correctly. The firewall backend will silently ignore malformed XML. You need to inspect the runtime state directly.
Here is how to list only the rich rules currently active in the public zone.
sudo firewall-cmd --zone=public --list-rich-rules # Prints every rich rule in the active runtime config
If the output matches your persistent commands, the rule is live. You can also test connectivity from a remote machine. Use nc or curl to hit the port. Check the logs if you added a log action.
Here is how to watch the firewall logs in real time.
sudo journalctl -f -t kernel | grep BLOCKED_8080 # Streams kernel log lines matching your custom prefix
The journalctl -f command follows the log. The -t kernel flag filters for kernel messages, which is where firewalld forwards its log actions. If you see your prefix, the logging rule is working. If you see nothing, check your rule syntax and verify the traffic actually matches the source and port conditions.
You can also query a single rule to confirm it exists in the runtime state.
sudo firewall-cmd --zone=public --query-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="8080" accept' # Returns success or failure without printing the rule
A silent exit code of zero means the rule is active. A non-zero exit means it is missing. Trust the query command over your memory.
Common pitfalls and error messages
Rich rules fail in predictable ways. The most common error is a syntax mismatch. The XML parser inside firewalld is strict. A missing quote or a misplaced space breaks the entire rule.
Warning: ALREADY_ENABLED: 'rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="8080" accept'
This warning means you tried to add a rule that already exists in the persistent configuration. firewalld prevents duplicates. Remove the old rule first, then add the corrected version.
sudo firewall-cmd --permanent --zone=public --remove-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="8080" accept' # Clears the duplicate
Another frequent issue is mixing IPv4 and IPv6. If you declare family="ipv4" but the traffic arrives over IPv6, the rule never matches. You must write a separate rule for family="ipv6" or omit the family tag entirely to match both. Omitting the family tag is safer for modern dual-stack networks.
sudo firewall-cmd --permanent --zone=public --add-rich-rule='rule source address="192.168.1.0/24" port protocol="tcp" port="8080" accept' # Matches both IPv4 and IPv6 automatically
Rate limiting also trips people up. The limit action uses a token bucket algorithm. The syntax requires a value and a time unit. limit value="3/m" allows three connections per minute. Anything faster gets dropped. If you set the limit too low, legitimate traffic gets throttled. Start high, then tighten it based on your logs.
When debugging a broken rule, enable the built-in trace mode. It prints exactly how firewalld translates your command into iptables or nftables rules.
sudo firewall-cmd --trace --zone=public --add-rich-rule='rule family="ipv4" port protocol="tcp" port="8080" accept' # Shows the backend translation and evaluation path
The trace output reveals missing modules, incorrect chain placement, or syntax errors that the standard command hides. Run trace before you rewrite the rule from scratch.
When to use rich rules versus alternatives
Use standard --add-service or --add-port when you need to open a port to the entire internet without conditions. Use rich rules when you need source filtering, logging, rate limiting, or protocol-specific exceptions. Use iptables or nftables directly when you need advanced NAT, connection tracking tweaks, or hardware offloading that firewalld does not expose. Use ufw when you are migrating from Ubuntu and refuse to learn firewalld syntax. Stay on firewalld rich rules if you want a persistent, reload-safe, zone-aware firewall that integrates with NetworkManager.
Check the zone assignment before you write the rule. A rule in the wrong zone is just dead weight.