The Problem: Mysterious Permission Denied Errors
You deploy a clean Nginx virtual host pointing to a custom document root such as /srv/www/site. Directory permissions look spotless: ls -l confirms ownership by nginx:nginx and permissions set to 755. Yet every test request throws an immediate 403 Forbidden.
Checking /var/log/nginx/error.log reveals an all-too-familiar entry: open() "/srv/www/site/index.html" failed (13: Permission denied). Standard UNIX permissions say the worker process has read access, but the kernel rejects the read operation anyway.
When faced with this wall, many administrators reach for the panic button: running setenforce 0 or setting SELINUX=disabled in /etc/selinux/config. It clears the error instantly. However, disabling Security-Enhanced Linux (SELinux) removes your strongest defense-in-depth barrier, leaving the host exposed if an application daemon is ever breached.
The Root Cause: Discretionary vs. Mandatory Access Control
Traditional Linux file security relies on Discretionary Access Control (DAC). Access depends strictly on owner IDs, group memberships, and standard rwx bits. If an attacker exploits a remote code execution bug in Nginx, they inherit the privileges of UID 998 (nginx). They can immediately read any file on the system where that user or group has permissions—including temporary database dumps or sensitive internal configs.
SELinux enforces Mandatory Access Control (MAC) directly inside the Linux kernel. Every process, file descriptor, directory, and network socket carries an explicit security context label. SELinux consults an active policy engine before granting access. Even if DAC permissions grant rwxrwxrwx (0777), SELinux terminates the request if the security policy forbids that specific domain-to-type interaction.
Core Concepts: Contexts, Modes, and Booleans
Effective SELinux administration comes down to three moving parts:
1. The Security Context
Every file and process label consists of four colon-separated fields: user:role:type:level. The third field—the type (or domain when assigned to a running process)—dictates Type Enforcement rules.
Inspect file contexts by appending the -Z flag to ls:
ls -lZ /var/www/html/index.html
# Output:
# -rw-r--r--. 1 root root system_u:object_r:httpd_sys_content_t:s0 242 Oct 12 10:14 index.html
Inspect running process contexts using ps:
ps -eZ | grep nginx
# Output:
# system_u:system_r:httpd_t:s0 1842 ? 00:00:01 nginx
In this targeted policy rule, the kernel allows any process confined to the httpd_t domain to read files labeled with the httpd_sys_content_t type.
2. Operating Modes
SELinux operates in one of three states:
- Enforcing: Rules apply strictly. Denied operations are blocked immediately and written to the audit log.
- Permissive: Denials are logged for debugging, but operations are allowed to complete. This mode is ideal for staging servers.
- Disabled: The SELinux kernel subsystem is completely turned off. Re-enabling it requires a full filesystem relabel on reboot.
Switch modes at runtime without rebooting:
# Check the current operational mode
getenforce
# Switch to Permissive mode for troubleshooting
sudo setenforce 0
# Revert to Enforcing mode once debugging is complete
sudo setenforce 1
3. SELinux Booleans
Booleans are runtime feature flags compiled into the security policy. Instead of writing custom policy code, you flip pre-built toggles to permit standard operations, such as allowing a web daemon to initiate network connections or run mail delivery agents.
Hands-On: Fixing Labels and Resolving Denials
Step 1: Install Core Administration Tools
On RHEL 9, Rocky Linux, AlmaLinux, or Fedora, install the policy utilities and audit analysis daemons:
sudo dnf install -y policycoreutils-python-utils setroubleshoot-server
Step 2: Correcting File Contexts Persistently
When you create a custom web directory such as /srv/www/site, the filesystem assigns it a fallback label—typically default_t or var_t. The httpd_t process domain cannot access these generic types.
Avoid using chcon. It changes labels only temporarily, and those manual edits will be wiped out during any maintenance relabel. Always write the context rule into the system policy store using semanage, then apply it using restorecon:
# 1. Register the persistent labeling rule in the policy database
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/www/site(/.*)?"
# 2. Apply the registered context recursively to disk
sudo restorecon -Rv /srv/www/site
If your application must handle user uploads or write cache files (such as WordPress caching in /srv/www/site/uploads), apply the read-write type httpd_sys_rw_content_t instead:
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/srv/www/site/uploads(/.*)?"
sudo restorecon -Rv /srv/www/site/uploads
Step 3: Toggling Network Access with Booleans
Suppose your Nginx web application needs to query a PostgreSQL database running on port 5432 or reach an upstream API over HTTPS. By default, the targeted policy prevents HTTP daemons from establishing outbound network sockets.
Search for relevant booleans using getsebool:
getsebool -a | grep httpd_can_network
Enable the exact capability you need. The -P flag writes the change permanently across system reboots:
# Allow web apps to connect strictly to relational databases
sudo setsebool -P httpd_can_network_connect_db on
# Alternatively, permit general outbound network connections
sudo setsebool -P httpd_can_network_connect on
Step 4: Parsing Denials with ausearch, sealert, and audit2allow
When the kernel blocks a system call, it writes an Access Vector Cache (AVC) record to /var/log/audit/audit.log:
type=AVC msg=audit(1710328912.421:842): avc: denied { read } for pid=1842 comm="nginx" name="index.html" dev="nvme0n1p2" ino=2097156 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=0
Isolate recent denials using ausearch:
sudo ausearch -m AVC -ts recent
To get a plain-English explanation of why the action failed and how to resolve it, feed the log into sealert:
sudo sealert -a /var/log/audit/audit.log
When working with custom internal daemons or third-party binary agents where no boolean exists, generate a tailored Type Enforcement module using audit2allow:
# 1. Preview the human-readable policy draft generated from denial logs
sudo grep "mydaemon" /var/log/audit/audit.log | audit2allow -m mydaemon_custom
# 2. Compile the rules into a source file (.te) and binary policy package (.pp)
sudo grep "mydaemon" /var/log/audit/audit.log | audit2allow -M mydaemon_custom
# 3. Always inspect the generated .te source code prior to activation
cat mydaemon_custom.te
# 4. Load the compiled policy package into the kernel
sudo semodule -i mydaemon_custom.pp
Never install generated policy modules blindly. If a web daemon denial was caused by malicious code attempting to read /etc/shadow, compiling that denial with audit2allow will grant the attacker permanent access to your password hashes.
Practical Rules of Thumb
Working with SELinux is straightforward once you follow a consistent sequence. Check file labels with ls -Z. Register permanent directory paths using semanage fcontext. Enable operational requirements with setsebool -P, and reserve audit2allow for bespoke internal services. Treat every AVC denial as a diagnostic clue rather than an obstacle. By keeping SELinux in Enforcing mode, a compromised web service remains trapped in its domain instead of taking over your entire server.

