SELinux in Practice (Not 'Disable It')
Every sysadmin has been there. A service won’t start. Logs are cryptic. Someone googles the problem and the top answer says “set SELinux to permissive and try again”. It works. The problem is forgotten. SELinux stays permissive forever, the one security feature RHEL shipped specifically to prevent this class of escalation is now neutered, and a year later when the service is compromised by a directory traversal bug, the attacker gets the keys to everything.
SELinux has a reputation as the security feature that breaks things and that nobody understands. Both halves of that reputation come from the same source: the surface-level interaction most people have with it is “it blocked my thing, I turned it off”. That’s not using SELinux. That’s avoiding it.
This post is the practical introduction: the mental model, the tooling that actually helps, and the 80% of common problems you can fix in a few minutes without ever setting SELINUX=permissive.
The one-paragraph mental model
SELinux is a Mandatory Access Control (MAC) layer on top of standard Unix Discretionary Access Control (DAC). Standard Linux permissions answer “can this user access this file?”. SELinux answers “does this process type have permission to perform this operation on this object type?”. Every process has a security context (user:role:type:level) and every object (file, socket, process) has one too. The kernel consults a policy — a compiled set of rules — to decide whether the operation is allowed. If it’s not in the policy, it’s denied, and the denial is logged. Root in DAC terms doesn’t help. You can be root and still get denied because your process type isn’t allowed that operation on that object type.
The “type” is the important part. Every object has one. SELinux policy is a graph of “type A is allowed these actions on type B”. Everything else — users, roles, levels — matters less in day-to-day use.
The three modes
|
|
Returns one of:
- Enforcing — policy is consulted and denials are blocked. Production mode.
- Permissive — policy is consulted, denials are logged, but nothing is actually blocked. Testing/debugging mode.
- Disabled — SELinux is off entirely. Don’t.
Permissive is useful: you can enable it temporarily to see what would be denied without breaking production. It’s the diagnostic mode, not the destination.
setenforce 0 temporarily sets permissive until reboot. setenforce 1 sets enforcing. To change persistently, edit /etc/selinux/config:
SELINUX=enforcing
SELINUXTYPE=targeted
SELINUXTYPE=targeted is the policy shipped with RHEL/Fedora. It confines specific known services (Apache, MySQL, sshd, etc.) and leaves everything else unconfined. The alternative (mls) is for multi-level security environments and you’re not using it unless you explicitly need to.
Security contexts
Every file has a context. Check with ls -Z:
|
|
Breaking that down: user:role:type:level. For most purposes, only the type matters: httpd_sys_content_t. That’s the label that says “this is content Apache is allowed to serve”.
Every process has a context. Check with ps -Z:
|
|
The Apache process is running as type httpd_t. The policy says httpd_t can read httpd_sys_content_t, which is why Apache can serve the file.
Sockets, pipes, shared memory, IPC — all have types too. ls -Z works on most things; netstat -Z and lsof -Z exist for process/socket context.
The #1 most common problem
“I moved a file and now the service can’t read it.”
Moving a file with mv preserves its SELinux context. Copying with cp usually gives it the context of the destination directory. A file created inside a directory inherits the directory’s type.
So if you wget an index.html to /root/ and then mv it to /var/www/html/, it has the context of /root/ (admin_home_t or similar), not httpd_sys_content_t. Apache denies it. The file is there, permissions look right, but SELinux says no.
The fix is never “disable SELinux”. The fix is to restore the right context:
|
|
restorecon looks up what the context should be based on the system’s file context rules, and applies it. For a whole directory tree:
|
|
-R for recursive, -v to see what changes. That’s it. Service now works. SELinux remains enabled.
This single command fixes 60% of SELinux “problems”.
Label rules: where contexts come from
The system knows what context a path should have via file context rules. View them:
|
|
The left column is a regex on paths; the right column is the default context. restorecon consults this to decide what label to apply.
Adding your own labels
You want to serve content from /srv/webdata/. Standard path would be /var/www/html/ but you have reasons. You need to tell SELinux “treat anything under /srv/webdata/ as httpd content”:
|
|
First command adds a rule. Second applies it to existing files. Now the labels will survive restorecon runs and persist across reboots.
If you later want to delete the rule:
|
|
semanage persists changes. chcon is a related but discouraged command that changes a file’s context without updating the rules — its changes get reverted on the next restorecon. Use semanage fcontext -a + restorecon, not chcon, for anything you want to keep.
Booleans: policy tunables
Many SELinux policies have booleans — on/off switches for specific behaviors. Listed with:
|
|
Example output:
httpd_can_network_connect --> off
httpd_can_network_connect_db --> off
httpd_can_sendmail --> off
httpd_enable_cgi --> on
httpd_execmem --> off
httpd_read_user_content --> off
...
Each boolean toggles an entire cluster of policy rules. httpd_can_network_connect is the famous one: by default, Apache (httpd_t) cannot initiate outbound TCP connections. If your PHP app needs to hit an external API:
|
|
The -P flag makes the change persistent across reboots. Without it, it’s runtime only.
The boolean database is how SELinux supports common administrator decisions without requiring policy editing. Before writing custom policy, check if there’s a boolean that does what you want.
audit2allow: when a boolean isn’t enough
When a service does something the shipped policy doesn’t permit, SELinux denies it and logs an AVC (Access Vector Cache) message. Find them:
|
|
Or in the journal:
|
|
A typical denial looks like:
type=AVC msg=audit(1712345678.123:456): avc: denied { write } for pid=1234 comm="myapp" name="log.txt" dev="sda1" ino=12345 scontext=system_u:system_r:myapp_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file
Read it as: “process with context myapp_t tried to write to a file with context user_home_t, and that’s not allowed”.
Now you have choices:
- Is this a mislabeled file? Does the file actually belong somewhere
myapp_tcan write?restoreconorsemanage fcontext. - Is there a boolean for this?
getsebool -a | grep myapp. Flip if appropriate. - Is this truly a new access the policy should allow? Write a custom policy module.
For option 3, audit2allow translates denials into policy:
|
|
Always read the .te file before loading. audit2allow just transcribes denials into rules. If the denial was a legitimate security boundary that the process shouldn’t be crossing (a compromised process trying to read /etc/shadow, say), blindly loading the module undoes the protection.
Reasonable rules to allow: myapp_t writing its own log in its own directory, myapp_t reading a config file, myapp_t connecting to a specific port. Rules to never allow: writing to /etc, reading from random user home directories, executing shell interpreters from unexpected places.
Port labels
Network ports have contexts too. By default, httpd_t can bind to http_port_t, which maps to 80, 443, 8008, 8009, 8443 (typical HTTP-ish ports). If you want Apache on port 8888:
|
|
Now Apache can bind port 8888. Without this, Apache fails to start with a cryptic error about permission denied on bind.
Same pattern for any service on a non-default port: find the *_port_t for the service, add your port, restart.
sealert: the troubleshooter
The setroubleshoot-server package watches the audit log, correlates denials, and writes human-readable explanations to the journal. Install:
|
|
After it runs for a while, check:
|
|
Or:
|
|
Output includes an English description of what was denied, why, and — critically — suggested fixes: “try restorecon”, “try enabling boolean X”, “if you believe this should be allowed, run ausearch -c 'myapp' --raw | audit2allow -M my-myapp”.
For most problems, sealert’s suggestion is the right fix. It’s the closest thing SELinux has to a “helpful assistant”.
Common recipes
NFS home directories
|
|
Without this, SELinux treats NFS-mounted home directories as untrusted foreign storage and denies most operations on them.
Apache accessing a non-standard directory
|
|
Apache writing to a directory (user uploads, cache)
|
|
Notice the different type: httpd_sys_rw_content_t allows writes, httpd_sys_content_t is read-only.
Systemd service running as a non-standard binary
If you wrote a custom service and SELinux is confining it via the init_t rules without a specific policy, use chcon to run it as bin_t or unconfined_service_t for initial testing:
|
|
For a production service you care about, write a targeted policy module.
Container workloads
Containers get labeled with unique MCS categories (s0:c1,c2, etc.) so that containers can’t read each other’s files even if they share volumes. Podman and Docker handle this automatically. If you bind-mount a host directory into a container with -v /host/path:/container/path:z, the z flag tells the runtime to relabel the host directory with container-accessible labels. Use :Z (capital) for private relabel (only this container), lowercase :z for shared.
Without :z or :Z, the container typically can’t read the host directory, and the symptom looks like “my volume mount is empty”.
Permissive domains
Sometimes you want SELinux enforcing globally but permissive for one specific service while you’re debugging its policy:
|
|
All other types stay enforcing. Only httpd_t gets permissive treatment. Perfect for developing a new policy — denials are logged but Apache still works.
Remove when done:
|
|
This is the right way to debug policy. Setting the whole system to permissive is overkill.
When to actually disable SELinux
Honest answer: almost never on a server running a mainstream workload. The distros ship policies that cover the common services well. Custom policy for your own services is a few hours of work with audit2allow and semanage.
Exceptions where temporary disable is defensible:
- Rescue mode — something is preventing boot, you need to investigate, SELinux is secondary. Boot with
selinux=0on the kernel command line, fix the underlying problem, re-enable. - Bringing up a new kernel + policy combination that’s broken. Very rare; you’ll know.
- Software genuinely incompatible with SELinux. Some old proprietary software. In that case, run it in a container or VM with SELinux, not on the host.
If you find yourself disabling SELinux on every machine because “it never works”, you’re doing it wrong. The fix is restorecon and audit2allow, not SELINUX=disabled.
Files you should know about
/etc/selinux/config— persistent mode setting./var/log/audit/audit.log— raw audit events including AVC denials./etc/selinux/targeted/contexts/files/file_contexts.local— your customsemanage fcontextrules live here./var/log/messages(or journal) — setroubleshoot output.
Tools checklist
Every SELinux user should have these in muscle memory:
ls -Z/ps -Z— see contexts.restorecon -Rv <path>— fix labels to policy defaults.semanage fcontext -a -t <type> <regex>+restorecon— add a label rule.getsebool -a/setsebool -P <bool> on— check and flip booleans.semanage port -a -t <type> -p tcp <port>— add a port label.ausearch -m AVC -ts recent— recent denials.audit2allow -M <name>— generate a policy module from denials.semodule -i <name>.pp/semodule -r <name>— load/remove a module.sealert -a /var/log/audit/audit.log— English-language analysis.semanage permissive -a <type>— permissive mode for one type.
Ten commands. Memorize them and most SELinux problems resolve in minutes.
Reading policy
For advanced troubleshooting, read the actual policy rules:
|
|
Output shows every rule allowing httpd_t to do anything on httpd_sys_content_t. Helps when you need to understand why something is or isn’t allowed.
|
|
Shows type attributes and which types it’s equivalent to.
These are expert-level commands; most operators never need them. But they exist when the policy does something counterintuitive and you need to understand why.
Writing custom policy
audit2allow generates modules from denials. For cleaner, intentional policy, write a .te file yourself:
# myapp.te
module myapp 1.0;
require {
type myapp_t;
type var_log_t;
class dir { write add_name };
class file { create write open };
}
allow myapp_t var_log_t:dir { write add_name };
allow myapp_t var_log_t:file { create write open };
Compile and load:
|
|
Most real policy modules are more complex, using macros from the reference policy. For RHEL-packaged services (httpd, nginx, postgresql), the shipped policy is comprehensive; you’re usually extending it with small modules or adjusting labels, not writing from scratch.
Common myths
- “SELinux is only for military systems.” No, the targeted policy is designed for general server use.
- “SELinux is deprecated in favor of AppArmor.” Different distros, different defaults. RHEL/Fedora ship SELinux, Ubuntu/SUSE ship AppArmor. Both are live and maintained.
- “SELinux breaks containers.” Modern container runtimes handle SELinux correctly. The
:z/:Zvolume flags are the main thing to remember. - “Once enabled, it’s too late to disable.” You can flip modes anytime. Reboot required only if transitioning from disabled to enabled (or vice-versa).
- “The kernel slows down with SELinux.” Measurable overhead is small (~1-2%), and for security-sensitive workloads it’s trivial. For extremely latency-sensitive workloads, benchmark your specific case.
An operating posture
For a production server:
- Keep SELinux enforcing. Not optional. The default.
- Install
setroubleshooton non-production boxes. It’s helpful for learning but noisy for production. - When a service breaks after an install or upgrade, first run
restorecon -Rvon its data directories. - When it’s still broken, check
ausearch -m AVC -ts recentandsealert. - Before writing custom policy, check for a boolean.
- When writing custom policy, read the
.teoutput ofaudit2allowbefore installing it. - Never
setenforce 0in production to make a problem go away. If it’s urgent, set one specific type permissive:semanage permissive -a <type>.
When things still won’t work
A short triage:
- Is the file actually there and readable by DAC?
ls -l(without-Zfirst). ls -Zthe file andps -Zthe process. Do the types make sense?restorecon -vthe file. Did anything change?ausearch -m AVC -ts recent. Is there a denial?sealert. What does it suggest?- Is there a boolean?
getsebool -a | grep <service>. - If a port is involved,
semanage port -l | grep <port>. Does the port have the right label? - Temporary:
semanage permissive -a <type>. Does it work now? If yes, the policy is the issue; refine withaudit2allow. If no, SELinux is not the problem — something else is broken.
SELinux is the Linux security feature most commonly disabled by the people who most need it. The tooling is there, the docs are there, and most problems have five-minute fixes that preserve the protection. The mental model is small: types, contexts, allow rules. Once you’ve used restorecon, semanage fcontext, and audit2allow a few times, SELinux goes from “the thing that breaks everything” to “the thing that quietly does its job”. Keep it enforcing.
Comments