Networking & firewall¶
Scope: the course page's networking section adds one static IP with
nmcliand opens one port withfirewall-cmd— and stops there. Add a second NIC, a bond, or a container that needs to publish a port, and both tools turn out to have a whole model hiding behind that one command. This page covers NetworkManager's connection-profile model (why a "profile" isn't the same thing as an "interface"), firewalld's zone model in depth (why a zone, not one global rule list, is the unit of policy), and the Netavark bridge path a container's packet actually takes to reach the host firewall.
TL;DR — in 30 seconds:
- NetworkManager configures a connection profile, not "an interface" —
nmcli connection modifychanges stored settings, and nothing on the wire changes untilnmcli connection upre-activates it. - firewalld's unit of policy is the zone, not one global rule list — every interface belongs to exactly
one zone, and
--permanentwrites to disk without touching the live ruleset until--reload. - A container's published port crosses three layers to reach the outside world — Netavark's bridge NAT rule, the kernel's nftables, and the host's firewalld zone — any one of which can be the reason it's unreachable.
1. NetworkManager's model: profiles, not interfaces¶
A RHEL host doesn't configure "the eth0 interface" directly — it configures a connection profile, a named bundle of settings (IP mode, addresses, DNS, VLAN, bonding) that NetworkManager then activates onto a device:
flowchart LR
P1["Profile: static-eth0\nipv4.method=manual"]
P2["Profile: dhcp-eth0\nipv4.method=auto"]
DEV["Device: eth0"]
P1 -.->|nmcli connection up static-eth0| DEV
P2 -.->|nmcli connection up dhcp-eth0| DEV
Only one profile can be active on a device at a time, but a device can have several profiles defined
for it — a static one for a fixed deployment, a DHCP one for a lab, a VLAN one for a trunked port — and
switching between them is nmcli connection up <name>, not editing files:
nmcli connection show # every defined profile, active or not
nmcli connection show --active # only what's live right now
nmcli device status # device -> currently active profile
This is why nmcli connection modify (change a profile's settings) and nmcli connection up (apply
them) are two separate steps — a modify only rewrites the stored profile; nothing on the wire changes
until something re-activates it.
2. Bonds and VLANs — profiles that stack¶
A bond or a VLAN is not a special case bolted onto NetworkManager — it's another connection profile, one that names other profiles as its members instead of a physical device:
# Bond two NICs for redundancy/throughput
sudo nmcli connection add type bond con-name bond0 ifname bond0 bond.options "mode=active-backup"
sudo nmcli connection add type ethernet con-name bond0-eth0 ifname eth0 master bond0
sudo nmcli connection add type ethernet con-name bond0-eth1 ifname eth1 master bond0
sudo nmcli connection up bond0
# A VLAN on top of a physical interface
sudo nmcli connection add type vlan con-name vlan100 ifname eth0.100 dev eth0 id 100
sudo nmcli connection modify vlan100 ipv4.method manual ipv4.addresses 10.0.100.5/24
sudo nmcli connection up vlan100
The bridge profile type from the course page is the same idea again — a profile that other profiles
attach to as master. Once you can read one of these (parent profile + member profiles + up to
activate), you can read all of them.
3. firewalld's zone model, in depth¶
The course page opens one port in the default zone. The model underneath is richer: every network interface is assigned to exactly one zone, and a zone is a named bundle of trust decisions — which services and ports are allowed, whether ICMP is answered, whether the zone is even reachable at all:
flowchart TB
ETH0["eth0 (uplink)"] --> PUBLIC["zone: public\nssh only"]
ETH1["eth1 (internal)"] --> INTERNAL["zone: internal\nhttp, https, custom app ports"]
DOCKER["podman0 (container bridge)"] --> TRUSTED["zone: trusted\n(or a dedicated zone)"]
sudo firewall-cmd --get-zones # every zone firewalld knows about
sudo firewall-cmd --get-active-zones # zone -> interface(s) actually assigned
sudo firewall-cmd --zone=internal --list-all # rules for one specific zone
sudo firewall-cmd --zone=internal --change-interface=eth1 --permanent
A common two-NIC pattern: a locked-down public zone for the internet-facing interface (SSH only), and
a more permissive internal zone for a private-network interface (application ports, monitoring
scrape). Everything from §9 of the course page — --add-service, --add-port, --permanent,
--reload — takes an explicit --zone=<name> the same way; omitting it just targets whichever zone is
the current default.
Runtime vs. permanent, precisely: firewalld actually holds two configurations at once — the
live, in-kernel ruleset ("runtime") and the on-disk ruleset ("permanent"). A plain firewall-cmd
--add-service=https changes runtime only and is gone on the next reload or reboot; --permanent writes
to disk but does not touch the live ruleset until --reload copies permanent over runtime. Testing a
change safely means applying it without --permanent first, confirming it behaves, then re-running it
with --permanent --reload once you're sure — a bad runtime-only rule is undone by firewall-cmd
--reload, no bad state persists.
4. Netavark — the container bridge's path to the host firewall¶
The course page names the Netavark backend as the thing that programs podman0/podman1 and says
it "traverses firewalld." Here is that path end to end:
flowchart LR
C["Container\n10.88.0.5"] -->|veth pair| BR["podman0 bridge\n(Netavark-managed)"]
BR -->|nftables NAT rule\nPublishPort 8080:80| NFT["kernel nftables"]
NFT -->|matched against| ZONE["firewalld zone\nfor the physical uplink"]
ZONE --> HOST["Host's external interface"]
Netavark is Podman's default network backend, replacing the older CNI plugins (per Podman's own
docs). For every container
network it creates, it writes the bridge (podman0 for the default network, podman1, … for additional
ones), assigns a subnet, and programs the nftables NAT rules that make a --publish 8080:80 actually
forward traffic from the host's port 8080 into the container's port 80. Those NAT rules sit inside
the kernel's nftables tables that firewalld also manages — which is why a container's published port can
still be blocked at the zone level if the host's zone doesn't allow inbound traffic on it, even though the
container itself is running and listening fine.
The practical debugging order when "the container is running but the port isn't reachable from outside":
podman port <container> # confirm the publish mapping podman thinks exists
sudo firewall-cmd --list-all # is the port/service actually allowed in the active zone?
sudo nft list ruleset | grep -A2 <port> # confirm the NAT rule is present at the nftables layer
Container-to-container traffic on the same bridge network doesn't touch firewalld at all — it's resolved by the bridge and Netavark's built-in DNS (containers on a shared network can reach each other by container name); firewalld only enters the path once a container's port is published to the host and needs to cross into the outside world.
5. How this connects¶
The course page's one static IP and one open port is the 80% you'll do on almost every RHEL host. This page is what to reach for once a host needs more than one network profile (a bond, a VLAN, a DHCP-vs-static switch), once policy needs to differ per interface instead of one flat rule list (the zone model), or once you're debugging why a container's published port isn't reachable and need to know which of three layers — Podman's mapping, firewalld's zone, or the raw nftables rule — is the one saying no.
Common gotchas
- Running
nmcli connection modifyand expecting the change to take effect immediately. Fix: amodifyonly rewrites the stored profile — nothing changes on the wire untilnmcli connection up <name>re-activates it. - Forgetting
--zone=<name>and assuming a firewalld rule landed where intended. Fix: an--add-service/--add-portwithout an explicit zone targets whichever zone is the current default — on a multi-zone host that's easy to get wrong silently. - Testing a firewalld change with
--permanentfrom the start. Fix:--permanentwrites to disk but doesn't touch the live ruleset until--reload— test without--permanentfirst, confirm it behaves, then re-apply with--permanent --reloadonce you're sure. - Assuming a running, listening container means its published port is reachable. Fix: the port
still has to clear the host's firewalld zone — check
podman port, thenfirewall-cmd --list-all, then the nftables ruleset, in that order, before assuming the container itself is broken.
Check yourself — you run nmcli connection modify static-eth0 ipv4.addresses 10.0.0.5/24 and ping the host from another machine. The IP hasn't changed. Why?
modify only rewrites the stored profile on disk — it doesn't touch what's live on the wire. The
profile has to be re-activated with nmcli connection up static-eth0 before the new address takes
effect.
Check yourself — a container is running and podman port confirms it's publishing 8080:80, but the port isn't reachable from outside the host. Where do you look, in order, and why that order?
podman port (confirm the mapping podman thinks exists) → firewall-cmd --list-all (is the port
allowed in the active zone?) → nft list ruleset (is the NAT rule actually present at the kernel
level?). That order goes from the layer closest to the container outward to the layer closest to the
outside world, so each check rules out one hop before moving to the next.
You can defend this when you can explain why nmcli connection modify doesn't change anything on the
wire by itself, what a zone actually groups (rules) and how two zones on one host let policy differ per
interface, the difference between firewalld's runtime and permanent configuration and why --reload
matters, and the three-hop path (bridge → nftables NAT → firewalld zone) a published container port
takes to reach the outside world.