# 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.

Canonical: https://www.wiki.jodisand.me/firewall/
Reviewed: 2026-09-24
Related: [iproute2](https://www.wiki.jodisand.me/iproute2/index.md), [SSH](https://www.wiki.jodisand.me/ssh/index.md), [Docker](https://www.wiki.jodisand.me/docker/index.md), [Podman](https://www.wiki.jodisand.me/podman/index.md), [tcpdump and Wireshark](https://www.wiki.jodisand.me/tcpdump/index.md), [systemd](https://www.wiki.jodisand.me/systemd/index.md)


## Cheatsheet

| Task | Command |
| --- | --- |
| Is firewalld running, which zones are active | `firewall-cmd --state; firewall-cmd --get-active-zones` |
| Everything in the default zone | `firewall-cmd --list-all` |
| Open a service now and permanently | `firewall-cmd --add-service=https --permanent && firewall-cmd --reload` |
| Open a port | `firewall-cmd --add-port=8443/tcp --permanent` |
| Remove it | `firewall-cmd --remove-port=8443/tcp --permanent` |
| Apply permanent config | `firewall-cmd --reload` |
| Copy runtime to permanent | `firewall-cmd --runtime-to-permanent` |
| Allow SSH from one subnet only | `firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.0.2.0/24 service name=ssh accept' --permanent` |
| Put an interface in a zone | `firewall-cmd --zone=internal --change-interface=eth1 --permanent` |
| NAT for a private network | `firewall-cmd --zone=public --add-masquerade --permanent` |
| Forward a port | `firewall-cmd --add-forward-port=port=80:proto=tcp:toaddr=192.0.2.10:toport=8080 --permanent` |
| Log denied packets | `firewall-cmd --set-log-denied=all` |
| The nftables rules firewalld generated | `nft list ruleset` |
| One nftables table | `nft list table inet filter` |
| Load an nftables file atomically | `nft -f /etc/nftables/main.nft` |
| Check a file without loading | `nft -c -f /etc/nftables/main.nft` |
| Flush every nftables rule (drops all filtering) | `nft flush ruleset` |
| Counters on rules | `nft list ruleset -a` (handles), `nft list chain inet filter input` |
| Translate an iptables rule | `iptables-translate -A INPUT -p tcp --dport 22 -j ACCEPT` |
| Legacy view of the rules | `iptables -L -n -v --line-numbers` |
| Which ports are listening | `ss -tulpn` |
| Watch what a rule matches | `nft 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](https://firewalld.org/documentation/), the [nftables wiki](https://wiki.nftables.org/) and the [Red Hat networking guide](https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/9/html/configuring_firewalls_and_packet_filters/index).

## 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`.

```sh
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`.

```sh
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/`:

```sh
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.

```sh
# 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.

```sh
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.

```sh
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

```sh
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.

```sh
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.

```sh
#!/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.

```sh
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:

```sh
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

```sh
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.

```sh
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.

```sh
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.

```sh
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
```

| iptables | nftables |
| --- | --- |
| `-A INPUT` | `add rule inet filter input` |
| `-I INPUT 1` | `insert rule inet filter input` |
| `-p tcp --dport 22` | `tcp dport 22` |
| `-s 192.0.2.0/24` | `ip saddr 192.0.2.0/24` |
| `-i eth0` / `-o eth0` | `iifname "eth0"` / `oifname "eth0"` (`iif` for an index, faster but breaks if the interface is recreated) |
| `-m conntrack --ctstate ESTABLISHED,RELATED` | `ct state established,related` |
| `-m multiport --dports 80,443` | `tcp dport { 80, 443 }` |
| `-m set --match-set x src` | `ip saddr @x` |
| `-m limit --limit 4/min` | `limit rate 4/minute` |
| `-j LOG --log-prefix "x "` | `log prefix "x "` |
| `-j REJECT --reject-with icmp-port-unreachable` | `reject with icmp type port-unreachable` |
| `-j MASQUERADE` | `masquerade` |
| `-j DNAT --to 10.0.0.10:8443` | `dnat ip to 10.0.0.10:8443` |
| `-j SNAT --to 192.0.2.1` | `snat ip to 192.0.2.1` |
| `-j REDIRECT --to-ports 8080` | `redirect to :8080` |
| `-m mark --mark 1` / `-j MARK --set-mark 1` | `meta mark 1` / `meta mark set 1` |
| `-m comment --comment "x"` | `comment "x"` |
| `-P INPUT DROP` | `policy drop` in the chain definition |
| `iptables-save` / `iptables-restore` | `nft list ruleset` / `nft -f` |

## Common patterns

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

```sh
# 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):

```sh
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:

```sh
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`:

```sh
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:

```sh
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:

```sh
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](https://www.wiki.jodisand.me/docker/#networks-and-published-ports).

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](https://www.wiki.jodisand.me/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](https://www.wiki.jodisand.me/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

```sh
# 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.

```sh
#!/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.

```sh
#!/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.

```sh
#!/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

| Symptom | Cause | Fix |
| --- | --- | --- |
| Rule added, still blocked | Added to runtime or permanent but not both; or wrong zone for that interface | `firewall-cmd --list-all` versus `--permanent --list-all`; `--get-zone-of-interface=eth0` |
| Rule works until reboot or `--reload` | Runtime only | `firewall-cmd --runtime-to-permanent` |
| Port open in firewalld, connection refused | Nothing listening, or bound to `127.0.0.1` | `ss -tulpn \| grep :PORT`; fix the service's bind address |
| Port open in firewalld, connection times out | Another table drops it (Docker `FORWARD`, a custom nft table), or an upstream network firewall | `nft list ruleset`, `nft monitor trace` with an `nftrace` rule, `tcpdump -ni eth0 port PORT` to confirm arrival |
| Container's published port reachable despite firewalld rules | Docker DNATs in `prerouting` and accepts in its own `FORWARD` chain | Restrict in `DOCKER-USER`, bind to `127.0.0.1`, or use a reverse proxy |
| VMs or containers cannot reach the internet | Forwarding off, no masquerade, or forward chain policy drop | `sysctl 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 directory` | Referencing a table, chain or set that does not exist yet, or the wrong family | Declare the table first in the same file; check `inet` versus `ip` |
| `nft -f` fails with `Operation not supported` | Kernel lacks the feature (old kernel, missing module) or `flags interval` needed for CIDR elements | `uname -r`, add `flags interval`, or `modprobe nft_*` |
| Locked out after a reload | Removed SSH access or changed default zone remotely | Console or provider recovery shell; use `--timeout` or the rollback oneliner next time |
| Locked out after `nft -f` | Ruleset with `policy drop` and no established-state or SSH rule | Console; always include `ct state established,related accept` and test with a timed rollback |
| firewalld logs `FINAL_REJECT` for traffic you allowed | Wrong zone matched (source-based zone overrides interface zone) | `firewall-cmd --get-active-zones`, check `sources` on each |
| Rich rule not removing | Text must match exactly, including `family=` | `firewall-cmd --list-rich-rules` and copy the line verbatim |
| `firewall-cmd` says `INVALID_SERVICE` | Service not predefined | `firewall-cmd --get-services`, define with `--new-service`, or use `--add-port` |
| `iptables` shows nothing but traffic is filtered | Rules are in nftables native tables, or in `iptables-legacy` | `nft list ruleset`, `iptables-legacy -S` |
| `nf_conntrack: table full, dropping packet` in `dmesg` | Too many tracked connections | Raise `net.netfilter.nf_conntrack_max`, lower `nf_conntrack_tcp_timeout_established`, or `notrack` bulk flows |
| Asymmetric or hairpin NAT fails | Reply path bypasses the NAT host, or no masquerade for LAN clients hitting the public address | Add the hairpin masquerade rule; check routes with `ip route get` |
| ICMP or traceroute broken after hardening | ICMP types dropped, IPv6 ND blocked | Allow the ICMP types in the ruleset above; IPv6 needs `nd-neighbor-solicit` and `nd-neighbor-advert` |
| Rules vanish after `systemctl restart firewalld` | Custom `nft` rules were added to firewalld's table | Keep 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 family | `nft -a list chain`, move with `insert`, use `iifname` not `iif` after interfaces are recreated |

## Further reading

- [firewalld documentation](https://firewalld.org/documentation/): zones, policies, rich language and the man pages `firewall-cmd(1)`, `firewalld.richlanguage(5)`, `firewalld.policy(5)`.
- [nftables wiki](https://wiki.nftables.org/wiki-nftables/index.php/Main_Page): quick reference, examples, sets, maps and the netfilter hooks diagram.
- [nft(8) man page](https://www.netfilter.org/projects/nftables/manpage.html): the complete grammar, expressions, statements and data types.
- [Red Hat: Configuring firewalls and packet filters](https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/9/html/configuring_firewalls_and_packet_filters/index): firewalld and nftables on RHEL 9, including migration from iptables.
- [Moving from iptables to nftables](https://wiki.nftables.org/wiki-nftables/index.php/Moving_from_iptables_to_nftables): the translation tools and the differences that matter.
- [Docker: Packet filtering and firewalls](https://docs.docker.com/engine/network/packet-filtering-firewalls/): `DOCKER-USER`, firewalld integration and the daemon options.


