SELinux Without Turning It Off

Reading denials, writing policy, and other things you skipped the first time

Contents

Every homelab has a moment where a service refuses to start, the logs are useless, and setenforce 0 is sitting right there in your shell history from the last time this happened. I’ve typed it more times than I’d like to admit. It fixes the immediate problem the way unplugging a smoke alarm fixes a burning pan — technically true, and a bad habit to build.

SELinux gets a reputation as the thing you disable during setup, right after you set a root password you’ll forget. That reputation is earned by tooling that used to be genuinely hostile to newcomers, but it’s stale. Modern SELinux on Fedora, RHEL, Rocky and Alma ships with tools that turn “why won’t this start” into a five-minute diagnosis, and once you’ve done it a few times it stops being scary. This post is the workflow I actually use: read the denial, understand why it happened, and either fix the context or write a scoped policy module — never reach for the global off switch.

Why bother, when AppArmor and firewalls already exist

Advertisement

Discretionary access control — the standard Unix permission model — answers one question: does this user own this file. That’s it. If a process runs as root, or runs as a user with access to a file, DAC has nothing more to say. A compromised web server running as www-data that can write to /var/www can, under plain DAC, also read /etc/shadow if some other bug lets it, because the kernel isn’t tracking what kind of thing a process is, only who it’s running as.

SELinux adds mandatory access control on top. Every process and every file gets a context — a label like httpd_t for the process type or httpd_sys_content_t for a file type — and the kernel enforces a policy that says which process types can touch which file types, regardless of Unix ownership. A web server process labelled httpd_t can read files labelled httpd_sys_content_t, full stop, even if it’s somehow running as root and the files are owned by someone else. If an attacker pops a shell through that same web server, they inherit httpd_t, and httpd_t still can’t read /etc/shadow, write to /home, or open a listening socket on a port the policy hasn’t allowed. That containment is the entire point, and it’s why “just turn it off” throws away a real security layer for the sake of avoiding twenty minutes of reading.

AppArmor does something similar by path instead of by label, and it’s genuinely easier to hand-write rules for. But path-based confinement is weaker against symlink games and doesn’t give you the same fine-grained boolean toggles SELinux does for common “yes but only for this specific thing” cases. I run AppArmor on Debian-family boxes and SELinux on Fedora-family ones, and I don’t think either is objectively superior — but if your distro ships SELinux by default, learning to work with it is cheaper than fighting it.

There’s also a performance question people ask before they’ve even tried it, expecting a labelling and policy-lookup layer on every file access to cost something noticeable. In practice, on any CPU from the last decade, the overhead is close to unmeasurable for typical homelab workloads — the policy is a compiled binary structure the kernel checks against a cache, not a script interpreted per syscall, and the cache hit rate on a steady-state service is high enough that the cost disappears into noise well below what you’d notice compared to disk or network latency. The reputation for SELinux being “slow” is really a reputation for being unfamiliar, carried over from a decade-plus-old set of complaints about early implementations that have long since been optimised.

Targeted versus MLS, and why targeted is almost always the right default

Most distros ship SELinux in targeted policy mode, which confines a specific, curated list of network-facing daemons and system processes while leaving ordinary user processes running under unconfined_t — effectively no mandatory access control at all for anything not on the list. MLS (Multi-Level Security) is the stricter mode, confining everything under a lattice of security levels originally designed for military and government classification systems, and it’s a genuinely different tool: heavier to administer, requires thinking in terms of security clearances rather than service types, and overkill for a homelab where the goal is “contain the web server, not model a classified document hierarchy.” Targeted policy already covers the processes that actually face the network — SSH, web servers, DNS resolvers, container runtimes — which is where almost all real-world compromises start. Switching a homelab box to MLS is one of the few SELinux decisions I’d actively discourage unless you have a specific compliance reason to model information flow that granularly.

Reading a denial

Advertisement

The instinctive test is “did it break”: start the service, watch it fail, check journalctl. SELinux denials show up in the audit log, and the raw form is dense:

1
type=AVC msg=audit(1729590142.221:412): avc:  denied  { write } for  pid=18422 comm="nginx" name="cache" dev="dm-0" ino=798234 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:var_t:s0 tclass=dir permissive=0

Read it right to left in plain English: the process labelled httpd_t (scontext) tried to write to a directory labelled var_t (tcontext), and policy said no (permissive=0 means enforcing mode actually blocked it, rather than just logging what it would have blocked).

That’s the whole story: I moved nginx’s cache directory to a path that was never relabelled, so it inherited the generic var_t type from its parent instead of the httpd_cache_t type nginx policy expects. This is the single most common SELinux “bug” you’ll hit: a mislabelled file, almost always because you moved or created something outside the paths the policy already knows about, rather than an actual gap in the policy itself.

ausearch gives you a friendlier view of the same events:

1
2
3
4
$ sudo ausearch -m avc -ts recent
----
time->Tue Oct 22 09:41:52 2024
type=AVC msg=audit(1729590112.221:412): avc:  denied  { write } for  pid=18422 comm="nginx" name="cache" dev="dm-0" ino=798234 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:var_t:s0 tclass=dir permissive=0

And sealert, if you’ve got setroubleshoot installed, translates it into a suggested fix in plain language, complete with the exact command to run. On a headless box I skip the GUI dependency and just read the AVC directly — once you’ve done it a dozen times, the format stops being opaque.

Fixing it the right way: contexts first, booleans second, custom policy last

There’s a hierarchy to SELinux fixes, and most people skip straight to the bottom of it.

First, check whether it’s a labelling problem. restorecon resets a file’s context to what policy says it should be, based on its path:

1
2
3
$ sudo semanage fcontext -a -t httpd_cache_t "/srv/nginx/cache(/.*)?"
$ sudo restorecon -Rv /srv/nginx/cache
Relabeled /srv/nginx/cache from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_cache_t:s0

The semanage fcontext call adds a permanent rule to the local policy store so the label survives a full restorecon -R / or a future package update; without it, restorecon has nothing to restore to and just leaves the file as-is. This fixes the overwhelming majority of “I moved my data directory somewhere else” denials, and it’s the correct fix precisely because it doesn’t grant any new permission — it just tells the file what it already should have been.

Second, check whether there’s already a boolean for what you’re trying to do. SELinux ships hundreds of booleans — toggles for specific, already-considered scenarios. Want nginx to make outbound connections (for a reverse proxy setup, say)?

1
2
3
$ getsebool -a | grep httpd | grep connect
httpd_can_network_connect --> off
$ sudo setsebool -P httpd_can_network_connect on

The -P makes it persistent across reboots. Booleans are the second rung of the ladder because someone already scoped the exact permission you need and audited it; you’re not inventing new policy, just flipping a switch someone else built.

Third, and only when neither of those covers it, write custom policy. This is for genuinely unusual access patterns — a service that needs to read a directory type it has no business reading by default. Capture the denial and generate a module:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ sudo ausearch -m avc -ts recent | audit2allow -M mynginx_extra
$ cat mynginx_extra.te
module mynginx_extra 1.0;

require {
    type httpd_t;
    type var_t;
    class dir write;
}

#============= httpd_t ==============
allow httpd_t var_t:dir write;

$ sudo semodule -i mynginx_extra.pp

Read the generated .te file before installing it. audit2allow will happily generate a rule for exactly what was denied, no more, no less — which is exactly what you want, but it has no opinion on whether that access is actually a good idea. If the denial was httpd_t trying to write to shadow_t, audit2allow will cheerfully write you a policy that grants it, and now you’ve built yourself a hole with a build script. Treat its output as a draft, not an answer.

A habit worth building here is reading the require block as carefully as the allow line. A single denial sometimes generates a module that requires half a dozen types and classes because the audit log captured a burst of related-but-distinct denials in one session, and installing the whole module grants every one of them even if only one was actually load-bearing for the service to start. Comment out the lines you’re not sure about, reload with just the ones you understood, and add the rest back only if the service still complains — it’s slower than installing the whole module in one shot, but it means the policy you end up with maps to something you can actually explain later, rather than a grab-bag of permissions nobody remembers the reasoning for.

Living in permissive mode long enough to learn, not forever

There’s a legitimate middle ground between “enforcing and denying things I don’t understand” and “disabled”. Permissive mode logs every denial without blocking it:

1
2
3
$ sudo setenforce 0
$ getenforce
Permissive

Run your service through its full startup and normal operation in permissive mode, collect every AVC that would have fired, fix each one properly with contexts and booleans, then flip back:

1
$ sudo setenforce 1

The difference between this and disabling SELinux entirely is that permissive mode is temporary and diagnostic — you’re using it to build a complete picture before re-enabling enforcement, not living there. Set it in /etc/selinux/config only if you genuinely need it to survive a reboot for a longer audit window, and put a reminder somewhere you’ll actually see it to flip it back.

You can also scope permissive mode to a single domain instead of the whole system, which is a much better default while iterating on one troublesome service:

1
2
3
$ sudo semanage permissive -a httpd_t
# ... do your testing, everything else stays enforcing ...
$ sudo semanage permissive -d httpd_t

That way a debugging session on nginx doesn’t also quietly disable containment for every other confined process on the box.

Troubleshooting

Advertisement

“Denied” in the log but the boolean I need doesn’t exist. Not every scenario has a pre-built boolean. Check with semanage boolean -l | grep <service> for the full list before assuming you need custom policy — there are more booleans than most guides mention, covering things like httpd_can_sendmail or httpd_enable_homedirs. If nothing fits, custom policy via audit2allow is the correct next step, not a failure.

restorecon says “no default context” and does nothing. That means there’s no fcontext rule for the path at all, not even a wrong one. Add one with semanage fcontext -a before restorecon has anything to apply — this is normal for paths well outside the usual filesystem hierarchy, like a data directory under /opt or a second disk mounted somewhere unusual.

Service works after a manual fix but breaks again on reboot. You fixed the label with chcon instead of semanage fcontext. chcon changes the context immediately but doesn’t persist it anywhere — the next restorecon -R / (which some package updates trigger) or the next full relabel wipes it out. Always follow a chcon fix with the matching semanage fcontext -a rule, or better, skip chcon and just use semanage + restorecon from the start.

audit2allow output looks huge for one denial. You probably fed it a log with unrelated denials mixed in. Narrow the time window with ausearch -ts today or filter by comm= for the specific process before piping into audit2allow, otherwise you’ll bundle unrelated grants into one module and lose track of what each rule was actually for.

Container workloads denied even though the app is fine standalone. Podman and Docker containers get their own SELinux type (container_t by default) and their bind-mounted volumes need the :z or :Z suffix to get relabelled for container access — -v /srv/data:/data:Z relabels the host path exclusively for that container, :z shares it across multiple containers. Forgetting the suffix is the single most common SELinux complaint from people running containers, and it’s a one-character fix.

Is it worth it

If you’re on a Fedora-family distro, yes — SELinux is already there, already enforcing, and disabling it throws away real containment for the sake of avoiding a workflow that, once learned, takes minutes per incident. The ausearch → identify cause → semanage fcontext/setsebool/audit2allow ladder covers the overwhelming majority of denials you’ll ever see on a homelab box, and none of it requires memorising policy syntax up front.

Where I wouldn’t bother: a throwaway VM you’re going to nuke in a week, or a distro where SELinux isn’t the native model and you’d be fighting the packaging as much as the policy. For anything that stays up and faces the network — which, if you’ve done any hardening at all on the box per my SSH hardening walkthrough, you presumably care about — leaving mandatory access control enabled is one of the highest-leverage things you can do for the effort involved. It’s also worth pairing with a periodic outside-in audit; Lynis will flag SELinux status as one of its hardening-index checks, which is a good forcing function to notice if it’s silently drifted back to permissive.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.