2 AM and the setenforce 0 Trap
Back when CentOS 8 reached EOL, I had to migrate 5 servers to Rocky Linux within a week. On the first night, Nginx refused to bind to the new port, and PHP-FPM was throwing permission errors even though chown was set correctly. Hands shaking, eyes blurring, I typed setenforce 0 and everything just worked. Task done, went to sleep.
The next morning, I took a look and got a shock: the production servers were running with SELinux disabled. Not one server — all 5. And I knew that if left like that, it would be the first red flag in any security audit.
From that point on, I started asking myself: what is SELinux blocking, why, and how do you fix it without just turning it off? This post is the result — how I brought all 5 servers back to Enforcing mode without breaking anything else.
Three Ways to Handle SELinux — and Why Two of Them Don’t Belong on Production
Browse any forum or StackOverflow thread, and most sysadmins handle SELinux one of three ways:
Option 1: Disable SELinux entirely (SELINUX=disabled)
This is common in older tutorials, especially LAMP stack guides from 2018 and earlier. Edit /etc/selinux/config, reboot, done.
# /etc/selinux/config
SELINUX=disabled
The only upside: everything works immediately, no debugging needed. The downsides outweigh that significantly — you’ve killed an entire kernel-level security layer, and re-enabling it later requires relabeling the entire filesystem: my 150GB server took nearly 18 minutes just for that step.
Option 2: Permissive Mode — Logs but Doesn’t Block
SELinux still runs and checks policy, but instead of blocking, it just logs to the audit log. Think of it as a dry run — it catches violations but doesn’t act on them.
setenforce 0 # Temporary (lost after reboot)
getenforce # Check status: will output "Permissive"
To make it persistent across reboots:
vi /etc/selinux/config
# SELINUX=permissive
Option 3: Enforcing — Real Security
This is the mode you actually want. Policy violations are blocked immediately — no warnings, no exceptions. It’s the default when you do a fresh Rocky Linux install.
setenforce 1
getenforce # Output: Enforcing
Practical Analysis: Which Mode Fits Which Situation?
| Mode | Security | Easy to Debug? | When to Use |
|---|---|---|---|
| Disabled | None | Not needed | Never on production |
| Permissive | Doesn’t block | Easiest | Dev/staging, or while debugging |
| Enforcing | Full | Need to know audit log | Production — the ultimate goal |
If you’re running a real server, Disabled is the worst choice. Permissive is fine for test environments, but it’s not a long-term solution. Enforcing is where you need to be.
The Plan: Moving from Permissive → Enforcing in a Controlled Way
The approach I used when migrating those 5 servers was:
- Enable Permissive mode first
- Run services normally for a few days to let SELinux accumulate enough denials
- Use
audit2allowto create custom policies - Load the policies and switch to Enforcing
It takes a few extra days to let the logs accumulate, but in return you’re not fumbling in the dark — you know exactly which services need what permissions before flipping the switch.
Practical Implementation Guide
Step 1: Check the Current Status
sestatus
# Sample output:
# SELinux status: enabled
# SELinuxfs mount: /sys/fs/selinux
# SELinux mount point: /sys/fs/selinux
# Loaded policy name: targeted
# Current mode: permissive
# Mode from config file: enforcing
# Policy MLS status: enabled
# Policy deny_unknown status: allowed
# Memory protection checking: actual (secure)
# Max kernel policy version: 33
Pay attention to the distinction between “Current mode” (runtime) and “Mode from config file” (after reboot). These two can differ — especially if you’ve just used setenforce without updating the config file.
Step 2: Read the AVC Denial Log
In Permissive mode, every violation gets logged to /var/log/audit/audit.log:
# View recent denials
ausearch -m avc -ts recent
# Or use raw grep
grep "avc: denied" /var/log/audit/audit.log | tail -20
A typical AVC denial looks like this:
type=AVC msg=audit(1709712345.123:456): avc: denied { read } for pid=12345 comm="nginx" \
name="app.sock" dev="tmpfs" ino=67890 \
scontext=system_u:system_r:httpd_t:s0 \
tcontext=system_u:object_r:var_run_t:s0 tclass=sock_file permissive=1
Read it in order: which process (comm), what it’s doing (read), on which file (name), denied because the type doesn’t match.
Step 3: Analyze with sealert (More Human-Readable)
Reading audit logs by eye is exhausting. setroubleshoot-server parses them into plain English:
dnf install -y setroubleshoot-server
# Run against the audit log
sealert -a /var/log/audit/audit.log
sealert gives specific recommendations — usually the exact setsebool or semanage command you need to run, along with an explanation of why.
Step 4: Create a Custom Policy with audit2allow
When a service has non-standard behavior — for example, Nginx serving files from a non-standard directory — you need to create a custom policy:
# Create a policy module from the audit log
ausearch -m avc -ts recent | audit2allow -M my_nginx_custom
# Load the newly created policy
semodule -i my_nginx_custom.pp
# Verify it's loaded
semodule -l | grep my_nginx
The .te file is the text source, .pp is the compiled policy. Keep the .te file — if you need to modify the policy later, edit that file instead of starting from scratch.
Step 5: Use Booleans Instead of Creating New Policies
Many common scenarios already have booleans available — using them is faster and cleaner:
# Allow httpd to make outbound connections (API calls, proxy)
setsebool -P httpd_can_network_connect 1
# Allow httpd to read home directories
setsebool -P httpd_enable_homedirs 1
# Allow nginx/apache to bind to a non-standard port
semanage port -a -t http_port_t -p tcp 8080
# View all httpd-related booleans
getsebool -a | grep httpd
Step 6: Relabel File Context If Needed
When you copy files into a new directory or create files outside the standard path, the SELinux context is often wrong. Fix it like this:
# View current context
ls -Z /var/www/html/
# Assign the correct context for a web directory
semanage fcontext -a -t httpd_sys_content_t "/data/web(/.*)?";
restorecon -Rv /data/web/
# Or if fixing a single file
chcon -t httpd_sys_content_t /data/web/index.php
Step 7: Switch to Enforcing
No new denials in the log after a few days in Permissive mode? That’s your signal to switch:
# Temporarily (test immediately)
setenforce 1
# Monitor the log for 30 minutes
tail -f /var/log/audit/audit.log | grep denied
# If all looks good, update config to make it persistent
vi /etc/selinux/config
# SELINUX=enforcing
I usually open a second terminal tab running tail -f on audit.log right after switching to Enforcing. Wait ~30 minutes, test all the main app functionality. No new denials means you’re done.
Quick Reference: SELinux Commands to Know
# Status
sestatus
getenforce
# Change runtime mode
setenforce 0 # Permissive
setenforce 1 # Enforcing
# View context
ls -Z /path/to/file
ps auxZ | grep nginx
# Fix context
restorecon -Rv /path/ # Restore to policy default
chcon -t TYPE /file # Temporary fix
# Port
semanage port -l | grep http
semanage port -a -t http_port_t -p tcp 8443
# Log
ausearch -m avc -ts recent
sealert -a /var/log/audit/audit.log
# Policy
audit2allow -M mypolicy < /var/log/audit/audit.log
semodule -i mypolicy.pp
semodule -l
Closing Thoughts
The trap with SELinux is that it’s easy to turn off and hard to turn back on correctly. After that migration experience, I set a personal rule: no server goes to production without SELinux in Enforcing mode. It costs an extra hour or two of upfront debugging, but what you get in return is a real security layer — not just security theater.
Rocky Linux preserves RHEL’s SELinux behavior. Every command above works identically on AlmaLinux or any other RHEL clone.

