TL;DR
Something broke and you suspect SELinux. Do not run setenforce 0. Find the denial, read what it actually blocked, then apply the one fix that matches the cause. Three quarters of real denials are a wrong file label, an unlabeled port, or a boolean that is off.
$ sudo ausearch -m AVC,USER_AVC,SELINUX_ERR,USER_SELINUX_ERR -ts recent $ sudo ausearch -m AVC -ts recent | audit2why # then, depending on the cause: $ sudo restorecon -Rv /path # wrong file context $ sudo semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?" && sudo restorecon -Rv /srv/www $ sudo semanage port -a -t http_port_t -p tcp 8888 # non-standard port $ sudo setsebool -P httpd_can_network_connect_db on # optional behavior gated by a boolean
Why SELinux denies things, in one paragraph
SELinux is mandatory access control by labels. Every process and every file carries a security context (you see it with ls -Z and ps -Z), and the loaded policy is a giant list of which subject labels may act on which object labels. When a subject tries something the policy has no allow rule for, the kernel blocks it and writes an AVC (Access Vector Cache) denial to the audit log. A denial is not a bug and not a mystery. It is the policy doing exactly its job, and it comes with enough detail to fix the one thing that is actually wrong.
That is why disabling SELinux is the wrong reflex. It does not fix the cause, it removes the seatbelt for every process on the box to work around one misbehaving service. The correct loop is always the same: find the denial, read it, understand the cause, apply the matching fix. Below is that loop with the real commands and the real output.
Step 1: confirm it is actually SELinux
Before you touch anything, make sure the failure is a denial and not a plain permission or config error. Check the mode, then look for a recent AVC:
$ getenforce Enforcing $ sudo ausearch -m AVC -ts recent <no matches>
If ausearch says <no matches> while your service still fails, the problem is very likely not SELinux at all: check file ownership, the service config, and the actual error in journalctl -u yourservice first. Chasing SELinux for a non-SELinux failure is how people end up disabling it and still being broken. If there is a matching AVC, keep going.
Step 2: read the denial
The audit daemon logs denials to /var/log/audit/audit.log. Do not grep that file raw when ausearch can parse it for you. This is the search I start every SELinux investigation with:
$ sudo ausearch -m AVC,USER_AVC,SELINUX_ERR,USER_SELINUX_ERR -ts recent
----
time->Wed Jul 22 14:03:11 2026
type=AVC msg=audit(1753192991.412:812): avc: denied { read } for
pid=1789 comm="nginx" name="index.html" dev="vda1" ino=8409123
scontext=system_u:system_r:httpd_t:s0
tcontext=unconfined_u:object_r:user_home_t:s0
tclass=file permissive=0
Read it left to right. The subject (scontext) is httpd_t, the nginx worker. It tried to { read } a file whose label (tcontext) is user_home_t. A web server process is not allowed to read files labeled as home-directory content, so the kernel said no. permissive=0 confirms the denial was enforced, not just logged. Now you know the exact who, what and target. That is enough to pick the fix, but there is a tool that spells out the cause for you.
Step 3: understand the cause with audit2why (and sealert)
Pipe the denial into audit2why. It reads the same AVC and tells you which category of cause it is:
$ sudo ausearch -m AVC -ts recent | audit2why
type=AVC msg=audit(1753192991.412:812): avc: denied { read } for pid=1789 comm="nginx" ...
Was caused by:
Missing type enforcement (TE) allow rule.
You can use audit2allow to generate a loadable module to allow this access.
"Missing type enforcement allow rule" almost always means a labeling problem: the target has the wrong context, and once you relabel it the existing policy already allows the access. audit2why only suggests audit2allow as the generic escape hatch. Resist it here, because a custom module to let httpd read user_home_t would be far too broad. Fix the label instead (Step 4a).
When the cause is a boolean, audit2why is even more helpful and prints the exact command:
Was caused by:
One of the following booleans was set incorrectly.
Description:
Allow httpd to connect to the network
Allow access by executing:
# setsebool -P httpd_can_network_connect 1
For a friendlier, plain-English report with confidence-ranked suggestions, install setroubleshoot and use sealert. It is not on minimal images, so install it first:
$ sudo dnf install -y setroubleshoot-server
$ sudo sealert -a /var/log/audit/audit.log
SELinux is preventing nginx from read access on the file index.html.
***** Plugin restorecon (92.2 confidence) suggests ************************
If you want to fix the label,
/srv/www/mysite/index.html default label should be httpd_sys_content_t.
Then you can run restorecon. The access attempt may have been stopped due to
insufficient permissions to access a parent directory in which case try to
change the following command accordingly.
Do
# /sbin/restorecon -v /srv/www/mysite/index.html
sealert is the single most useful tool for someone new to SELinux, because it names the fix in one line. Just do not paste its suggestion blindly: read which plugin fired and at what confidence, then apply the matching step below.
Step 4: apply the fix that matches the cause
4a. Wrong file context: restorecon, or semanage fcontext first
If the file lives under a path the policy already knows about (like /var/www/html) and just got the wrong label (common after you move a file with mv, which keeps the old context), restorecon resets it to the policy default:
$ ls -Z /var/www/html/index.html unconfined_u:object_r:user_home_t:s0 /var/www/html/index.html $ sudo restorecon -v /var/www/html/index.html Relabeled /var/www/html/index.html from unconfined_u:object_r:user_home_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
But if you serve from a non-standard docroot, restorecon alone is not enough: the policy default for /srv/www is var_t, not web content, so relabeling would just restore the wrong label. You have to teach the policy the new default with semanage fcontext, then relabel:
$ sudo semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?"
$ sudo restorecon -Rv /srv/www
Relabeled /srv/www from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
Relabeled /srv/www/mysite from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
Relabeled /srv/www/mysite/index.html from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
The semanage fcontext rule is the permanent part: it survives a full filesystem relabel and reboots. The (/.*)? at the end is a regex meaning "this directory and everything under it". This is the step most quick tutorials skip, which is why their restorecon advice "does nothing" for people using a custom path.
4b. Non-standard port: semanage port
Run a service on a port its domain does not own and you get a name_bind denial. Say nginx on 8888:
$ sudo ausearch -m AVC -ts recent
type=AVC msg=audit(1753193440.221:851): avc: denied { name_bind } for
pid=2043 comm="nginx" src=8888
scontext=system_u:system_r:httpd_t:s0
tcontext=system_u:object_r:unreserved_port_t:s0
tclass=tcp_socket permissive=0
The port label (unreserved_port_t) is not in httpd's allowed set. Add 8888 to the http port type and confirm:
$ sudo semanage port -a -t http_port_t -p tcp 8888
$ sudo semanage port -l | grep '^http_port_t'
http_port_t tcp 8888, 80, 81, 443, 488, 8008, 8009, 8443, 9000
8888 is now a valid http port for the policy and nginx binds. Reboot-safe, no module, no disabling anything.
4c. Optional behavior gated by a boolean: setsebool -P
Many perfectly legitimate actions are off by default and guarded by a boolean, for example a web app talking to a remote database. That shows up as a name_connect denial, and audit2why names the boolean. Find the relevant switches, flip the right one, confirm:
$ getsebool -a | grep httpd_can_network httpd_can_network_connect --> off httpd_can_network_connect_cobbler --> off httpd_can_network_connect_db --> off $ sudo setsebool -P httpd_can_network_connect_db on $ getsebool httpd_can_network_connect_db httpd_can_network_connect_db --> on
The -P is not optional. Without it the change applies now but reverts on reboot, and you will be back here in a month wondering why the app broke after a routine restart. -P writes the boolean to persistent policy. Prefer a specific boolean (_db) over the broad one (httpd_can_network_connect) whenever the specific one covers your case.
Step 5: last resort, a custom module with audit2allow
Sometimes there genuinely is no boolean, no context and no port label that covers what you need, for instance a custom daemon doing something unusual. Only then do you write a policy module, and only after you have read what it will allow. audit2allow generates it from the denials:
$ sudo ausearch -m AVC -ts recent | audit2allow -M my-customdaemon ******************** IMPORTANT *********************** To make this policy package active, execute: semodule -i my-customdaemon.pp $ cat my-customdaemon.te module my-customdaemon 1.0; require { type httpd_t; type user_home_t; class file read; } #============= httpd_t ============== allow httpd_t user_home_t:file read;
Read the .te file before you install the .pp. That example rule would let httpd read every file labeled user_home_t on the system, which is exactly the over-broad grant you were trying to avoid. If, and only if, the rule is narrow and you understand it, install and later remove it like this:
$ sudo semodule -i my-customdaemon.pp $ sudo semodule -l | grep my-customdaemon my-customdaemon $ sudo semodule -r my-customdaemon # to remove it later
Piping ausearch ... | audit2allow -M without reading the output is the SELinux equivalent of chmod 777. It usually works, and it usually opens a hole. The whole point of this post is to reach for Step 4 first and land here almost never.
The diagnostic tool people confuse with the off switch: permissive per domain
When a service hits denials in several places at once, fixing them one reboot at a time is slow, because SELinux stops at the first denial and you never see the next three. You can make one domain permissive so it logs denials without blocking, collect the full set in a single run, fix them all, then turn enforcement back on for that domain:
$ sudo semanage permissive -a httpd_t
$ sudo semanage permissive -l | grep httpd
httpd_t
Now exercise the service, gather every AVC with ausearch, apply the real fixes from Step 4, and immediately undo the permissive domain:
$ sudo semanage permissive -d httpd_t
$ getenforce
Enforcing
This is not setenforce 0. The rest of the system stays enforcing the entire time; only the one domain you are debugging is relaxed, and only until you finish. Used this way it is a scalpel. Left on, it is a hole with a nicer name. Never ship a box with a permissive production domain.
If this fails
ausearchreturns<no matches>but the app still fails: either auditd is not running (sudo systemctl status auditd) or the denial is being silenced by a dontaudit rule. Temporarily reveal suppressed denials withsudo semodule -DB, reproduce, investigate, then rebuild the policy withsudo semodule -Bto restore the dontaudit rules.semanage port -asaysValueError: Port tcp/8080 already defined: that port is already owned by another type (8080 ishttp_cache_port_t). Use-mto modify instead of-ato add:sudo semanage port -m -t http_port_t -p tcp 8080.- Your boolean reverts after a reboot: you left off
-P. Re-run assudo setsebool -P name on. sealert: command not found: it is in setroubleshoot-server, which is not on minimal or container images.sudo dnf install -y setroubleshoot-server.restoreconruns but the denial comes right back: your file is under a custom path whose policy default is wrong. You skipped thesemanage fcontext -astep in 4a. Add the fcontext rule, then relabel again.- You set the one domain permissive and the service still fails: that is your proof the problem is not SELinux. Look at ownership, the service config, or the network, then remove the permissive domain.
Wrap up
The method never changes: ausearch to find it, audit2why or sealert to understand it, then the one matching fix (relabel, boolean, or port), with a custom module only as a rare, read-first last resort. Do this a dozen times and denials stop feeling like walls and start feeling like log lines with an obvious next command, which is the whole reason SELinux stayed enforcing back in the initial server setup instead of being switched off "to avoid problems". Keeping the seatbelt on and knowing how to read it is the entire difference between an amateur box and a professional one, and it is the line this domain has stood on since 2003.