firewalld basics for a Rocky or AlmaLinux VPS
Open SSH, allow a web port, close one, and survive a reboot with firewalld on Rocky or AlmaLinux. Zones explained, plus the --permanent trap.
What firewalld is, and why Rocky and AlmaLinux ship it
firewalld is the firewall manager installed by default on Rocky Linux, AlmaLinux and the other rebuilds of Red Hat Enterprise Linux (RHEL). It does not inspect packets itself. It keeps a saved configuration and turns that configuration into nftables rules. One command, firewall-cmd, edits it while the server stays online.
If you already know how ufw works on an Ubuntu VPS, you know the job. firewalld adds two ideas ufw does not have. The first is zones: a named policy that packets are sorted into. The second is a split between the live rules and the saved rules, which is the --permanent flag and the biggest source of confusion in this tool.
Everything below is a command you run on your own server. Test every change from a second machine, because a rule that looks correct on the box can still be wrong from the internet.
Open SSH before you do anything else
Most Rocky and AlmaLinux installs have firewalld present and running already, and the shipped configuration allows SSH. Some minimal cloud images strip it out. Check instead of assuming.
sudo dnf install -y firewalld
sudo systemctl enable --now firewalld
sudo firewall-cmd --statefirewall-cmd --state prints running. If the service is stopped, every other firewall-cmd call answers FirewallD is not running and exits non-zero. That is the first thing to check when a command seems to do nothing at all.
Now read what is currently allowed.
sudo firewall-cmd --list-allThe real output has a few more lines. These are the ones that matter:
public (active)
target: default
interfaces: eth0
sources:
services: cockpit dhcpv6-client ssh
ports:
rich rules:ssh in the services: line is why your session still works. If it is missing, add it before you touch anything else, because starting a firewall with no SSH rule ends the session and does not let you back in.
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --reloadtarget: default means a packet matching nothing is rejected with an ICMP (internet control message protocol) host-prohibited reply, so a client hitting a closed port sees No route to host straight away. Setting the target to DROP makes the server silent instead, and scanners then wait for a timeout.
sudo firewall-cmd --permanent --zone=public --set-target=DROP
sudo firewall-cmd --reloadKnow the cost before you run that: DROP also stops the server answering ping, so your own monitoring goes quiet too.
Why did my rule disappear? The --permanent flag
firewalld holds two configurations at once. The runtime configuration is what the kernel enforces this second. The permanent configuration is what lives in /etc/firewalld/zones/public.xml and what comes back after a reload or a reboot.
A command without --permanent changes the runtime only. It works immediately and it is gone at the next reload or boot. A command with --permanent writes the file and changes nothing that is running, so the port stays closed until you reload. Neither behaviour is a bug. Both surprise people, because the command prints success either way.
Write the pair every time.
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --reloadYou can read both configurations, which is the fastest way to find out which of the two mistakes you made.
sudo firewall-cmd --list-services
sudo firewall-cmd --permanent --list-servicesThe first prints the live set. The second prints the saved set. If the live set holds a service the saved set does not, that rule dies at the next reload. If the saved set holds one the live set does not, you forgot the reload. sudo firewall-cmd --runtime-to-permanent copies everything live into the saved file, which is useful after a session of experiments.
--reload keeps connection tracking state, so your SSH session survives it. --complete-reload reloads the kernel modules as well and loses that state, which usually terminates every open connection including yours. Use the plain reload.
One safety net is built in. A runtime rule can expire on its own.
sudo firewall-cmd --add-service=http --timeout=5mThat rule removes itself after five minutes. It cannot be combined with --permanent, and that is the point: it exists for testing a change you are unsure about. The older safety net is better. Keep a second SSH session open while you edit rules, and do not close it until a fresh login proves the new rules work.
Zones, and why only the default zone matters on a VPS
A zone is a named set of permissions with a trust level attached. firewalld puts every incoming packet into exactly one zone. It matches the packet's source address against each zone's sources: list first. If nothing matches, it uses the zone that the incoming interface is bound to. If the interface is bound to no zone, the packet goes to the default zone.
sudo firewall-cmd --get-default-zone
sudo firewall-cmd --get-active-zonesOn a VPS with one network interface the first answer is almost always public, and that is the only zone you will use. firewall-cmd with no --zone= argument acts on the default zone, which is why every short command in this guide works without naming one.
Here is the failure that wastes an afternoon. If the interface is bound to some other zone, your rules land in public while the traffic is handled elsewhere, so nothing you add has any effect and nothing warns you. --get-active-zones shows the binding:
public
interfaces: eth0If the interface appears under a different zone name, either write your rules there with --zone=, or move the interface.
sudo firewall-cmd --permanent --zone=public --change-interface=eth0
sudo firewall-cmd --reloadNetworkManager manages interfaces on Rocky and AlmaLinux, and it reasserts the zone when the connection comes up. Set it there as well so a reboot does not undo your work. Take the connection name from the first command, since it is rarely the same as the device name.
sudo nmcli connection show
sudo nmcli connection modify "System eth0" connection.zone publicSource matching beats interface matching, which is how one address gets a different policy. The built-in trusted zone accepts everything.
sudo firewall-cmd --permanent --zone=trusted --add-source=203.0.113.10/32
sudo firewall-cmd --reloadBe careful with that one. It opens every port on the server to that address, including the database you believed was private. Use a rich rule when you want one port, not one host.
What is a firewalld service?
A service is a named bundle of ports shipped as an XML file. --add-service=https opens 443/tcp because /usr/lib/firewalld/services/https.xml defines what https means.
sudo firewall-cmd --get-services
sudo firewall-cmd --info-service=https--info-service prints the ports hiding behind the name:
https
ports: 443/tcpUse the name when one exists. It reads clearly in --list-all six months later, and packages such as Cockpit install their own service file. Use --add-port for anything that has no definition.
The gap to watch: the ssh service means 22/tcp and nothing else. If you moved SSH to another port while hardening SSH access on the server, then --add-service=ssh does not open the port you actually use.
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --reloadOn a RHEL rebuild there is a second lock on that door. SELinux (security-enhanced Linux) labels port numbers, and sshd is not allowed to bind to a port outside its labels. It then refuses to start and the log reads error: Bind to port 2222 on 0.0.0.0 failed: Permission denied. Label the port first.
sudo dnf install -y policycoreutils-python-utils
sudo semanage port -a -t ssh_port_t -p tcp 2222How do I see what is open right now?
sudo firewall-cmd --list-all
sudo firewall-cmd --list-rich-rules
sudo nft list table inet firewalld | head -n 40The first two report what firewalld believes. The third reads the rules the kernel actually holds, in the table firewalld owns. They should agree.
None of that is proof. Test from another machine:
nc -zv 203.0.113.20 443Do not run the test on the server itself. firewalld accepts everything arriving on the loopback interface, so curl http://localhost:8080 succeeds whatever your rules say. That test tells you the service is alive. It tells you nothing about the firewall.
Allow a web port
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-servicesThe last command should now list http https alongside what was there before. If the site still does not answer, the firewall may not be your problem. A rule permits a packet. A process still has to be listening for it.
sudo ss -tlnpA socket shown as 0.0.0.0:443 or *:443 accepts connections from any address. One shown as 127.0.0.1:443 answers on loopback only, and no firewall rule will ever make it reachable from outside. Ports and listening sockets on Linux covers that difference in more detail.
How do I close a port again?
sudo firewall-cmd --permanent --remove-service=http
sudo firewall-cmd --reloadThe --permanent rule applies here too, and it bites harder in this direction. Remove a service from the runtime only and the port looks closed, then the next reload or reboot reopens it from the saved file. That is a hole you will not notice, because the check you ran passed.
Removing something that was never there prints Warning: NOT_ENABLED: http and still exits 0. Adding the same thing twice prints Warning: ALREADY_ENABLED: http. Both are safe. A misspelled name is different: Error: INVALID_SERVICE means firewalld has no definition by that name and nothing was changed at all.
If your --list-all shows cockpit and you do not use the Cockpit web console on port 9090, remove it. Every open port is a service you have to keep patched.
Restrict a port to one source address
Rich rules are the long form, for when a plain service name cannot express what you mean. Restricting SSH to one office address takes two commands, and the second is the one people forget.
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.10/32" service name="ssh" accept'
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reloadA zone is a set of permissions, not a numbered list that stops at the first match. The rich rule adds an accept for one address. It denies nobody. While ssh is still in the services: line, the whole internet still reaches port 22 and the rich rule changes nothing you can measure. Remove the broad entry, or the narrow one is decoration.
For a port with no service name, name the port instead.
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.10/32" port port="5432" protocol="tcp" accept'To drop a noisy network and keep a record, put the log element before the action, which is the order the rich rule language expects.
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="198.51.100.0/24" log prefix="fw-drop " level="info" limit value="3/m" drop'
sudo firewall-cmd --reload
sudo journalctl -k -g fw-dropThe limit value stops a flood of packets filling the journal. Before you lock SSH to a single address, be sure that address is stable. A home connection with a changing IP address locks you out the day it changes, so have your provider's console access tested and working first.
ufw commands and their firewall-cmd equivalents
Same tasks, different tool. Every --permanent line needs a sudo firewall-cmd --reload after it, which is the one thing a list like this cannot show you.
sudo ufw enablebecomessudo systemctl enable --now firewalldsudo ufw disablebecomessudo systemctl disable --now firewalldsudo ufw status verbosebecomessudo firewall-cmd --list-allsudo ufw allow OpenSSHbecomessudo firewall-cmd --permanent --add-service=sshsudo ufw allow 443/tcpbecomessudo firewall-cmd --permanent --add-port=443/tcpsudo ufw delete allow 443/tcpbecomessudo firewall-cmd --permanent --remove-port=443/tcpsudo ufw allow from 203.0.113.10 to any port 22becomes the rich rule shown abovesudo ufw reloadbecomessudo firewall-cmd --reloadsudo ufw default deny incomingis already how thepubliczone behaves, and--set-target=DROPis the silent version of itsudo ufw logging onbecomessudo firewall-cmd --set-log-denied=all
One difference is worth stating plainly. ufw keeps a numbered list and you can insert a rule at position 1. firewalld has no rule numbers, so "put this rule first" means nothing here. When two firewalld entries appear to contradict each other, the broad accept wins, because nothing in the set denies. You remove the broad entry yourself.
Why is my Docker container reachable when the firewall looks closed?
Because a published container port never reaches the part of the firewall your zone controls. docker run -d -p 8080:80 nginx tells Docker to write its own NAT (network address translation) and forwarding rules. A packet arriving on 8080 is rewritten and routed onward to the container, so it is forwarded rather than delivered to the host. The services: and ports: lines in your zone govern packets delivered to the host. Docker's rules govern the forward path, and they accept.
The result is a server where sudo firewall-cmd --list-all shows no port 8080 and nc -zv 203.0.113.20 8080 from another machine connects anyway. Look at what Docker installed:
sudo iptables -t nat -L DOCKER -nThe fix lives in the publish flag. Bind the port to loopback and put a reverse proxy in front of it.
docker run -d -p 127.0.0.1:8080:80 nginxThe container now answers curl http://127.0.0.1:8080 on the server and nothing from outside. Ubuntu users hit the same wall, described in why Docker containers publish ports straight past ufw. Rootful Podman, which Rocky and AlmaLinux ship in the base repositories, publishes ports with the same NAT approach, so test from another machine rather than trusting the zone list.
Making it survive a reboot, and the errors you will see
sudo systemctl is-enabled firewalld
sudo systemctl status firewalldenabled and active (running) are what you want. A firewall that is running but not enabled protects you until the first reboot. That check belongs on the list you work through in the first ten minutes on a new VPS, next to SSH keys and updates.
Raw nftables commands and firewalld do not mix. firewalld owns a table called inet firewalld. sudo nft flush ruleset deletes it, the server is then open to everything, and firewall-cmd --list-all still prints your intended configuration, because firewalld reports what it believes rather than what the kernel holds. sudo firewall-cmd --reload reinstalls the rules. Write rules with firewall-cmd so they come back after a reload.
Two firewall managers on one server. Installing ufw or iptables-services beside firewalld gives you two programs writing rules with no knowledge of each other, and the winner depends on which service started last. Pick one. On Rocky and AlmaLinux, firewalld is the one with distribution support.
The provider firewall in front of the box. Many VPS panels have a separate network firewall. If --list-all shows a port open and a connection from outside still fails, check the panel before you change anything on the server. It works the other way too: an open panel rule does nothing while firewalld rejects the packet.
Running firewall-cmd without sudo. Every change needs root. Without it the request is refused by an authorization check and nothing is modified, which reads at a glance like the command was ignored.
Six commands cover most days: --list-all to read the state, --permanent --add-service or --add-port to open something, --permanent --remove-service to close it, --reload to apply the saved file, and --runtime-to-permanent after a round of experiments. The zone is public, the flag is --permanent, and the only honest check comes from another machine.
FAQ
Why did my firewalld rule disappear after a reboot?
The rule went into the runtime configuration only. sudo firewall-cmd --add-service=http applies immediately and is discarded at the next reload or boot, because the saved configuration in /etc/firewalld/zones/public.xml was never touched. Add --permanent and then run sudo firewall-cmd --reload. To keep rules you already added by hand, run sudo firewall-cmd --runtime-to-permanent, which copies the live set into the saved file.
Why does nothing change after I add a rule with --permanent?
Because --permanent writes the file and leaves the running firewall alone. The port stays closed until sudo firewall-cmd --reload loads the saved configuration into the kernel. Compare sudo firewall-cmd --list-services with sudo firewall-cmd --permanent --list-services: if the saved list holds an entry the live list does not, the reload is what you are missing.
Should I use --add-service or --add-port?
Use --add-service when a name exists for what you run. It states intent, and sudo firewall-cmd --info-service=https shows exactly which ports the name covers. Use --add-port when nothing defines your service, or when it listens on a non-standard port. The ssh service means 22/tcp only, so SSH moved to 2222 needs --add-port=2222/tcp plus an SELinux label for that port.
Why is my Docker container reachable when firewall-cmd shows the port closed?
A published port is rewritten by Docker's own NAT rules and forwarded to the container, so the packet is never delivered to the host, and a zone's service and port lists only cover packets delivered to the host. The container answers from the internet while --list-all shows nothing. Publish to loopback instead, with docker run -d -p 127.0.0.1:8080:80 nginx, and put a reverse proxy in front of it.
Can I install ufw on Rocky Linux instead of firewalld?
Two firewall managers on one server write rules with no knowledge of each other, and which set survives depends on which service started last. firewalld is the supported tool on Rocky Linux and AlmaLinux, it is installed already, and it drives the same nftables backend ufw would drive. Learn the default zone and the --permanent flag once and you have the whole tool.