Complete Diagnostic Guide for SELinux 'Permission denied' — When chmod Looks Fine but Access Is Still Blocked
The Real Cause of 'Permission denied' That Survives chmod 777
You've already run chmod 777 and even chown nginx:nginx, but nginx still returns 502, the page throws 403, or the app log shows Permission denied. ls -l looks perfectly fine. The first thing to suspect here is SELinux.
The chmod/chown we all know is DAC (Discretionary Access Control). On RHEL, Rocky, and AlmaLinux 9/10, SELinux (MAC, Mandatory Access Control) is enabled in Enforcing mode by default. Even if DAC lets you through, the kernel blocks the request again if the MAC label (context) doesn't match. That's why you get denied even when permissions look correct.
This is not a SELinux theory lecture. It's a practical guide that organizes commands to unblock access right now, by case. If you're in a hurry, confirm the culprit in Step 1, then copy-paste only the block that matches your symptom in Step 2.
Step 1 — Confirm SELinux Is the Culprit in 30 Seconds
First, check whether SELinux is on with a single command.
getenforce
# Enforcing → SELinux is likely blocking you
# Permissive → logs only, does not block (suspect another cause)
# Disabled → SELinux is not the culpritIf it's Enforcing, capture the denial logs. To watch them in real time:
tail -f /var/log/audit/audit.log | grep denied
# In another terminal, retry the blocked action (e.g. refresh the page)For logs that already happened, use ausearch.
ausearch -m avc -ts recent # last 10 minutes
ausearch -m avc -ts today # all of todayThen translate why it was blocked into something a human can read.
ausearch -m avc -ts recent | audit2whyIf setroubleshoot is installed, sealert will even suggest a friendly fix.
dnf install -y setroubleshoot-server
sealert -a /var/log/audit/audit.logHere is where to look, by symptom.
| Symptom | What the log shows | Verdict |
|---|---|---|
| Permission denied / 403 reading a file | tclass=file, tcontext is default_t, user_home_t, etc. | File context (①) |
| The service can't open its port | { name_bind }, tclass=tcp_socket | Port label (②) |
| Reverse proxy 502, remote DB connection fails | { name_connect } | Boolean or port type (③) |
| A container can't read a mounted volume | tcontext is not container_file_t | Volume label (Podman :Z/:z) |
Anatomy of a Single avc: denied Log Line
A real log looks like this. The arrows show what to look at.
type=AVC msg=audit(1718440000.123:456): avc: denied { read } for
pid=2314 comm="nginx" ← ① who (process name)
name="index.html" dev="vda1" ino=12345
scontext=system_u:system_r:httpd_t:s0 ← ② source label (process)
tcontext=unconfined_u:object_r:user_home_t:s0 ← ③ target label (file) ← the culprit!
tclass=file permissive=0 ← ④ what was accessed (file/dir/tcp_socket…)The interpretation is simple: httpd_t (the web server) tried to read a file labeled user_home_t (home-directory label) and got blocked. Web content should be httpd_sys_content_t; the label is wrong, so this is a classic denial. The key clue is that tcontext is the wrong context.
Step 2 — Precise Fixes by Root Cause (The Core Section)
Looking at tclass and tcontext in the log, you can almost always tell which case you're in.
① Wrong file context (the most common case)
If tclass=file/dir and the label is something off like default_t, user_home_t, or var_t, it's a context problem. Check first:
ls -Z /var/www/html
# unconfined_u:object_r:default_t:s0 index.html ← wrongIf it's a standard path, restoring the policy default is enough.
restorecon -Rv /var/www/htmlComparing the label the policy expects with the actual label settles it.
matchpathcon /var/www/html/index.html # the label the policy expects (libselinux-utils)
restorecon -Rnv /var/www/html # -n: change nothing, only print what would changeChanging a label with chcon fixes it for now, but if it differs from the policy rules it reverts on the next restorecon or full relabel. The permanent fix is to register a rule with semanage fcontext and apply it with restorecon.
If you moved the web root to a non-standard path like /data/www, register a default context rule for that path first, then restore.
semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
restorecon -Rv /data/www② You need a policy for a non-standard port or directory
If you run nginx on a port the policy doesn't cover, you'll get a tclass=tcp_socket, name_bind denial. First check whether the port already has a type, then label it.
semanage port -l | grep -w 8090 # no output means the port isn't defined yet
semanage port -a -t http_port_t -p tcp 8090
# A port already defined with another type, like 8080 (http_cache_port_t),
# fails with -a ("Port tcp/8080 already defined"), so change it with -m
semanage port -m -t http_port_t -p tcp 8080
semanage port -l -C # show locally modified port definitions③ A boolean is turned off
This is the easiest case to miss. For example, if PHP needs to reach an external API or DB but httpd_can_network_connect is off, network connections get blocked. Check the current value, then turn it on.
getsebool -a | grep httpd
setsebool -P httpd_can_network_connect onBooleans that commonly get in the way:
| Boolean | Purpose | Command to enable |
|---|---|---|
httpd_can_network_connect | Web server outbound connections to external hosts/APIs | setsebool -P httpd_can_network_connect on |
httpd_can_network_connect_db | Web server connecting to a remote DB (MySQL/PostgreSQL) | setsebool -P httpd_can_network_connect_db on |
httpd_use_nfs | Using an NFS-mounted path as web content | setsebool -P httpd_use_nfs on |
httpd_enable_homedirs | Serving ~user/public_html | setsebool -P httpd_enable_homedirs on |
nis_enabled | Broad network access for NIS / external auth integration | setsebool -P nis_enabled on |
The
-Pflag is critical. Runningsetseboolwithout-Papplies the change in memory only, so it reverts after a reboot. If "I turned it on, but it blocked again after restart," suspect a missing-Pfirst. Permanent changes always need-P.
Container (Podman) volumes: if a container can't read a mounted directory, add :Z as in podman run -v /data:/data:Z to give it a label private to that container. For a directory shared by several containers, use :z (shared label). Don't use either on system directories like /home or /usr; it relabels them on the host.
④ You actually need a custom policy
If it's a legitimate action that doesn't match any of the three cases above, generate a dedicated policy module from the denial logs.
# 1) First read what would be allowed
ausearch -c 'nginx' --raw | audit2allow
# 2) Collect only nginx denials and generate a module (nginxlocal.te / nginxlocal.pp)
ausearch -c 'nginx' --raw | audit2allow -M nginxlocal
cat nginxlocal.te # review the allow rules by eye
# 3) Install only after reviewing
semodule -i nginxlocal.ppAfter installing, retry the action and verify that no new avc: denied entries appear. Check the module list with semodule -l | grep nginxlocal.
Step 3 — The Trap of Temporarily Bypassing with 'setenforce 0', and How to Use It Correctly
In a panic, setenforce 0 makes the problem vanish like magic. That's because Permissive mode logs denials without blocking them. Leaving a production server in Permissive, though, is the same as dropping your security controls entirely. Lateral movement and file access that SELinux would have stopped during a compromise all become open.
The correct workflow is not "turn it off" — it's "open it briefly for diagnosis."
Instead of switching the whole system to Permissive, making just the problem domain permissive keeps every other service protected.
semanage permissive -a httpd_t # only httpd_t (nginx/Apache) becomes permissive
# Reproduce the blocked actions to collect denials → fix them with ①–④ above
semanage permissive -d httpd_t # always remove it once diagnosis is done
semanage permissive -l # list permissive domainsIf you can't pin down the domain, you can briefly switch the whole system to Permissive instead.
setenforce 0 # briefly Permissive for diagnosis
# Reproduce every blocked action once → collect all denial logs
ausearch -m avc -ts recent | audit2allow -M myapp
semodule -i myapp.pp # apply the collected policy for real
setenforce 1 # return to EnforcingPermanently disabling it with SELINUX=disabled in /etc/selinux/config is a last resort. Once you boot disabled, file labels stop being updated, so turning it back on later requires a full filesystem relabel (touch /.autorelabel then reboot) — a heavy operational cost. Given that SELinux is increasingly enabled on cloud images and container hosts, the right approach is "handle it, don't disable it."
A common case: Intermittent 403s after every deploy often happen because CI
rsyncs files and overwrites the context todefault_t. Adding a singlerestorecon -Rv /var/www/htmlat the end of the deploy script stopped the recurrence. A large share of SELinux problems aren't "grand policy work" — they end with one line of context restore.
If you changed settings by hand on the server, record them in code with Ansible's community.general.sefcontext, ansible.posix.seboolean, and community.general.seport modules. Rebuilt servers then won't hit the same problem again.
Conclusion: Copy-Paste Command Cheatsheet
When permissions look fine but you still get denied, just follow this order.
| Symptom | Diagnosis | Fix |
|---|---|---|
| 403 / Permission denied (permissions look fine) | getenforce → Enforcing? | Continue below |
| Don't know which action was blocked | ausearch -m avc -ts recent | audit2why | Identify the cause branch |
File label is wrong (ls -Z) | tcontext is default_t or similar | restorecon -Rv /var/www/html |
| Non-standard web root | No label rule | semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?" → restorecon -Rv |
| Non-standard port | tclass=tcp_socket, name_bind | Undefined port: semanage port -a -t http_port_t -p tcp 8090; already-defined 8080: use -m |
| External connection / DB blocked | getsebool -a | grep httpd | setsebool -P httpd_can_network_connect on |
| None of the above | Legitimate action still denied | ausearch -c 'nginx' --raw | audit2allow -M nginxlocal && semodule -i nginxlocal.pp |
Closing checklist: ① Confirm the culprit with getenforce → ② Collect logs with ausearch → ③ Apply the command for your case → ④ Always use -P with setsebool → ⑤ After diagnosis, return to setenforce 1.
References: Official Docs
The primary source for the behavior, settings, and errors covered in this post is the following official documentation. Check version-specific options and exact behavior there.
FAQ
Q. Can I just allow everything in the policy that audit2allow generates?
A. That's dangerous. audit2allow only generates rules that "allow every blocked action"; it does not judge whether those actions are legitimate. Always pipe to | audit2allow first, read which permissions it would open, and module only what your legitimate workload needs. Allowing denials that came from a compromise creates a security hole.
Q. I enabled it with setsebool, but it blocks again after reboot.
A. You most likely omitted -P. Without -P it applies only in runtime memory and resets on reboot. Make it permanent with setsebool -P ....
Q. Can't I just turn SELinux off?
A. It's convenient in the moment, but not recommended. RHEL 9, Rocky, and AlmaLinux 9/10 default to Enforcing, and SELinux use is growing in cloud and container environments. If you need to diagnose, briefly collect logs with setenforce 0, apply a policy, then return to setenforce 1. That's the safer workflow.
Sources and checks vary by article. Only articles with a recorded check date, scope and sources below were checked, and only within that scope. For other articles, verify against the current docs for your version before applying anything, and email us about errors. Editorial policy · email us
This article has no recorded human check (date and scope) yet.
Comments
Be the first to comment.