All systems operational Your IP: 216.73.216.190 info@cloudhosting.lv +371 66 66 29 69 Client area

← All questions

AlmaLinux 9 firewall: firewalld and nftables

A fresh AlmaLinux 9 server boots with firewalld running and almost nothing open, and the first reflex of many admins is to switch it off so the application starts answering. On a public IP address that reflex costs more than it saves. Opening exactly what you need takes about a minute, and doing it over SSH without locking yourself out takes one extra command.

What actually runs on AlmaLinux 9

Three layers, regularly confused. firewalld 1.x is the policy daemon you edit, nftables is what your policy compiles into, netfilter is the kernel hook that enforces it. On EL9 the default backend is nftables (FirewallBackend=nftables in /etc/firewalld/firewalld.conf), and the same file still offers an iptables backend that is deprecated and due to be removed. The iptables commands you may remember are a compatibility wrapper (iptables-nft), itself deprecated in RHEL 9 and its rebuilds. The legacy non-nft iptables binary is gone, so a CentOS 7 guide that calls service iptables save has nothing left to talk to.

systemctl is-active firewalld
firewall-cmd --state
firewall-cmd --version

Minimal and cloud images sometimes ship without firewalld. If firewall-cmd is missing, dnf install firewalld and systemctl enable --now firewalld. Starting it will not usually cut your session, since the default public zone allows the ssh service, but check --list-all before you close the terminal, not after.

Zones, and why only public matters on a VPS

A zone is a named bundle of rules bound to interfaces or source addresses. AlmaLinux 9 puts every new interface in public; the other zones exist for laptops and routers that move between networks. On a VPS with one NIC, public is the only one you will touch.

firewall-cmd --get-default-zone
firewall-cmd --get-active-zones
firewall-cmd --zone=public --list-all

The trap: firewall-cmd --list-all with no --zone prints the default zone, which is not necessarily the zone your interface is in. Confirm with --get-active-zones. NetworkManager owns the assignment, so move an interface there, and ask for the profile name first: EL9 keeps NetworkManager keyfiles in /etc/NetworkManager/system-connections rather than the old /etc/sysconfig/network-scripts, and the profile is normally named after the device (ens3, eth0) or "Wired connection 1", not "System eth0" as on EL6 and EL7. The last command reactivates the profile, which takes the interface down for a moment, so run it with the hosting panel console within reach.

nmcli -g NAME connection show
nmcli connection modify "Wired connection 1" connection.zone internal
nmcli connection up "Wired connection 1"

One firewalld 1.0 change bites people migrating configs: zone drifting is gone, so a packet matching a source based zone is handled by that zone alone. The one genuinely useful second zone on a VPS is a private interface in internal, with the database open there and nowhere else.

Opening what you need

firewall-cmd --permanent --zone=public --add-service=http
firewall-cmd --permanent --zone=public --add-service=https
firewall-cmd --reload

--permanent writes /etc/firewalld/zones/public.xml and does not touch the running ruleset, --reload applies it. Forget --permanent and the rule vanishes at reboot. Forget --reload and it works after the next reboot but not now. Most "I opened the port and it still refuses" tickets are one of those two.

firewall-cmd --permanent --zone=public --add-port=8443/tcp
firewall-cmd --permanent --zone=public --add-port=30000-30010/udp
firewall-cmd --reload

Services are XML files in /usr/lib/firewalld/services. Defining your own keeps a zone readable six months later:

firewall-cmd --permanent --new-service=nodeapp
firewall-cmd --permanent --service=nodeapp --set-short="Node app"
firewall-cmd --permanent --service=nodeapp --add-port=3000/tcp
firewall-cmd --reload
firewall-cmd --permanent --zone=public --add-service=nodeapp
firewall-cmd --reload

The first reload is not a typo: firewalld validates the service name when the zone references it. Removal uses --remove-service and --remove-port. And --runtime-to-permanent saves everything currently live, which is blunt: it also persists the throwaway rule you added to debug.

Restricting a port to one source

Plain --add-port opens a port to the whole internet. For a database or a monitoring agent, a rich rule limits it to one source:

firewall-cmd --permanent --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.10/32" port port="5432" protocol="tcp" accept'
firewall-cmd --reload
firewall-cmd --zone=public --list-rich-rules

Two mistakes are common. If 5432 is still in the plain port list the rich rule changes nothing, because the port rule already accepts from everywhere: check --list-ports and remove it. And family="ipv4" covers IPv4 only, while your VPS almost certainly has a routed IPv6 address too, so add the matching family="ipv6" rule or the restriction is half a restriction. A rich rule with no address needs no family attribute and covers both:

firewall-cmd --permanent --zone=public --add-rich-rule='rule service name="ssh" accept limit value="10/m"'
firewall-cmd --reload

That limit only bites once the plain ssh service is out of the zone, for the same reason as 5432 above. Remove it with the safety net from the next section, never on its own.

Ordering inside a zone is not the order you typed: at equal priority firewalld applies log rules, then deny, then allow, so a rich deny beats a plain --add-service. Use the priority attribute for explicit control. To see what is refused:

firewall-cmd --set-log-denied=all
journalctl -k -f

Put it back with firewall-cmd --set-log-denied=off when you are done, or the journal fills with scan noise on any public address.

Seeing what is really open

firewall-cmd --zone=public --list-all shows intent: target, interfaces, sources, services, ports, masquerade, rich rules. ss shows reality.

ss -tulpen
nft list chain inet firewalld filter_IN_public_allow

Read the two together. A port open in firewalld with nothing listening is closed in practice; a daemon bound to 0.0.0.0 is one typo away from public, one bound to 127.0.0.1 is safe whatever the firewall says, and a socket on [::] normally accepts IPv4 too. Verify with nc -zv your.server.ip 443 from another machine, never from the server itself.

An AlmaLinux gotcha: SELinux is enforcing by default, and opening 8081 in firewalld does not let nginx bind it. If the daemon fails to start right after you open a port, that is usually why. Look at the label before you change it: http_port_t covers 80, 81, 443, 488, 8008, 8009, 8443 and 9000, while tcp/8081 already belongs to another type (transproxy_port_t) in the shipped policy. A port that some type already claims has to be modified with -m, and -a works only for a port nothing claims yet, otherwise semanage stops with "Port tcp/8081 already defined".

dnf install policycoreutils-python-utils
semanage port -l | grep 8081
semanage port -m -t http_port_t -p tcp 8081
ausearch -m avc -ts recent

Testing a change without locking yourself out

Never edit permanent rules that touch SSH and reload unless you have a second way in. Work in the runtime, with a timeout, then promote the change once it is proven. The rich rule on its own restricts nothing: the public zone still allows the ssh service, so SSH stays open to the whole internet, exactly the trap described above for 5432. The restriction begins only when you also drop that service:

firewall-cmd --zone=public --add-rich-rule='rule family="ipv4" source address="203.0.113.10/32" service name="ssh" accept' --timeout=10m
firewall-cmd --zone=public --remove-service=ssh
systemd-run --on-active=5m --unit=fw-rollback firewall-cmd --reload

--timeout is runtime only, cannot be combined with --permanent, and applies to the added rule alone, never to the removal: the ssh service does not come back by itself. The systemd-run line is a dead man switch: in five minutes it reloads firewalld, discarding every runtime change, and that reload is what restores the service from the permanent config if the test fails. Now open a new SSH session from another terminal. That step is the whole point: a reload keeps connection tracking state, so your current session survives rules that would block a fresh connection. If the new session works, cancel the rollback and save straight away, well inside the timeout:

systemctl stop fw-rollback.timer
firewall-cmd --runtime-to-permanent

The timing matters. Removing the ssh service has no timeout, the rich rule does, so if the rule expires before you save, the runtime holds neither of them and --runtime-to-permanent writes exactly that: a permanent lock-out your current session hides. If the time ran out, add the rich rule again before saving, or run firewall-cmd --reload and start over.

Avoid two commands while remote: --complete-reload, which discards connection tracking and drops established sessions, and --panic-on, which blocks everything including you. Keep the console from your hosting panel open in a tab. If a machine in our Riga data centre does end up unreachable, our engineers are available around the clock, and we also run this work as managed IT support.

nftables directly, and how it coexists

firewalld builds one table, inet firewalld, with filter base chains hooked at priority filter + 10, deliberately after the classic filter priority, so a hand written table at the standard priority is evaluated first. Verdicts across chains matter: a drop is final immediately, but an accept only ends its own chain and the packet still traverses the other base chains on that hook, so both rulesets have to let it through. Never hand edit inet firewalld, firewalld rewrites it on every reload.

If you prefer plain nftables, run one or the other, and build the replacement before you take the running firewall down. The unit loads exactly one file, /etc/sysconfig/nftables.conf, and the shipped copy of it loads nothing else: the line that would pull in /etc/nftables/main.nft is commented out, and that sample allows only ssh and 9090. So write your own ruleset into /etc/nftables/main.nft first, over the sample. Start it with flush ruleset, or reapplying the file appends duplicates instead of replacing them. A working minimum:

flush ruleset

table inet filter {
  chain input {
    type filter hook input priority filter; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif lo accept
    ip protocol icmp accept
    ip6 nexthdr ipv6-icmp accept
    tcp dport 22 accept
    tcp dport { 80, 443 } accept
  }
  chain forward { type filter hook forward priority filter; policy drop; }
  chain output { type filter hook output priority filter; policy accept; }
}

Now point the file the unit reads at your ruleset, check what it will actually load, and start it. The grep has to print the include line: if it prints nothing, the sed found no match and you add the line to /etc/sysconfig/nftables.conf by hand. The flush ruleset at the top also clears the table firewalld built, which is the point when you are replacing it, so from that moment your rules are the only thing between the machine and the internet:

dnf install nftables
sed -i 's|^#include "/etc/nftables/main.nft"|include "/etc/nftables/main.nft"|' /etc/sysconfig/nftables.conf
grep -n '^include' /etc/sysconfig/nftables.conf
nft -c -f /etc/sysconfig/nftables.conf
systemctl enable --now nftables
nft list ruleset

Read that last output before anything else: your input chain, its policy drop and the line for port 22 all have to be there. Then open a new SSH session from another terminal, the same test as in the firewalld section. If it fails, or the ruleset is not what you wrote, go back: systemctl stop nftables and systemctl restart firewalld put the old rules in place. Read systemctl cat nftables rather than assuming the stop clears anything, since whether the unit flushes the ruleset on stop depends on the version you have, and if it does not, run nft flush ruleset yourself before starting firewalld.

Only once that new session works is it safe to retire the old firewall:

systemctl disable --now firewalld
systemctl mask firewalld

Two field notes: container engines write their own netfilter rules and a firewalld reload can wipe what Docker inserted, so if a published port dies right after a reload, restart the engine. And fail2ban has separate actions for firewalld and for nftables, renamed between 0.11 and 1.0, so check /etc/fail2ban/action.d/ and confirm bans appear in nft list ruleset.

The narrow case for turning it off

One case is defensible: the host has no globally routable address and sits behind a hardware firewall or edge ACL you control and can inspect. That describes a dedicated server on a private VLAN behind your own appliance. A narrower variant is a node whose packet path is fully managed by another controller, such as a Kubernetes CNI, where firewalld only fights the controller.

systemctl disable --now firewalld
systemctl mask firewalld
nft list ruleset

Expect empty output from the last command. Mask rather than merely disable, or a package update can quietly start it again. Bind your daemons to the private address anyway (listen_addresses in PostgreSQL, bind-address in MySQL), because "there is a firewall in front of it" is one misconfigured switch port away from false. Not defensible: turning it off because a port would not open, because a 2014 guide said so, or because a container engine argued with it. All three are diagnosable in the time it takes to run ss -tulpen and --list-all.

When it still does not work

SymptomCheck
Rule added, connection refusedAnything listening, right zone, reload done, SELinux port label
Works from the office, not from homeRich rule family, the CIDR, or a changed ISP address
Reverted after rebootRuntime only rule, no --permanent
Port open, nothing from outsideA filter above the host: edge ACL or router
Bans do nothingfail2ban action does not match the firewall you run
Rather have us run it for you?
VPS. NVMe virtual servers, live in under a minute.
Learn more

Ready to start?

Deploy in minutes or talk to an engineer about what fits your project.