I have rolled SELinux out across mixed fleets more than a dozen times now, and the pattern is always the same. Someone sets it to permissive during initial provisioning, everyone gets busy, and eighteen months later there's a production box running as root's best friend with zero mandatory access control doing anything useful. This runbook is my attempt to stop that cycle. We're going to take a server from disabled or permissive SELinux to a properly enforced, verified, production-grade configuration, and we're going to do it in a way that doesn't page anyone at 2 AM.
SELinux is not a firewall and it's not an antivirus. It's a label-based mandatory access control (MAC) system built into the kernel that constrains what processes can do regardless of what discretionary permissions (the regular Unix rwx bits) allow. Even if an attacker gets code execution as your web server user, SELinux confines that process to only the actions its policy type permits — reading specific directories, binding specific ports, talking to specific sockets. Discretionary access control says "the owner decides." Mandatory access control says "the kernel decides, and the owner doesn't get a vote." That distinction matters enormously once you've had an application server compromised and watched a policy violation in the audit log stop lateral movement cold.
Prerequisites
Before you touch anything, you need a few things in place. I've seen this step skipped and it always ends in a rushed rollback.
- A RHEL-family distribution (RHEL, Rocky, AlmaLinux, or Fedora) — this guide assumes SELinux is already compiled into the kernel, which it is on all of these by default.
- Root or sudo access on the target host, here referred to as sw-infrarunbook-01.
- The policycoreutils, policycoreutils-python-utils, and setroubleshoot-server packages installed for management and troubleshooting tooling.
- A maintenance window. Switching enforcement modes on a live production server without one is how you end up locked out of SSH at an inconvenient hour.
- A rollback plan — specifically, console or out-of-band access (IPMI, iLO, or a hypervisor console) in case enforcing mode blocks the very service you use to reach the box.
- Backups of
/etc/selinux/config
and any custom policy modules you plan to touch.
In my experience, the single biggest predictor of a smooth SELinux rollout is whether the team has console access ready before flipping to enforcing. Skip that and you're one bad policy decision away from a truck roll.
Step-by-step setup
Start by checking the current state. Don't assume — I've walked into environments where three different people believed three different things about whether SELinux was even running.
$ getenforce
Permissive
$ sestatus
SELinux status: enabled
SELinuxfs mount: /sys/fs/selinux
SELinux root directory: /etc/selinux
Loaded policy name: targeted
Current mode: permissive
Mode from config file: permissive
Policy MLS status: enabled
Policy deny_unknown status: allowed
Max kernel policy version: 33
If you see
disabledinstead of
permissive, that's a bigger job — you'll need to relabel the entire filesystem on next boot, which takes time on large disks and requires a reboot with
autorelabeltriggered. If it's already permissive, you're in a much better spot because the kernel is already tracking what it would have denied, without actually blocking anything.
Install the tooling you'll need for policy management and debugging:
$ sudo dnf install -y policycoreutils policycoreutils-python-utils \
setroubleshoot-server setools-console
Now, the actual workflow. Never jump straight from disabled or permissive to enforcing. The correct sequence is: run in permissive mode long enough to capture real traffic and generate a denial log, review those denials, write policy exceptions for anything legitimate, then flip to enforcing. I usually budget at least a full business week of permissive logging on a new production workload before I trust it enough to enforce.
With permissive mode active and the workload running normally, watch the audit log for AVC (Access Vector Cache) denials — these are the events SELinux would have blocked had it been enforcing:
$ sudo ausearch -m avc -ts recent
type=AVC msg=audit(1745582011.222:481): avc: denied { name_connect } for pid=2214 comm="nginx" dest=8443 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=1
That denial tells you exactly what's happening: the nginx process, running under the
httpd_tdomain, tried to connect out to TCP port 8443, but that port is labeled
unreserved_port_trather than something httpd is allowed to reach. This is a real, common finding whenever an app is proxying to a non-standard backend port. The fix is not to disable SELinux — it's to tell SELinux that this port is legitimately part of the httpd workflow.
$ sudo semanage port -a -t http_port_t -p tcp 8443
$ sudo semanage port -l | grep http_port_t
http_port_t tcp 80, 81, 443, 488, 8008, 8009, 8443, 9000
For file context issues — say, a custom application directory serving content from /srv/solvethenetwork-app/html instead of the default /var/www/html — you need to set the correct label so the policy recognizes it as web content, not just "some directory root happened to allow":
$ sudo semanage fcontext -a -t httpd_sys_content_t "/srv/solvethenetwork-app/html(/.*)?"
$ sudo restorecon -Rv /srv/solvethenetwork-app/html
For denials that don't map cleanly to an existing boolean or port label — custom daemons, in-house tooling, that sort of thing — you'll lean on
audit2allowto generate a custom policy module from the actual denials you've collected:
$ sudo ausearch -m avc -ts today | audit2allow -M solvethenetwork_custom
$ sudo semodule -i solvethenetwork_custom.pp
I want to flag something here because I've seen it go wrong more than once: don't pipe
audit2allowstraight into
semodule -iwithout reading the generated
.tefile first. Open solvethenetwork_custom.te and actually read what rules it's proposing. Sometimes it will happily generate a rule granting far broader access than the one denial actually requires, because it's pattern-matching against the AVC record rather than understanding intent. A denial for reading one config file can turn into a blanket read/write grant on an entire directory type if you're not paying attention.
Once you've worked through a reasonable set of denials and you're not seeing new ones show up in the logs for a few days of normal traffic, it's time to flip the switch:
$ sudo setenforce 1
$ getenforce
Enforcing
setenforce 1changes the running kernel state immediately, but it won't survive a reboot unless you also update the config file. Edit /etc/selinux/config:
SELINUX=enforcing
SELINUXTYPE=targeted
Full configuration example
Here's a complete, representative configuration for a hardened application server at sw-infrarunbook-01 running an nginx reverse proxy in front of a backend service on a non-standard port, with a custom content directory and a couple of booleans tuned for the actual workload rather than left at defaults.
# /etc/selinux/config
SELINUX=enforcing
SELINUXTYPE=targeted
# Port labeling for the reverse proxy backend
# (run once, persists in policy store)
semanage port -a -t http_port_t -p tcp 8443
# Custom web root labeling
semanage fcontext -a -t httpd_sys_content_t "/srv/solvethenetwork-app/html(/.*)?"
restorecon -Rv /srv/solvethenetwork-app/html
# Allow nginx to make outbound network connections
# (needed when proxying to an upstream API)
setsebool -P httpd_can_network_connect on
# Allow nginx to connect to the backend on a non-standard port
# already covered by the http_port_t labeling above
# Deny nginx from writing to content directories it only needs to read
setsebool -P httpd_unified off
# Custom module for the in-house monitoring agent
# generated from a week of permissive-mode denials, reviewed by hand
semodule -i solvethenetwork_custom.pp
# Confirm final boolean state relevant to this host's role
getsebool -a | grep httpd
Notice what's not in this configuration: no blanket
setenforce 0fallback cron job, no wildcard fcontext rules, no disabling of the audit daemon to "reduce noise." Every rule here maps to a specific, understood requirement. That traceability is what makes SELinux maintainable long-term instead of becoming an unexplainable pile of exceptions nobody wants to touch.
Verification steps
Don't declare victory the moment
getenforcesays
Enforcing. Verify the whole chain actually works under load and under attack-shaped conditions.
First, confirm enforcement survived a reboot, since a live
setenforce 1without the config file change will silently revert:
$ sudo reboot
# after reboot
$ getenforce
Enforcing
$ sestatus | grep "Current mode"
Current mode: enforcing
Second, confirm the actual application functions end to end. Hit the service the way a real client would and check for errors that look like silent SELinux blocks — connection refused where you'd expect a normal response, 502s from a proxy that can't reach its upstream, or a systemd unit that fails with a permission error despite correct file ownership:
$ curl -I https://sw-infrarunbook-01.solvethenetwork.com:8443/health
HTTP/1.1 200 OK
$ systemctl status nginx
● nginx.service - The nginx HTTP and reverse proxy server
Loaded: loaded
Active: active (running)
Third, and this is the step people forget most often, go back to the audit log and confirm there are zero new denials during a full business cycle — not just a quick smoke test, but a real day of traffic including whatever batch jobs, log rotations, or backup runs happen on a schedule:
$ sudo ausearch -m avc -ts today
$ sudo sealert -a /var/log/audit/audit.log
No problems found
An empty
ausearchresult after a full day, including cron-triggered jobs, is your real signal that the policy tuning was complete. If you only tested during business hours and skip the 3 AM backup job, you'll get paged when that job runs and finally hits a denial nobody anticipated.
Finally, verify context labels haven't drifted, particularly after any package updates or manual file operations that might have reset them to defaults:
$ ls -Z /srv/solvethenetwork-app/html
unconfined_u:object_r:httpd_sys_content_t:s0 index.html
unconfined_u:object_r:httpd_sys_content_t:s0 assets
Common mistakes
The number one mistake, by a wide margin, is treating
setenforce 0as a debugging tool that gets left in place. I've inherited servers where someone hit an SELinux denial during an incident, ran
setenforce 0to make the problem go away, fixed the actual issue, and then just... never turned it back on. Weeks later nobody remembers it happened. If you must disable enforcement temporarily to isolate whether SELinux is the cause of a problem, put a reminder on the calendar and treat the permissive window as a ticking clock, not a new steady state.
A close second is disabling SELinux entirely at the kernel boot parameter level (
selinux=0) rather than setting permissive or enforcing in the config file. This is a much heavier hammer — it removes the /sys/fs/selinux mount entirely, and re-enabling it later requires a full filesystem relabel and reboot, which on a large production disk can take a genuinely long time and holds the server offline the whole time. If you ever find yourself needing SELinux back on a box where it was fully disabled, budget real downtime for that relabel.
Blindly accepting every
audit2allowsuggestion is another one I've flagged already but it deserves repeating because of how often it happens under deadline pressure. The tool is a starting point for writing policy, not a policy generator you can trust unread. Grant the narrowest rule that resolves the actual denial you're chasing.
People also frequently forget that SELinux context and Unix permissions are independent layers. I've debugged plenty of "permission denied" errors where the file had perfectly correct ownership and mode bits, and the actual blocker was a mislabeled context — usually because someone moved or copied a file with
cpor an unusual tool that didn't preserve the SELinux label, rather than using
mvwithin the same filesystem or explicitly running
restoreconafterward. Any time a file is placed somewhere non-standard, get in the habit of running
restorecon -von it as a reflex.
Last one: not testing failure paths. Everyone tests the happy path where the service starts fine. Fewer people test what happens when the service restarts after a crash, when a backup script kicks off at 3 AM, or when a log rotation triggers a file move. Those are exactly the moments hidden denials surface, usually while nobody is watching, and usually a week after the rollout when everyone's confidence is highest and vigilance is lowest.
Done right, SELinux in enforcing mode gives you a genuinely meaningful second layer of defense — one that keeps doing its job even after an attacker has already gotten further than you wanted them to. The setup cost is real, but it's front-loaded, and it gets dramatically cheaper on every subsequent server once you've built the policy modules and muscle memory for how a given application stack behaves under mandatory access control.
