Software Engineering WikiSE Wiki

firewalld, nftables and iptables

Open, forward, NAT, rate-limit and log traffic on Linux with firewalld zones and rich rules, native nftables rulesets, and iptables translation, without breaking containers.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Is firewalld running, which zones are activefirewall-cmd --state; firewall-cmd --get-active-zones
Everything in the default zonefirewall-cmd --list-all
Open a service now and permanentlyfirewall-cmd --add-service=https --permanent && firewall-cmd --reload
Open a portfirewall-cmd --add-port=8443/tcp --permanent
Remove itfirewall-cmd --remove-port=8443/tcp --permanent
Apply permanent configfirewall-cmd --reload
Copy runtime to permanentfirewall-cmd --runtime-to-permanent
Allow SSH from one subnet onlyfirewall-cmd --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 service name=ssh accept' --permanent
Put an interface in a zonefirewall-cmd --zone=internal --change-interface=eth1 --permanent
NAT for a private networkfirewall-cmd --zone=public --add-masquerade --permanent
Forward a portfirewall-cmd --add-forward-port=port=80:proto=tcp:toaddr=192.0.2.10:toport=8080 --permanent
Log denied packetsfirewall-cmd --set-log-denied=all
The nftables rules firewalld generatednft list ruleset
One nftables tablenft list table inet filter
Load an nftables file atomicallynft -f /etc/nftables/main.nft
Check a file without loadingnft -c -f /etc/nftables/main.nft
Flush every nftables rule (drops all filtering)nft flush ruleset
Counters on rulesnft list ruleset -a (handles), nft list chain inet filter input
Translate an iptables ruleiptables-translate -A INPUT -p tcp --dport 22 -j ACCEPT
Legacy view of the rulesiptables -L -n -v --line-numbers
Which ports are listeningss -tulpn
Watch what a rule matchesnft monitor trace with a meta nftrace set 1 rule

Commands assume firewalld 2.x on Fedora 42 and 1.3 on RHEL 9, nftables 1.1 and iptables-nft 1.8.x, all of which program the same kernel nf_tables subsystem. References: the firewalld documentation, the nftables wiki and the Red Hat networking guide.

One kernel, three front ends#

Since RHEL 8 and Fedora 32 every packet filter on the box ends up as nf_tables rules in the kernel. firewalld is a daemon with a zone model that writes nftables rules in its own tables (firewalld in the inet, ip and ip6 families). nft is the native command that writes rules directly. iptables on these systems is iptables-nft, a compatibility binary that translates the old syntax into nf_tables rules in tables named filter, nat and mangle of the ip family. Docker and libvirt write their own rules through iptables-nft. Podman uses nftables or iptables-nft through netavark.

All of these rule sets are evaluated. A packet accepted by firewalld can still be dropped by a rule in another table, and vice versa: a drop in any base chain wins, and accept in one table only means “continue to the next table at the same hook”. That is the source of most confusion when firewalld and Docker coexist. nft list ruleset is the one place that shows the whole truth.

Pick one front end per purpose and do not mix them for the same job. On a workstation or a server with a simple policy, firewalld. On a router, a container host with custom NAT, or anywhere a reviewer wants to read one file, a native nftables ruleset with firewalld disabled. Legacy iptables only to read or translate what old tooling left behind.

firewalld#

firewalld assigns every interface and every source range to a zone, and each zone has a policy plus a set of allowed services, ports, rich rules, masquerade and forward-port settings. Traffic arriving on an interface is evaluated by that interface’s zone; if the source matches a zone’s source range that zone takes precedence over the interface zone. Runtime configuration lives in memory and is lost at restart; --permanent writes XML under /etc/firewalld/ and takes effect after --reload.

firewall-cmd --state                                # running
firewall-cmd --get-default-zone                     # public on a fresh install
firewall-cmd --get-active-zones                     # zones with interfaces or sources bound
firewall-cmd --get-zones                            # block dmz drop external home internal nm-shared public trusted work
firewall-cmd --list-all                             # default zone in full
firewall-cmd --list-all --zone=internal
firewall-cmd --list-all-zones                       # every zone; long
firewall-cmd --get-zone-of-interface=eth0
firewall-cmd --get-services                         # ~200 predefined services: http https ssh dns nfs samba ...
firewall-cmd --info-service=https                   # ports the service maps to
firewall-cmd --permanent --list-all                 # what is on disk, versus runtime

Zones from most to least permissive: trusted accepts everything; home, internal, work accept a few services (ssh, mdns, samba-client, dhcpv6-client); public (default) accepts ssh and dhcpv6-client; external adds masquerade; dmz accepts ssh only; block rejects with ICMP; drop drops silently. The zone’s target decides what happens to traffic no rule matched: default (reject for most zones, accept for trusted), ACCEPT, REJECT or DROP.

firewall-cmd --set-default-zone=drop                                    # new interfaces land here
firewall-cmd --zone=internal --change-interface=eth1 --permanent         # NetworkManager-managed interfaces: also nmcli con mod eth1 connection.zone internal
firewall-cmd --zone=trusted --add-source=192.0.2.0/24 --permanent        # source-based zone: this range is trusted on any interface
firewall-cmd --zone=public --add-service=https --permanent
firewall-cmd --zone=public --add-service={http,https} --permanent
firewall-cmd --zone=public --add-port=8443/tcp --permanent
firewall-cmd --zone=public --add-port=60000-61000/udp --permanent        # port range
firewall-cmd --zone=public --remove-service=cockpit --permanent
firewall-cmd --zone=public --remove-service=ssh --permanent              # locks you out if this is the interface you came in on
firewall-cmd --reload                                                    # apply permanent; drops runtime-only changes
firewall-cmd --runtime-to-permanent                                      # save what you tested at runtime
firewall-cmd --add-port=8080/tcp --timeout=300                           # runtime only, removed after 5 minutes: safe for testing remotely
firewall-cmd --zone=public --set-target=DROP --permanent                 # silent drop instead of reject for unmatched traffic
firewall-cmd --panic-on                                                  # drops every packet in and out, including your SSH session; --panic-off

Adding a service without --permanent changes runtime only; adding with --permanent changes disk only. Do both, or add at runtime, test, then --runtime-to-permanent. Never --reload after untested permanent changes to a remote host without a --timeout fallback or a second session.

Custom services are XML files in /etc/firewalld/services/:

firewall-cmd --permanent --new-service=my-app
firewall-cmd --permanent --service=my-app --set-description='my-app API'
firewall-cmd --permanent --service=my-app --add-port=8080/tcp --add-port=8443/tcp
firewall-cmd --reload && firewall-cmd --zone=public --add-service=my-app --permanent && firewall-cmd --reload

Rich rules#

Rich rules express what zones and services cannot: a source restriction on one service, logging, rate limits, rejects with a specific ICMP type. They are evaluated before the zone’s plain services and ports.

# SSH only from the management subnet, everything else to port 22 dropped by the zone target
firewall-cmd --zone=public --remove-service=ssh --permanent
firewall-cmd --zone=public --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 service name=ssh accept' --permanent

# Block one host entirely
firewall-cmd --add-rich-rule='rule family=ipv4 source address=203.0.113.7 drop' --permanent

# Reject with a proper ICMP message instead of silently dropping
firewall-cmd --add-rich-rule='rule family=ipv4 source address=198.51.100.0/24 reject type=icmp-admin-prohibited' --permanent

# Rate-limit new SSH connections and log the ones that get through
firewall-cmd --add-rich-rule='rule service name=ssh log prefix="ssh " level=info limit value=5/m accept' --permanent

# Allow a port only from one address, logging every hit
firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.0.2.10 port port=5432 protocol=tcp log prefix="pg " accept' --permanent

# Forward a port only for one source
firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 forward-port port=8080 protocol=tcp to-port=80 to-addr=10.0.0.5' --permanent

# Masquerade only for one source range
firewall-cmd --add-rich-rule='rule family=ipv4 source address=10.0.0.0/24 masquerade' --permanent

firewall-cmd --list-rich-rules
firewall-cmd --remove-rich-rule='rule family=ipv4 source address=203.0.113.7 drop' --permanent   # exact text to remove

Rule elements in order: rule [family=ipv4|ipv6] [priority=N], then one source or destination, then one of service, port, protocol, icmp-block, icmp-type, masquerade, forward-port or source-port, then optional log and audit, then an action accept, reject, drop or mark. priority (firewalld 0.7+) runs from -32768 to 32767, lower first; without it rules are ordered by action (log, deny, allow).

Masquerade and port forwarding#

Masquerade is source NAT for traffic leaving a zone. Forward-port rewrites the destination of inbound traffic. Both require IP forwarding, which firewalld enables when masquerade is turned on.

firewall-cmd --zone=public --add-masquerade --permanent            # VMs or containers on an internal zone reach the internet via this host
firewall-cmd --zone=public --add-forward-port=port=443:proto=tcp:toport=8443 --permanent               # local redirect
firewall-cmd --zone=public --add-forward-port=port=80:proto=tcp:toaddr=192.0.2.10:toport=8080 --permanent   # to another host; needs masquerade or a policy
firewall-cmd --zone=public --list-forward-ports
sysctl net.ipv4.ip_forward                                          # 1 after masquerade; firewalld sets it, no sysctl.d entry needed

Policies#

Zones govern traffic to and from the host. Policies (firewalld 0.9+, RHEL 9) govern traffic forwarded between zones, which is what a router or a container host needs. The built-in allow-host-ipv6 policy exists on every install; a policy with --set-target=CONTINUE and explicit rules is the modern replacement for putting --add-masquerade on external and trusting the internal zone.

firewall-cmd --permanent --new-policy=lan-to-wan
firewall-cmd --permanent --policy=lan-to-wan --add-ingress-zone=internal
firewall-cmd --permanent --policy=lan-to-wan --add-egress-zone=public
firewall-cmd --permanent --policy=lan-to-wan --set-target=ACCEPT
firewall-cmd --permanent --policy=lan-to-wan --add-masquerade                 # or --add-masquerade on the egress zone
firewall-cmd --permanent --new-policy=wan-to-dmz
firewall-cmd --permanent --policy=wan-to-dmz --add-ingress-zone=public --add-egress-zone=dmz
firewall-cmd --permanent --policy=wan-to-dmz --add-rich-rule='rule family=ipv4 destination address=10.0.1.10 port port=443 protocol=tcp accept'
firewall-cmd --reload
firewall-cmd --info-policy=lan-to-wan

A zone with forward enabled (--add-forward, default on since 1.0) allows traffic between interfaces in the same zone. Anything crossing zones needs a policy.

Logging denied packets#

firewall-cmd --set-log-denied=all         # off, all, unicast, broadcast, multicast; runtime and permanent at once
firewall-cmd --get-log-denied
journalctl -k -g 'FINAL_REJECT|FINAL_DROP' -f     # firewalld prefixes: FINAL_REJECT for reject zones, FINAL_DROP for drop

Each log line names the interface, source and destination address, protocol and ports: IN=eth0 OUT= SRC=203.0.113.7 DST=192.0.2.1 ... PROTO=TCP SPT=51234 DPT=23. Turn it off after debugging on a busy host; every unsolicited packet becomes a journal line.

Direct rules and the nftables backend#

--direct rules insert raw iptables syntax and are deprecated; policies and rich rules cover what they were used for. firewalld’s own rules are in nft list table inet firewalld. Adding rules with nft to firewalld’s tables is undone at every reload; put your own rules in your own table, and keep in mind that both are evaluated.

nftables#

nftables replaces iptables, ip6tables, arptables and ebtables with one syntax and one kernel API. A ruleset is a set of tables; a table belongs to a family (ip, ip6, inet for both, arp, bridge, netdev) and holds chains, sets, maps and counters. A base chain hooks into the network stack at a point (prerouting, input, forward, output, postrouting) with a priority and a default policy; a regular chain runs only when jumped to. Rules are evaluated in order within a chain, and the first terminating verdict (accept, drop, reject, jump, goto, return) ends evaluation of that chain.

Everything is atomic when loaded from a file with nft -f: the kernel swaps the whole ruleset in one transaction, so a syntax error leaves the old rules in place and there is no half-loaded window. Sets replace long lists of near-identical rules and are updated without touching the rules that reference them.

nft list ruleset                            # everything, in the syntax you would write it in
nft list tables                             # table names and families
nft list table inet filter
nft list chain inet filter input
nft list ruleset -a                         # with rule handles, needed for delete and insert-at
nft list set inet filter admin_hosts
nft -c -f /etc/nftables/main.nft            # -c: check syntax and semantics, load nothing
nft -f /etc/nftables/main.nft               # load atomically; the file usually starts with flush ruleset
nft add rule inet filter input tcp dport 8080 accept                    # append to a chain at runtime
nft insert rule inet filter input position 0 tcp dport 8080 accept      # at the top
nft add rule inet filter input handle 12 tcp dport 8080 accept          # after handle 12
nft delete rule inet filter input handle 14
nft add element inet filter admin_hosts { 192.0.2.10, 192.0.2.11 }      # extend a set live
nft delete element inet filter admin_hosts { 192.0.2.11 }
nft flush chain inet filter input           # empty a chain (the base chain's policy then applies to everything)
nft flush ruleset                           # DESTRUCTIVE to filtering: removes every table including firewalld's and Docker's
nft list ruleset > /etc/nftables/backup-$(date +%F).nft   # dump in loadable form
nft -j list ruleset | jq                    # JSON
nft monitor                                 # print ruleset changes as they happen

Persistence on Fedora and RHEL: nftables.service runs nft -f /etc/sysconfig/nftables.conf, which by default includes files from /etc/nftables/. Put the ruleset in /etc/nftables/main.nft, reference it from /etc/sysconfig/nftables.conf with include "/etc/nftables/main.nft", and systemctl enable --now nftables. Disable firewalld first if the nftables file is meant to be the full policy; running both means two rulesets are evaluated.

A complete host ruleset#

A stateful ruleset for a server that accepts SSH from a management network, HTTP and HTTPS from anywhere, rate-limits new SSH connections, drops everything else and logs what it drops.

#!/usr/sbin/nft -f
# /etc/nftables/main.nft
flush ruleset

define MGMT_NET6 = { 2001:db8:1::/64 }

table inet filter {
    set admin_hosts {
        type ipv4_addr
        flags interval                       # allows CIDR ranges and ranges like 192.0.2.1-192.0.2.20
        elements = { 192.0.2.0/24 }
    }

    set ssh_meter {
        type ipv4_addr
        flags dynamic, timeout                # per-source rate counters, created on demand
        timeout 1m
    }

    set blocklist {
        type ipv4_addr
        flags dynamic, timeout                # elements expire; filled by the ssh_ratelimit chain below
        timeout 1h
    }

    chain input {
        type filter hook input priority filter; policy drop;

        iif lo accept                                     # loopback
        ct state established,related accept               # replies to our own connections and related ICMP
        ct state invalid drop                             # packets no connection tracking entry explains

        ip saddr @blocklist drop                          # anyone the rate limiter caught
        ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } limit rate 10/second accept
        ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept   # IPv6 does not work without ND

        tcp dport 22 ip saddr @admin_hosts ct state new jump ssh_ratelimit
        tcp dport 22 ip saddr @admin_hosts accept
        tcp dport 22 ip6 saddr $MGMT_NET6 accept
        tcp dport { 80, 443 } accept
        udp dport 443 accept                              # HTTP/3

        meta l4proto { tcp, udp } th dport 33434-33534 reject with icmpx type port-unreachable   # traceroute answers
        limit rate 5/second log prefix "nft-input-drop " flags all counter drop   # what the policy would drop; rate-limited so a flood cannot fill the journal
    }

    chain ssh_ratelimit {
        # more than 4 new connections in a minute from one address adds it to the blocklist for an hour
        add @ssh_meter { ip saddr limit rate over 4/minute burst 4 packets } add @blocklist { ip saddr } log prefix "nft-ssh-ratelimit " drop
        return
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
        # this host does not route; container and VM forwarding is added in the nat example below
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}

Notes on the constructs. ct state established,related accept near the top is what makes the ruleset stateful and cheap: only the first packet of a connection is evaluated by the rest of the chain. policy drop on input and forward with policy accept on output is the usual server posture. flags interval on a set permits CIDR elements; without it every element must be a single address. add @ssh_meter { ip saddr limit rate over ... } creates a per-source rate counter in a dynamic set and matches only when that source is over the rate, and the following add @blocklist { ip saddr } records the offender with the set’s default timeout; together they are the native replacement for fail2ban-style banning. inet tables see both IPv4 and IPv6, and ip saddr or ip6 saddr matches only the relevant family, so rules that mention neither apply to both. th dport matches the transport header port regardless of protocol. reject with icmpx type port-unreachable sends the correct ICMP or ICMPv6 message for the packet’s family. Anonymous sets in braces ({ 80, 443 }) are compiled into a single lookup.

Priorities: base chains at the same hook run in order of numeric priority, lowest first. filter is 0, mangle is -150, dstnat is -100, srcnat is 100, raw is -300, security is 50. firewalld and Docker’s iptables-nft tables also hook input and forward at priority 0; that is why a drop in any of them is final and an accept in one lets the next table decide.

NAT for VMs and containers#

A host with a bridge br0 for VMs on 10.0.0.0/24, masquerading out of eth0, forwarding port 443 to one VM, and a forward chain that lets the VMs out but not in.

table inet nat {
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        iifname "eth0" tcp dport 443 dnat ip to 10.0.0.10:8443          # inbound 443 to the web VM
        iifname "eth0" tcp dport 2222 dnat ip to 10.0.0.11:22           # SSH to the bastion VM on an alternate port
    }
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        ip saddr 10.0.0.0/24 oifname "eth0" masquerade                  # VMs share the host's public address
        ip saddr 10.0.0.0/24 ip daddr 10.0.0.10 tcp dport 8443 masquerade   # hairpin: VMs reaching the public port get replies via the host
    }
}

table inet filter {
    chain forward {
        type filter hook forward priority filter; policy drop;
        ct state established,related accept
        ct state invalid drop
        iifname "br0" oifname "eth0" accept                                 # VMs to the internet
        iifname "eth0" oifname "br0" ct status dnat accept                  # only inbound traffic that a dnat rule chose
        iifname "br0" oifname "br0" accept                                  # VM to VM on the bridge (or drop for isolation)
        log prefix "nft-forward-drop " limit rate 5/second counter drop
    }
}

ct status dnat matches only connections a DNAT rule rewrote, which is tighter than opening tcp dport 8443 on the forward chain. sysctl -w net.ipv4.ip_forward=1 (and net.ipv6.conf.all.forwarding=1) must be set separately and persisted in /etc/sysctl.d/; nftables does not enable forwarding for you. For bridges, net.bridge.bridge-nf-call-iptables decides whether bridged traffic between VMs on the same bridge is even seen by the inet forward chain (Docker and libvirt set it to 1); when it is 0 the br0 to br0 rule is irrelevant.

Maps turn repetitive DNAT rules into one lookup:

table inet nat {
    map port_forwards {
        type inet_service : ipv4_addr . inet_service
        elements = { 443 : 10.0.0.10 . 8443, 2222 : 10.0.0.11 . 22, 8080 : 10.0.0.12 . 80 }
    }
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        iifname "eth0" dnat ip to tcp dport map @port_forwards
    }
}

Sets, verdict maps and counters#

nft add set inet filter countries { type ipv4_addr\; flags interval\; }
nft add element inet filter countries { 198.51.100.0/24, 203.0.113.0/24 }
nft add rule inet filter input ip saddr @countries drop

# verdict map: one rule, different action per port
nft add rule inet filter input tcp dport vmap { 22 : jump ssh_chain, 80 : accept, 443 : accept, 3306 : drop }

# per-interface dispatch
nft add rule inet filter input iif vmap { "lo" : accept, "eth0" : jump wan_input, "br0" : jump lan_input }

# named counters that survive rule edits
nft add counter inet filter ssh_accepted
nft add rule inet filter input tcp dport 22 counter name ssh_accepted accept
nft list counters

# concatenations: match address and port together
nft add set inet filter allowed { type ipv4_addr . inet_service\; }
nft add element inet filter allowed { 192.0.2.10 . 5432, 192.0.2.11 . 5432 }
nft add rule inet filter input ip saddr . tcp dport @allowed accept

# reset counters
nft reset counters table inet filter

Set types: ipv4_addr, ipv6_addr, ether_addr, inet_proto, inet_service (port), mark, ifname. Flags: interval for ranges and prefixes, timeout for expiring elements, dynamic for elements added from rules, constant for read-only sets the kernel can optimise.

Tracing a packet#

nftrace marks packets so nft monitor trace prints every rule they hit in every table, which is the fastest way to find out which rule (and whose) is dropping something.

nft insert rule inet filter input position 0 tcp dport 8443 meta nftrace set 1      # mark matching packets at the very top
nft monitor trace                                                                   # in another terminal, then send a test packet
nft delete rule inet filter input handle N                                          # remove the trace rule afterwards; find N with nft list ruleset -a

Output shows trace id ... inet filter input packet: ..., then rule ... (verdict accept) lines per rule, then policy drop or the verdict that ended it, per table. A verdict drop in ip filter DOCKER-USER while your own table said accept is the classic result.

iptables and translation#

iptables on a modern system is iptables-nft; iptables -V prints (nf_tables). It still works, still uses the filter, nat and mangle table names in the ip family, and is what Docker, libvirt and many installers speak. iptables-legacy uses the old x_tables kernel modules, and rules in the two backends do not see each other, so a system with both installed can have rules in three places. update-alternatives --display iptables (Debian) or alternatives --display iptables (RHEL) shows which one the iptables name resolves to.

iptables -L -n -v --line-numbers                     # filter table, numeric, counters
iptables -t nat -L -n -v                             # nat table
iptables -S                                          # rules as add commands
iptables-save > /root/iptables-$(date +%F).rules     # dump all tables in restorable form
iptables-restore < /root/iptables.rules             # atomic load
iptables -A INPUT -p tcp --dport 22 -s 192.0.2.0/24 -j ACCEPT
iptables -I INPUT 1 -i lo -j ACCEPT                  # insert at position 1
iptables -D INPUT 3                                  # delete rule 3
iptables -P INPUT DROP                               # default policy
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.10:8443
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -m limit --limit 4/min --limit-burst 4 -j ACCEPT
iptables -A INPUT -j LOG --log-prefix 'ipt-drop ' --log-level 4

iptables-translate and iptables-restore-translate print the nftables equivalent of a rule or a whole saved ruleset. The output is a starting point: it lands in per-table ip and ip6 tables rather than one inet table, and hand-merging into a single file is worth the effort.

iptables-translate -A INPUT -p tcp --dport 22 -s 192.0.2.0/24 -j ACCEPT
# nft add rule ip filter INPUT ip saddr 192.0.2.0/24 tcp dport 22 counter accept
iptables-translate -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
# nft add rule ip nat POSTROUTING oifname "eth0" ip saddr 10.0.0.0/24 counter masquerade
iptables-restore-translate -f /root/iptables.rules > /etc/nftables/translated.nft
nft -c -f /etc/nftables/translated.nft
iptablesnftables
-A INPUTadd rule inet filter input
-I INPUT 1insert rule inet filter input
-p tcp --dport 22tcp dport 22
-s 192.0.2.0/24ip saddr 192.0.2.0/24
-i eth0 / -o eth0iifname "eth0" / oifname "eth0" (iif for an index, faster but breaks if the interface is recreated)
-m conntrack --ctstate ESTABLISHED,RELATEDct state established,related
-m multiport --dports 80,443tcp dport { 80, 443 }
-m set --match-set x srcip saddr @x
-m limit --limit 4/minlimit rate 4/minute
-j LOG --log-prefix "x "log prefix "x "
-j REJECT --reject-with icmp-port-unreachablereject with icmp type port-unreachable
-j MASQUERADEmasquerade
-j DNAT --to 10.0.0.10:8443dnat ip to 10.0.0.10:8443
-j SNAT --to 192.0.2.1snat ip to 192.0.2.1
-j REDIRECT --to-ports 8080redirect to :8080
-m mark --mark 1 / -j MARK --set-mark 1meta mark 1 / meta mark set 1
-m comment --comment "x"comment "x"
-P INPUT DROPpolicy drop in the chain definition
iptables-save / iptables-restorenft list ruleset / nft -f

Common patterns#

SSH from one subnet only, with the rest rejected so legitimate clients fail fast:

# firewalld
firewall-cmd --permanent --zone=public --remove-service=ssh
firewall-cmd --permanent --zone=public --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 service name=ssh accept'
firewall-cmd --permanent --zone=public --add-rich-rule='rule service name=ssh reject'
firewall-cmd --reload
# nftables
nft add rule inet filter input ip saddr 192.0.2.0/24 tcp dport 22 accept
nft add rule inet filter input tcp dport 22 reject with tcp reset

Rate-limit new connections to a service and ban repeat offenders (nftables version is in the complete ruleset above):

firewall-cmd --permanent --add-rich-rule='rule service name=ssh limit value=4/m accept'         # firewalld: 4 new per minute, rest hit the zone target
nft add rule inet filter input tcp dport 22 ct state new limit rate over 4/minute burst 4 packets drop   # nftables, without the ban set

Allow a service only from the container or VM network and the host itself:

nft add rule inet filter input ip saddr { 127.0.0.1, 10.0.0.0/24, 10.88.0.0/16 } tcp dport 5432 accept
firewall-cmd --permanent --zone=trusted --add-source=10.88.0.0/16       # firewalld: trust the Podman network entirely

Redirect a privileged port to an unprivileged one so a service can run without CAP_NET_BIND_SERVICE:

firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
nft add rule inet nat prerouting tcp dport 443 redirect to :8443
nft add rule inet nat output oif lo tcp dport 443 redirect to :8443     # local clients too; needs an output nat chain

Block outbound except what is needed, for a hardened host or a build box:

table inet filter {
    chain output {
        type filter hook output priority filter; policy drop;
        oif lo accept
        ct state established,related accept
        udp dport 53 ip daddr { 192.0.2.53, 192.0.2.54 } accept        # our resolvers only
        tcp dport { 80, 443 } accept
        udp dport 123 accept                                          # NTP
        log prefix "nft-output-drop " counter drop
    }
}

Docker, Podman and libvirt#

Docker writes iptables-nft rules in the ip filter, ip nat tables (DOCKER, DOCKER-USER, DOCKER-FORWARD, DOCKER-ISOLATION-STAGE-* chains), publishes ports with DNAT in PREROUTING, and sets the FORWARD policy to DROP while accepting its own bridges. A port published with -p 8080:80 is reachable from every interface regardless of firewalld, because Docker’s DNAT runs at prerouting and its FORWARD accept happens in its own table. Since Docker 20.10 on firewalld hosts, Docker also adds its bridge interfaces to a docker firewalld zone, which makes firewalld aware of them but does not restrict published ports.

The supported place for your own restrictions is the DOCKER-USER chain, which Docker jumps to first in FORWARD and never flushes:

iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.0/24 -m conntrack --ctdir ORIGINAL -j DROP   # published ports reachable only from the management net
iptables -I DOCKER-USER -i eth0 -p tcp --dport 8080 -j DROP                               # or block one published port from outside
iptables -L DOCKER-USER -n -v

Alternatives: bind published ports to loopback (-p 127.0.0.1:8080:80) and put a reverse proxy in front, set "iptables": false in /etc/docker/daemon.json and manage NAT yourself (breaks port publishing until you write the rules), or set "ip": "127.0.0.1" as the default bind address. Docker 28 also honours "ip6tables" and tightens the default so containers are no longer reachable on unpublished ports from other hosts on the LAN. See Docker.

Podman with netavark (default since Podman 4.0) writes nftables rules in its own netavark table when the nftables firewall driver is selected (the default on Fedora 41+), or iptables-nft rules in a NETAVARK-* set of chains otherwise. Rootless Podman uses pasta or slirp4netns and no kernel rules at all; published ports are held open by the user process, so firewalld rules on the host port are the only filter. Rootful Podman on a firewalld host adds its bridge to the trusted zone by default (firewalld driver in containers.conf). See Podman.

libvirt’s default NAT network writes iptables-nft rules (LIBVIRT_INP, LIBVIRT_FWO, LIBVIRT_FWI, LIBVIRT_PRT chains) or, with the nftables firewall backend in libvirt 10.4+, a libvirt_network nftables table. On a firewalld host it puts virbr0 into the libvirt zone. Forwarding a port to a VM on the default network needs a rule in the forward path that libvirt does not provide; a libvirt network hook script or an nft rule in your own table at dstnat priority does it. See libvirt.

The rule for all three: check nft list ruleset before assuming a firewalld rule is what governs a container or VM port, and put host policy in DOCKER-USER, a policy object, or your own nftables table rather than editing the tool’s generated chains.

Oneliners#

# What is actually open: listening sockets against firewall rules
ss -tulpnH | awk '{print $1, $5}' | sort -u; firewall-cmd --list-ports --list-services

# Runtime and permanent firewalld config differ
diff <(firewall-cmd --list-all) <(firewall-cmd --permanent --list-all)

# Open a port for 10 minutes while testing, then it closes itself
firewall-cmd --add-port=9090/tcp --timeout=600

# Which zone will handle a source address
firewall-cmd --get-zone-of-source=192.0.2.10 || echo 'interface zone applies'

# Move all interfaces from public to drop, safely (source-zone the management net first)
firewall-cmd --permanent --zone=trusted --add-source=192.0.2.0/24 && firewall-cmd --permanent --set-default-zone=drop && firewall-cmd --reload

# Every service firewalld knows that maps to a given port
for s in $(firewall-cmd --get-services); do firewall-cmd --info-service="$s" | grep -q 'ports:.*\b8080/tcp' && echo "$s"; done

# Tail denied packets with a readable format
journalctl -kf -g 'FINAL_(REJECT|DROP)|nft-.*-drop' | grep -oE 'IN=\S+|SRC=\S+|DST=\S+|PROTO=\S+|DPT=\S+' | paste - - - - -

# Top sources being dropped in the last hour
journalctl -k --since -1h -g 'FINAL_|nft-.*-drop' | grep -oP 'SRC=\K\S+' | sort | uniq -c | sort -rn | head

# Dump the live nftables ruleset with counters and handles to a file
nft -a list ruleset > /root/nft-$(date +%FT%H%M).txt

# Reload an nftables file only if it validates
nft -c -f /etc/nftables/main.nft && nft -f /etc/nftables/main.nft

# Try a new ruleset and roll back automatically in 60 s unless cancelled (remote-safe)
nft list ruleset > /root/nft-rollback.nft; (sleep 60 && nft -f /root/nft-rollback.nft) & rb=$!; nft -f /etc/nftables/main.nft; echo "kill $rb to keep"

# Counters for the rules in one chain, sorted by packets
nft -a list chain inet filter input | grep -oE 'counter packets [0-9]+ bytes [0-9]+.*' | sort -k3,3nr | head

# Add an address to a blocklist set for an hour (dynamic set with timeout)
nft add element inet filter blocklist { 203.0.113.7 timeout 1h }

# List the contents of a set with expiry
nft list set inet filter blocklist

# Which table dropped a packet: trace one destination port
nft insert rule inet filter input position 0 tcp dport 8443 meta nftrace set 1; timeout 20 nft monitor trace

# Is IP forwarding on (needed for NAT and containers)
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding

# Connection tracking table size and usage (drops appear as 'nf_conntrack: table full')
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max

# Live connection tracking entries to a port
conntrack -L -p tcp --dport 443 2>/dev/null | head    # conntrack-tools package

# Which backend the iptables command uses
iptables -V

# Docker: block a published port from anything but one subnet
iptables -I DOCKER-USER -i eth0 -p tcp --dport 8080 ! -s 192.0.2.0/24 -j DROP

# Docker: show the DNAT rules it wrote
iptables -t nat -S DOCKER

# Podman: the nftables table netavark manages
nft list table inet netavark 2>/dev/null || iptables -S | grep -i netavark

# Test a rule from outside without nmap
timeout 3 bash -c 'echo > /dev/tcp/192.0.2.1/8443' && echo open || echo 'closed or filtered'

# Check whether a port is filtered or closed (RST versus silence); see the nmap page for more
nc -zv -w3 192.0.2.1 8443

# Check for a second, legacy ruleset lurking
iptables-legacy -S 2>/dev/null | grep -v '^-P' || echo 'no legacy rules'

# Persist a firewalld set of changes as an idempotent script
firewall-cmd --permanent --list-all | sed 's/^/# /'; firewall-cmd --permanent --list-rich-rules | sed "s/.*/firewall-cmd --permanent --add-rich-rule='&'/"

Scripts#

Audit a host’s exposure: listening sockets against firewalld’s open ports and services, flagging anything listening on all interfaces that no rule allows. Read-only.

#!/usr/bin/env bash
# usage: exposure-audit.sh    (root, firewalld running)
set -euo pipefail
zone=$(firewall-cmd --get-default-zone)
allowed=$(firewall-cmd --zone="$zone" --list-ports | tr ' ' '\n')
for s in $(firewall-cmd --zone="$zone" --list-services); do
  allowed+=$'\n'$(firewall-cmd --info-service="$s" | awk '/ports:/ {for (i = 2; i <= NF; i++) print $i}')
done
printf 'default zone: %s\n\n%-6s %-22s %-8s %s\n' "$zone" PROTO LISTEN ALLOWED PROCESS
ss -tulpnH | while read -r proto _ _ local _ proc; do
  port=${local##*:}; addr=${local%:*}
  case $addr in 127.*|\[::1\]) continue ;; esac                                  # loopback is not exposed
  p=${proto%6}                                                                   # tcp6 -> tcp
  if grep -qx "$port/$p" <<< "$allowed"; then ok=yes; else ok=NO; fi
  printf '%-6s %-22s %-8s %s\n' "$proto" "$local" "$ok" "$(grep -oP 'users:\(\("\K[^"]+' <<< "$proc" | head -1)"
done | sort -k3

Generate a firewalld configuration from a small declarative file, so a host’s policy lives in version control rather than in the order someone typed commands. Applies to the permanent configuration and reloads.

#!/usr/bin/env bash
# usage: apply-firewall.sh policy.conf
# policy.conf lines:  zone public service https | zone public port 8443/tcp | zone trusted source 192.0.2.0/24 | rich public rule ... | masquerade public
set -euo pipefail
conf=${1:?policy file required}
run() { printf '+ firewall-cmd --permanent %s\n' "$*"; firewall-cmd --permanent "$@" >/dev/null; }

# reset the zones the file mentions to their shipped defaults so removed lines really go away
for z in $(awk '$1 == "zone" || $1 == "rich" || $1 == "masquerade" {print $2}' "$conf" | sort -u); do
  run --load-zone-defaults="$z" 2>/dev/null || echo "zone $z has no defaults to load (custom zone), continuing" >&2
done
while read -r kind zone what value; do
  case $kind in
    ''|'#'*) continue ;;
    zone) run --zone="$zone" --add-"$what"="$value" ;;              # service, port, source, interface
    rich) run --zone="$zone" --add-rich-rule="$what $value" ;;
    masquerade) run --zone="$zone" --add-masquerade ;;
    *) echo "unknown line: $kind $zone $what $value" >&2; exit 2 ;;
  esac
done < "$conf"
firewall-cmd --reload && firewall-cmd --list-all-zones | grep -B1 -A12 'active'

Block-list synchroniser: loads a list of CIDRs from a file into an nftables set atomically, so a feed of bad addresses can be refreshed from a timer without touching any rule.

#!/usr/bin/env bash
# usage: sync-blocklist.sh /etc/nftables/blocklist.txt   (one CIDR or address per line; # comments)
set -euo pipefail
src=${1:?list file required}
tmp=$(mktemp); trap 'rm -f -- "$tmp"' EXIT

mapfile -t cidrs < <(grep -Ev '^\s*(#|$)' "$src" | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}(/[0-9]{1,2})?$')
(( ${#cidrs[@]} )) || { echo 'no valid IPv4 entries' >&2; exit 1; }
{
  printf 'table inet filter {\n  set blocklist_v4 {\n    type ipv4_addr\n    flags interval\n  }\n}\n'   # ensure the set exists; a no-op if it does
  printf 'flush set inet filter blocklist_v4\n'
  printf 'add element inet filter blocklist_v4 { %s }\n' "$(IFS=,; echo "${cidrs[*]}")"
} > "$tmp"
nft -c -f "$tmp" && nft -f "$tmp"                                    # one transaction: flush and refill
printf 'loaded %d entries into inet filter blocklist_v4\n' "${#cidrs[@]}"
nft list chain inet filter input | grep -q '@blocklist_v4' || echo 'note: no rule references @blocklist_v4 yet' >&2

Troubleshooting#

SymptomCauseFix
Rule added, still blockedAdded to runtime or permanent but not both; or wrong zone for that interfacefirewall-cmd --list-all versus --permanent --list-all; --get-zone-of-interface=eth0
Rule works until reboot or --reloadRuntime onlyfirewall-cmd --runtime-to-permanent
Port open in firewalld, connection refusedNothing listening, or bound to 127.0.0.1ss -tulpn | grep :PORT; fix the service’s bind address
Port open in firewalld, connection times outAnother table drops it (Docker FORWARD, a custom nft table), or an upstream network firewallnft list ruleset, nft monitor trace with an nftrace rule, tcpdump -ni eth0 port PORT to confirm arrival
Container’s published port reachable despite firewalld rulesDocker DNATs in prerouting and accepts in its own FORWARD chainRestrict in DOCKER-USER, bind to 127.0.0.1, or use a reverse proxy
VMs or containers cannot reach the internetForwarding off, no masquerade, or forward chain policy dropsysctl net.ipv4.ip_forward, nft list table inet nat, firewall-cmd --list-all --zone=public | grep masquerade
nft -f fails with Error: Could not process rule: No such file or directoryReferencing a table, chain or set that does not exist yet, or the wrong familyDeclare the table first in the same file; check inet versus ip
nft -f fails with Operation not supportedKernel lacks the feature (old kernel, missing module) or flags interval needed for CIDR elementsuname -r, add flags interval, or modprobe nft_*
Locked out after a reloadRemoved SSH access or changed default zone remotelyConsole or provider recovery shell; use --timeout or the rollback oneliner next time
Locked out after nft -fRuleset with policy drop and no established-state or SSH ruleConsole; always include ct state established,related accept and test with a timed rollback
firewalld logs FINAL_REJECT for traffic you allowedWrong zone matched (source-based zone overrides interface zone)firewall-cmd --get-active-zones, check sources on each
Rich rule not removingText must match exactly, including family=firewall-cmd --list-rich-rules and copy the line verbatim
firewall-cmd says INVALID_SERVICEService not predefinedfirewall-cmd --get-services, define with --new-service, or use --add-port
iptables shows nothing but traffic is filteredRules are in nftables native tables, or in iptables-legacynft list ruleset, iptables-legacy -S
nf_conntrack: table full, dropping packet in dmesgToo many tracked connectionsRaise net.netfilter.nf_conntrack_max, lower nf_conntrack_tcp_timeout_established, or notrack bulk flows
Asymmetric or hairpin NAT failsReply path bypasses the NAT host, or no masquerade for LAN clients hitting the public addressAdd the hairpin masquerade rule; check routes with ip route get
ICMP or traceroute broken after hardeningICMP types dropped, IPv6 ND blockedAllow the ICMP types in the ruleset above; IPv6 needs nd-neighbor-solicit and nd-neighbor-advert
Rules vanish after systemctl restart firewalldCustom nft rules were added to firewalld’s tableKeep your rules in your own table; firewalld only flushes its own
Rule matches nothing (counter packets 0)Rule below a terminating rule, wrong interface name, wrong familynft -a list chain, move with insert, use iifname not iif after interfaces are recreated

Further reading#