Secure an AlmaLinux 9 VPS with SELinux and Firewalld

You just got a fresh AlmaLinux 9 VPS, you logged in as root, and the first thing you notice is that SELinux is probably in permissive mode with a warning in dmesg. Most tutorials skip it because it is easier to disable. That is a mistake in 2026. This guide walks you through enabling SELinux in enforcing mode and building a Firewalld policy that actually holds: default-deny zones, only SSH, HTTP and HTTPS open, and a verify step after every change.
- Key takeaways:
- SELinux ships in permissive mode on AlmaLinux 9, you should flip it to enforcing and fix any denials before you reboot.
- Firewalld uses zones, the public zone should only allow SSH, HTTP and HTTPS, everything else stays blocked.
- Always verify with
semanage,setenforce 1andfirewall-cmd --list-allafter each change, never assume a rule works.
Prerequisites
This guide assumes an AlmaLinux 9 VPS with a dedicated IPv4 and full root access. You need:
- An AlmaLinux 9 installation, current to the 9.8 release.
- Root access over SSH, ideally with an ED25519 key already in place.
- A domain name only if you plan to serve HTTPS traffic, not strictly required for the firewall part.
If you are renting a Linux VPS that runs AlmaLinux, you can start from a clean install and follow along. If you have already disabled SELinux in the past, this guide shows you how to switch it back on safely.
Why SELinux and Firewalld together
SELinux is not a firewall and Firewalld is not an access control system. They sit on different layers and you need both. SELinux enforces mandatory access control at the kernel level, it restricts what processes can read, write or execute regardless of what the user running them wants. Firewalld filters network traffic at the packet level, it decides who can reach a port at all.
A common failure pattern on a VPS with a dedicated IPv4 is to open a port in Firewalld, then wonder why the service still fails. Nine times out of ten, SELinux is blocking it and the audit log in /var/log/audit/audit.log has the answer. The reverse also happens, you fix a SELinux boolean, then traffic still gets dropped because Firewalld never had the port open. You must treat them as a pair and verify both.
AlmaLinux 9 ships with SELinux enabled but in permissive mode. That means it logs denials but does not enforce them. Running permissive gives you a false sense of security. The only way to know your policy works is to set it to enforcing and let it actually block.
Step 1 - Switching SELinux to enforcing
Check the current state first:
sestatus
getenforce
sestatus shows the mode and the policy file, getenforce gives you a one word answer. If both report permissive or disabled, you have work to do. The important detail is that you should not reboot with SELinux set to enforcing if you have never run it before. You may have denials that stop critical services from starting.
The safe sequence is to enable enforcing at boot, then boot once into permissive to collect any denials, then switch to enforcing live. Edit the main config:
vi /etc/selinux/config
Set SELINUX=enforcing. Do not touch the SELINUXTYPE line, it must stay targeted. This is the only file that controls the boot-time mode. Now, before rebooting, generate a policy to capture denials:
mkdir /root/selinux-denials
audit2why -a > /root/selinux-denials/why.log
ausearch -m avc -ts recent > /root/selinux-denials/avc.log
Reboot, then check again. When the server comes back, SELinux is enforcing and getenforce reports it. If a service fails to start after the reboot, look at the audit log:
ausearch -m avc -ts recent | audit2allow -R
That command shows you the exact boolean or policy rule to add. Apply it with setsebool for booleans, or create a custom policy module with audit2allow -M for file contexts. Most web server setups only need one or two booleans, such as httpd_can_network_connect.
Verify:
getenforce
# Expected output: Enforcing
This is the point where most mistakes happen. Do not copy-paste a boolean from a blog for a stack you are not running. Read the denial, understand it, then fix it.
Step 2 - Building a minimal Firewalld policy
Firewalld is the default firewall on AlmaLinux 9. Its model is zone-based, each network interface belongs to a zone, and each zone has its own set of allowed services and ports. The default zone is public, and unless you changed the runtime settings, everything is blocked except what you explicitly allow.
First, confirm firewalld is running and see the current state:
systemctl status firewalld
firewall-cmd --get-default-zone
firewall-cmd --list-all
On a fresh install you will see that SSH is already open in the public zone, which is correct. Everything else should be filtered. If you see a long list of services here, stop and figure out who added them before you continue.
Set the policy you actually want. This is the whole point of the exercise, a default-deny posture where only SSH, HTTP and HTTPS are reachable:
firewall-cmd --permanent --zone=public --remove-service=ssh
firewall-cmd --permanent --zone=public --remove-service=dhcpv6-client
firewall-cmd --permanent --zone=public --add-service=ssh
firewall-cmd --permanent --zone=public --add-service=http
firewall-cmd --permanent --zone=public --add-service=https
firewall-cmd --reload
Why the explicit remove and re-add of SSH? Because you want the policy to be exactly these three services and nothing else. The dhcpv6-client service is enabled by default and usually unnecessary on a server with a static IP. Removing it tightens the policy. If you later need a custom port for SSH, this is where you add it, with --add-port=2222/tcp instead of the service name.
Verify:
firewall-cmd --zone=public --list-all
# Expected output: services: dhcpv6-client removed, services: http, https, ssh
Now test from your local machine before you log out. Open a second SSH session and confirm you can still connect. If you locked yourself out, firewall-cmd --reload in a recovery console restores it, or reboot the VPS from the control panel.
Step 3 - Setting the right SELinux contexts for your services
SELinux is not something you configure once and forget. Every service you install on AlmaLinux 9 needs the correct file context and the correct booleans, or it will fail in ways that are hard to debug. The classic example is Nginx or Apache serving files from a non-standard directory.
If you point your web root at /var/www/mysite, SELinux expects it to carry the httpd_sys_content_t type. A ls -Z shows the context of every file:
ls -Z /var/www/mysite
# Expected output: (the .Z column shows httpd_sys_content_t on the files)
If the context is wrong, for example var_t, the web server cannot read the files even with correct permissions. Fix it with:
semanage fcontext -a -t httpd_sys_content_t "/var/www/mysite(/.*)?"
restorecon -Rv /var/www/mysite
The first command records the rule in the SELinux policy, the second applies it to the real files. This survives reboots, which a plain chcon does not.
For network connections, the two booleans most web apps need are:
setsebool -P httpd_can_network_connect on
setsebool -P httpd_can_network_relay on
The -P flag makes the change persistent. httpd_can_network_connect is required when your web app talks to a database or an external API, a very common denial people hit when setting up a CMS or an n8n workflow. httpd_can_network_relay is for reverse proxy setups that forward traffic to a backend on a different port.
Verify:
getsebool httpd_can_network_connect
# Expected output: httpd_can_network_connect --> on
Troubleshooting common SELinux denials
You will hit a denial sooner or later, it is part of running SELinux in enforcing mode. The fastest way to find it is the audit log, not trial and error.
ausearch -m avc -ts recent
This prints the last AVC denials. Each entry shows the process, the target and the denied permission. The three most common patterns and their fixes:
- Web server cannot read files: wrong context on
/var/wwwor a custom directory. Runrestorecon -Rvor add asemanage fcontextrule. - Web app cannot call an API or database:
httpd_can_network_connectis off. Turn it on withsetsebool -P. - Port is bound but unreachable: check both layers.
semanage port -l | grep 8080confirms SELinux allows the port,firewall-cmd --list-allconfirms the firewall opens it.
The last one is the most common trap. A port can be blocked by SELinux even when Firewalld has it open, and vice versa. The diagnostic order is: check ss -tlnp to confirm the process binds, then ausearch for SELinux, then firewall-cmd for the zone rule.
Keeping the policy clean over time
A firewall and SELinux policy is not a one-time setup, it decays. Every service you install opens something, and every package update can change a context. Set a habit of reviewing the open ports regularly:
firewall-cmd --list-all
semanage port -l | grep http
Once a month, scroll through these two outputs. Anything you do not recognize, question it. A VPS that has been running for six months with accumulated --add-port rules starts to look like Swiss cheese. The VPS pricing on a small plan is cheap, but the cost of a compromise on an insecure server is not, so this review is worth the five minutes.
For a deeper audit, run lynis audit system, it flags misconfigured SELinux booleans and open ports in one pass. Treat its suggestions as a checklist, not gospel, and verify everything before you change it.
FAQ
How do I check if SELinux is enforcing?
Run getenforce. It prints Enforcing, Permissive or Disabled. For the full policy file and current mode, use sestatus.
Should I disable SELinux on an AlmaLinux 9 VPS?
No. AlmaLinux 9 ships with SELinux in permissive mode for a reason, and disabling it removes a real layer of defense. If you hit a denial, fix the boolean or context instead of turning SELinux off.
What is the difference between firewalld and SELinux?
Firewalld filters packets at the network layer, it decides which ports are reachable. SELinux controls what processes can do at the kernel level, regardless of network access. You need both configured correctly.
I opened a port in firewalld but the service is still blocked, why?
Check the SELinux audit log with ausearch -m avc -ts recent. Most likely a boolean like httpd_can_network_connect is off, or the port is not defined in SELinux policy with semanage port -a.
How do I restore a broken SELinux context?
Use restorecon -Rv /path/to/directory. For a custom directory that must keep its context across reboots, add a persistent rule with semanage fcontext -a first.
Related articles
- security audit and hardening with lynis on a vps
- set up nftables firewall on a vps to replace iptables
- hardening ssh on a new vps keys ports fail2ban and crowdsec
- how to configure the ufw firewall on an ubuntu vps
AlmaLinux 9 安全加固要点
启用 SELinux enforcing 模式前先收集 AVC 拒绝日志,用 audit2allow 修复布尔值,不要直接关闭 SELinux。Firewalld 采用默认拒绝策略,只开放 SSH、HTTP 和 HTTPS 端口。每次改动后用 getenforce 和 firewall-cmd --list-all 验证,并定期审查规则和端口,避免策略随服务增加而失控。


