firewalld: Managing the Linux Firewall
- Explain firewalld's zone model — rules attach to trust levels, not just ports
- Find the default and active zone, and read a zone's full ruleset before changing it
- Tell runtime from permanent config — and never lose a rule to a reboot, or wonder why one isn't active
- Allow services and ports the persistent way, and verify both halves
- Scope access to specific sources with rich rules and zone bindings
Why firewalld, and the zone idea
Understand that rules attach to zones, and zones model trust
A host firewall is the machine's own bouncer: for every incoming connection, it decides accept or refuse before any application even hears about it. You hardened SSH's lock; the firewall decides who gets to touch the lock at all.
firewalldis the manager on RHEL-family systems, and it has two headline properties: it applies changes dynamically (no restart, no dropped connections — critical on a production box), and it organizes everything around zones.Here's the idea that makes firewalld click. A laptop on coffee-shop Wi-Fi and the same laptop on your home LAN shouldn't apply the same rules — same machine, different trust. So firewalld doesn't attach rules to the machine; it attaches them to zones, where a zone is a named trust level. Picture a receptionist with a different rulebook per entrance: connections arriving through the coffee-shop entrance get the strict
publicbook; the management-LAN entrance gets a relaxed one. Every network interface (and any source IP you explicitly assign) is bound to exactly one zone, and that zone's rulebook is what applies.The zones you'll actually use:
- `public` — the default: untrusted; only explicitly-allowed traffic gets in. Where most server work happens.
- `trusted` — accept everything. For interfaces where the network itself is the security boundary (a private cluster interconnect). Use sparingly.
- `drop` — silently discard all incoming, not even a refusal message. The right home for known-hostile sources.
- `block` — refuse with an ICMP "no." Politer than
drop; the sender knows immediately rather than timing out.
On a single-interface server you'll live in
public— but the model still matters, because every rule you write lands in a zone, and rules in the wrong zone silently do nothing.Check yourself
You added a rule to the 'work' zone but traffic is still blocked. ss shows the service listening. What's the first thing to check?
Find your zone, read the rulebook
See the default and active zones and the current ruleset before changing anything
Same discipline as every other subsystem: read before you write. Three questions, in order — is the firewall running, what's the default zone, and (the one that actually matters) which zone is active on my interfaces right now?
Example — not runnable on your lab box
sudo systemctl enable --now firewalld firewall-cmd --state; firewall-cmd --get-default-zone; firewall-cmd --get-active-zonesrunning public public interfaces: eth0
- --state
- is firewalld running?
- --get-default-zone
- the fallback zone for unassigned interfaces
- --get-active-zones
- zones with interfaces/sources bound now — the rulebooks in force
enable --now, from your systemctl toolkit — running now, back at every boot. Then the three reads: --state (running), --get-default-zone (the zone interfaces fall into when not explicitly assigned — public, almost always), and --get-active-zones (which zones have interfaces or sources bound RIGHT NOW, and which interfaces). The active zone is the rulebook actually in force — the default zone only tells you where new interfaces will land. Example — not runnable on your lab box
firewall-cmd --list-allpublic (active) target: default icmp-block-inversion: no interfaces: eth0 sources: services: ssh dhcpv6-client ports: protocols: forward-ports: rich rules:
The full rulebook for the active zone — your "what is allowed right now" report. Read the meaningful lines: interfaces (eth0 is governed by this book), services (ssh and dhcpv6-client — nothing else gets in), and the empty ports/rich-rules lines (no ad-hoc openings yet). Note ssh in the defaults: that's the deliberate courtesy that stops enabling firewalld from cutting off the SSH session that enabled it. Check yourself
What's the difference between the default zone and the active zone — and which one decides whether your packet gets in?
Runtime vs permanent — the rule that bites everyone
Make changes that both take effect now AND survive a reboot
You've met this split three times now —
ipvsnmcli,startvsenable— and firewalld has its own flavor, sharp enough to bite in both directions. It keeps two copies of its configuration:- Runtime — what the kernel is enforcing this second. A whiteboard: changes hit instantly, and it's wiped clean on
--reloador reboot (rewritten from the binder). - Permanent — the policy binder on disk, read at boot and on reload. Writing to it changes nothing about what's enforced right now.
A plain
firewall-cmd --add-service=Xwrites only the whiteboard. Adding--permanentwrites only the binder. Neither alone gives you what you almost always want — active now and after reboot — which is why the canonical idiom is two commands:Example — not runnable on your lab box
sudo firewall-cmd --permanent --add-service=http sudo firewall-cmd --reloadsuccess success
- --permanent
- write the rule to disk — NOT active until a reload
- --reload
- rebuild the runtime from the permanent config — applies binder, wipes un-persisted scribbles
The RHCSA idiom, and the production idiom: write the binder (--permanent), then re-read the binder onto the whiteboard (--reload). After these two lines http is allowed now AND at every future boot. One nuance of --reload worth knowing: it wipes any runtime-only rules that never made it to the binder — which is tidy when intentional and a surprise when someone's un-persisted emergency rule vanishes. Check yourself
A colleague ran firewall-cmd --permanent --add-port=9090/tcp an hour ago and reports the port still refuses connections. Config file shows the rule. What happened?
- Runtime — what the kernel is enforcing this second. A whiteboard: changes hit instantly, and it's wiped clean on
Allow services and ports
Open access by named service or raw port, and verify
What goes in the rulebook? Two kinds of allowance. A service is a named bundle —
httpmeans 80/tcp,httpsmeans 443/tcp, defined in shipped XML — so rules read as intent (--list-servicessays this box serves web, not port 80 is open, good luck). A raw port is for anything without a predefined name: your app on 8080, a custom agent. The rule of thumb: service when one exists, port when it doesn't.Example — not runnable on your lab box
firewall-cmd --get-services | tr ' ' '\n' | grep -cE '.' firewall-cmd --list-services150 ssh dhcpv6-client http
Two different questions: --get-services lists every service firewalld KNOWS (150 definitions — https, mysql, nfs, samba are all in there), while --list-services shows what's ALLOWED in the current zone (our http from the previous step is now live). Mixing these two up makes --get-services output look alarmingly like an open-everything firewall — it's the menu, not the order. Example — not runnable on your lab box
sudo firewall-cmd --permanent --add-port=8080/tcp sudo firewall-cmd --reload firewall-cmd --list-portssuccess success 8080/tcp
- --add-port=8080/tcp
- open a port/protocol; ranges like 5000-5100/tcp work
- --remove-service / --remove-port
- the inverse — same --permanent + --reload discipline
- --zone=NAME
- aim the command at a specific zone instead of the default
The raw-port version of the same idiom, for a dev server with no service definition — permanent, reload, then VERIFY with --list-ports. That verify beat matters: it reads the runtime, so it confirms the reload actually happened, closing out both halves of the trap in one check. Ranges work too (--add-port=5000-5100/tcp), removal is the mirror image (--remove-port, same permanent+reload discipline), and any command can target a non-active zone with --zone=work. Check yourself
Why prefer --add-service=https over --add-port=443/tcp when both open the same port?
Scope access with rich rules and sources
Allow something for one host or network, not the whole world
--add-service=mysqlopens the database to everyone the interface can hear — usually far more trust than intended. The real requirement is almost always scoped: "only the app server may reach 3306." firewalld gives you two scoping tools at two granularities: a rich rule (a precise sentence: this source, this port, this action) and a source-to-zone binding (coarse: this whole network gets that zone's entire rulebook).Example — not runnable on your lab box
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.50" port port="3306" protocol="tcp" accept' sudo firewall-cmd --reloadsuccess success
Read the rich rule left to right like the sentence it is: for IPv4, from source 192.168.1.50, to TCP port 3306 — accept. That one host reaches MySQL; everyone else still hits the zone's default refusal, because 3306 appears in no general allowance. Rich rules also speak reject (refuse loudly), log prefix="db-probe" (record attempts), and limit value="3/m" (rate-limit) — the vocabulary for rules with judgement. Example — not runnable on your lab box
sudo firewall-cmd --permanent --zone=drop --add-source=203.0.113.0/24 sudo firewall-cmd --reload firewall-cmd --get-active-zonessuccess success drop sources: 203.0.113.0/24 public interfaces: eth0
The coarse tool: bind a source to a zone, and that zone's whole rulebook applies to it. Everything from 203.0.113.0/24 is now silently discarded — the drop zone just became active (see the output) with that network as its member, and source bindings are checked BEFORE interface zones, so this wins over anything public allows. This is the clean, persistent way to ban a hostile network — the firewalld move that pairs with fail2ban's automated per-IP version. Check yourself
Write the scoping decision: the whole 10.8.0.0/24 VPN subnet should be fully trusted, but exactly one external host (198.51.100.7) needs to reach TCP 8443. Which tool for which?
Now do it — open a service the graded way
Run the open-verify loop a "configure the firewall" lab grades, both halves checked
The full lifecycle, hands on: baseline, change, then — the part that separates done from provably done — verify both stores. Graded labs check the permanent config (will it survive reboot?) AND the runtime (is it active?); so should you, every time.
Example — not runnable on your lab box
sudo systemctl enable --now firewalld firewall-cmd --get-active-zones; firewall-cmd --list-allRunning + persistent (one systemctl idiom), then the two reads: which zone is live on which interface, and its full current rulebook. This baseline tells you what 'unchanged' looks like — you can't verify a change without it. Example — not runnable on your lab box
sudo firewall-cmd --permanent --add-service=https sudo firewall-cmd --reload firewall-cmd --list-servicesThe canonical idiom, all three beats: binder, reload, verify-runtime. https should now appear in the services line. Say the two-store model out loud as you type — 'writing the binder… copying to the whiteboard… reading the whiteboard' — until the --permanent-then---reload pair is one gesture in your fingers. Example — not runnable on your lab box
firewall-cmd --query-service=https; firewall-cmd --permanent --list-servicesThe both-stores verification: --query-service asks the RUNTIME (expect 'yes' — it's enforced now), and --permanent --list-services reads the BINDER (https present — it survives reboot). Two stores, two checks. This pair is exactly how you'd prove the task in an exam, and exactly how you'd debug either half of the trap in production. Example — not runnable on your lab box
sudo firewall-cmd --add-port=9999/tcp; firewall-cmd --list-ports; sudo firewall-cmd --reload; firewall-cmd --list-portsSpring the trap on purpose: add 9999 runtime-only (no --permanent), see it in --list-ports… then watch --reload WIPE it — the whiteboard rewritten from a binder that never knew about 9999. That vanishing act, witnessed once deliberately, is the reboot-loses-my-rule bug you'll now never ship.
- firewalld attaches rules to ZONES — named trust levels bound to interfaces/sources (public = default-deny-except-allowed; trusted/drop/block for the edges). A correct rule in the wrong zone silently does nothing: always check --get-active-zones first.
- Two config stores: runtime (the whiteboard — enforced now, wiped on reload/reboot) and permanent (the binder — disk, read at reload/boot). The trap bites BOTH ways: runtime-only vanishes at reboot; permanent-only isn't active now.
- The canonical idiom is three beats: --permanent (write binder) → --reload (rebuild whiteboard) → verify (--list-services/--list-ports reads the runtime).
- Allow by named service where one exists (self-documenting intent), raw port where it doesn't; --remove-* mirrors with the same discipline; --zone=NAME aims at non-default zones.
- Scope by granularity: rich rules for precise sentences (one source, one port, accept/reject/log/limit); source-to-zone bindings for whole networks (drop zone = the clean persistent ban). Panic mode exists — console-only.
- Prove work in both stores: --query-service (runtime) + --permanent --list-services (binder) — that pair is exam verification and production debugging alike.
| Task | Command |
|---|---|
| Zone in force / rulebook | --get-active-zones · --list-all |
| Allow service (persist) | --permanent --add-service=http + --reload |
| Allow port (persist) | --permanent --add-port=8080/tcp + --reload |
| Verify runtime / binder | --query-service=NAME · --permanent --list-services |
| One host → one port | --add-rich-rule='rule family=ipv4 source address=IP port port=N protocol=tcp accept' |
| Ban a network | --permanent --zone=drop --add-source=CIDR + --reload |
| Break glass | --panic-on / --panic-off (console only) |
