Firewalld Configuration for DIY Linux Home Router Gateway

This is part of a series of posts about building your own Linux home router using systemd-networkd. The posts are organized as follows:

  1. Connecting to CenturyLink using PPPoE and systemd-networkd
  2. Network Architecture and VLAN configuration
  3. Firewalld policy-based access control between zones

This guide assumes you have a working knowledge of Linux, networking and routing concepts. This guide is built on Arch, but should be roughly translatable to other Linux distributions which have Systemd Networkd, Firewalld and pppd packaged at a relatively recent version.

In this post, we’ll cover how to setup and configure firewalld as your inter-zone policy-based access controlled firewall.

Background and Context

This is my first time using firewalld, and I was hesitant to pick it up after being spoiled by the simplicity of BSD’s pf (packet filter). pf makes building firewalls easy because it’s model assumes that rules are attached to individual interfaces by default whereas iptables/nftables rules are global by default. This makes writing firewalls on linux complicated because the commands and configuration you end up writing gets really hairy really fast.

Enter firewalld. Firewalld is a wrapper around various backends (notably nftables and iptables) to enable higher-level concepts like zones, services and policy-based access controls. After my experiences interacting with firewalls using iptables directly, I wasn’t too keen on going back to linux for my home router after using BSD with pf for some time.

After some experimentation and playing around, I’m happy to report that although firewalld’s configuration isn’t nearly as clean and simple as pf, it certainly stands up to the job and is relatively pleasant to work with, particularly compared to directly configuring nftables and iptables themselves.


A core concept that firewalld adds on top of iptables/nftables is the “Zone”. A zone can be defined by an interface, multiple interfaces, or a range of source IP addresses. For simplicity, I’ve only used zones as interfaces. Each zone may have it’s own set of rules specific to that zone. Firewalld has another concept called “Policies”. The intent of policies is to define the rules applied across multiple zones. Although policies could just as easily applied to one zone, I’m not sure that makes sense in practice since you could apply the rules directly to the zone itself.

In the above example, I’ve color-coded the various zones I’ve defined. In this case, one zone per subnet, as well as the internet-facing zone which contains all the internet-facing interfaces. enp3s0 has no zone, meaning it has the default zone’s ruleset applied to it (in my case, the firewalld provided public zone).

enp3s0 is physically connected to all the managed switches in my house (mostly running OpenWRT).

Generally, when thinking about where to apply rules, I found the following makes the most sense:

  1. Apply rules to zones if the traffic that rule applies to is bound for the host itself (in this case, the router), like ssh access (a service in firewalld is an abstraction on rules).
  2. Apply rules to policies if the traffic that rule applies to is transiting more than one zone. For example, I configured a default-deny rule for all interfaces, but still want to enable them all to access the internet, so that’s done via a policy.


I’m gonna be honest, the firewall-cmd cli is not my favorite. Instead of building sub-commands, everything is a flag. There’s a mix of --get-* and --list-* flags that I can never keep straight. Some flags can be mixed while other’s cant. It sometimes doesn’t error when it should (like when a flag gets ignored). So for this section, I’ll just be referencing the configuration files directly, which live under /etc/firewalld.

First, install and enable firewalld. Ensure there aren’t any other firewalls installed on your host before enabling it, otherwise you could be in pain soon.

pacman -S firewalld
systemctl enable --now firewalld

Firewalld comes with a default set of zones, but I’ll include mine here for reference:

cat >/etc/firewalld/zones/servers.xml<<EOF
<?xml version="1.0" encoding="utf-8"?>
  <description>Server Network</description>
  <service name="ssh"/>
  <service name="dhcpv6-client"/>
  <service name="dhcp"/>
  <interface name="enp3s0.10"/>

cat >/etc/firewalld/zones/clients.xml<<EOF
<?xml version="1.0" encoding="utf-8"?>
  <description>Client Network</description>
  <service name="ssh"/>
  <service name="mdns"/>
  <service name="dhcpv6-client"/>
  <service name="dhcp"/>
  <interface name="enp3s0.20"/>

cat >/etc/firewalld/zones/iot.xml<<EOF
<?xml version="1.0" encoding="utf-8"?>
  <description>IoT Network</description>
  <service name="dhcpv6-client"/>
  <service name="dhcp"/>
  <interface name="enp3s0.30"/>

cat >/etc/firewalld/zones/inet.xml<<EOF
<?xml version="1.0" encoding="utf-8"?>
  <description>A Series of Tubes...</description>
  <service name="ssh"/>
  <service name="http"/>
  <service name="https"/>
  <interface name="enp2s0"/>
  <interface name="enp2s0.201"/>
  <interface name="ppp0"/>

After modifying this to suit your needs, you can reload the firewall config using the command and it will then become active.

firewall-cmd --reload

In the above examples, you can see that I’ve enabled dhcp on my internal networks and ssh on all the interfaces except the IoT network. This is great, but we still won’t be able to access anything across zones with default-deny rules configured for each zone. For that, we need to introduce policies.

The below policy allows clients from every internal network to initiate connections to the internet, but not the other way around. It depends on the zones having the forward property set on the zones themselves (see above), but is otherwise fairly simple.

The policy also includes a special rule to fix some connections on CenturyLink called MTU Clamping. We won’t get into the weeds about what that is here, but it’s a good example of how policies can help enable simpler management of your firewall rules since every outbound connection will need the same rule applied for the connection to be fully functional.

cat >/etc/firewalld/policies/internet-access.xml<<EOF
<?xml version="1.0" encoding="utf-8"?>
<policy target="ACCEPT">
    <tcp-mss-clamp value="pmtu"/>
  <ingress-zone name="clients"/>
  <ingress-zone name="servers"/>
  <ingress-zone name="iot"/>
  <egress-zone name="inet"/>

Finally, you’ll need to do another firewall reload before this becomes active.

firewall-cmd --reload

You can test the connection from one of the subnets with a good ol simple ping:

# (From my laptop)
$ ping 1.1
PING 1.1 ( 56(84) bytes of data.
64 bytes from icmp_seq=1 ttl=59 time=3.32 ms
--- 1.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 3.318/3.318/3.318/0.000 ms

If this doesn’t work, I found it very useful to debug using the unfortunately named nftables command: nft. It will only work if nftables is configured as your backend for firewalld, but it’s the default on Arch Linux already (nftables replaces iptables).

nft list ruleset

The output from that command is massively more readable than iptables-save which was a huge relief to me after years in the past debugging iptables chains.

I’ve also got some other access control policies defined for inter-subnet communication on an ad-hoc basis, but I’ll leave that as an exercise for the reader.

I hope this tutorial was helpful! That concludes my series on building a SOHO router on Linux. Linux tools have come a long way since a few years back, and I’m glad I no longer have to bifurcate my knowledge of command flags between BSD and Linux. For now, I can delegate that process of remembering to the thing which handles it best: the docs and search engines.

Powered by Buttondown.