SELinux Blocking Nginx? Fix 403s Without Disabling It
Your files are there. Permissions look right. Nginx is running. And you still get a 403 or a "Permission denied" in the error log. On an AlmaLinux, Rocky, or CentOS Stream VPS, that combination almost always means SELinux is doing its job — just not the job you wanted. Here's how to read what it blocked and fix it properly, instead of switching it off and hoping.
Symptom: everything looks correct, but the request still fails
A few ways this shows up on a RHEL-family VPS:
- Nginx returns
403 Forbiddenon a site you just moved to/srv/wwwor/home/deploy/app, even thoughls -lshows644files owned bynginx. - The error log says
failed (13: Permission denied)while opening a file you can happilycatas root. - A reverse proxy to a Node or Gunicorn app on
127.0.0.1:3000dies withconnect() to 127.0.0.1:3000 failed (13: Permission denied). - WordPress can't write to
wp-content/uploads, or a PHP app can't reach an external API or a remote MySQL host. - You moved SSH to port 2222, restarted
sshd, and it refuses to bind.
The tell is the mismatch: standard Unix permissions are fine, the process is running as the right user, and it still gets denied. That's a second permission layer talking.
Cause: SELinux labels, not file permissions
AlmaLinux, Rocky, and CentOS Stream ship with SELinux in enforcing mode by default. Ubuntu doesn't, which is why most tutorials never mention it and why this bites people migrating from Ubuntu.
SELinux gives every file, port, and process a label (a "context"). Policy then says which process label may touch which file label. So nginx running as httpd_t is allowed to read files labelled httpd_sys_content_t — and nothing else, no matter what chmod says.
Confirm SELinux is even in play:
getenforce
sestatus
If that prints Enforcing, keep reading. You can prove it's the culprit in ten seconds:
sudo setenforce 0 # permissive - logs but doesn't block
# retry the failing request
sudo setenforce 1 # put it straight back
If the request works in permissive mode, it's SELinux. That test is a diagnostic, not a fix — turn it back on. Leaving a public VPS permissive to avoid one label problem is like removing a door because the key sticks.
Read the actual denial
SELinux writes an AVC (Access Vector Cache) denial for every block. Don't guess — read it:
sudo ausearch -m avc -ts recent
A typical line, trimmed:
type=AVC msg=audit(...): avc: denied { read } for pid=1421 comm="nginx"
name="index.php" dev="vda1" ino=264188
scontext=system_u:system_r:httpd_t:s0
tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file
Read it right-to-left: the source is httpd_t (Nginx), the target is user_home_t (a file still carrying a home-directory label), and the action was read. That's your whole diagnosis.
Plain-English explanations are worth installing:
sudo dnf install -y setroubleshoot-server policycoreutils-python-utils
sudo sealert -a /var/log/audit/audit.log
policycoreutils-python-utils is the package that gives you semanage — you'll need it below.
One gotcha: some denials are suppressed by dontaudit rules and never reach the log. If a request clearly fails but ausearch is empty, unmask them temporarily:
sudo semodule -DB # disable dontaudit, rebuild policy
# reproduce, check ausearch again
sudo semodule -B # restore normal behaviour
Fix 1: wrong file context (the 403 case)
This is the common one. You copied a site into a non-standard path, or restored it from a tarball, and the files kept the wrong label. Check with -Z:
ls -Z /srv/www/example.com/
If you see user_home_t, admin_home_t, or default_t instead of httpd_sys_content_t, add a rule for the path and relabel:
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?"
sudo restorecon -Rv /srv/www
The semanage fcontext line writes the rule into local policy so it survives reboots and future relabels. restorecon applies it now. Doing only chcon works until something relabels the filesystem, then you're back to square one.
Directories the web server must write to — uploads, cache, session dirs — need a different type:
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/srv/www/example.com/wp-content/uploads(/.*)?"
sudo restorecon -Rv /srv/www/example.com/wp-content/uploads
Keep that scope tight. Labelling the whole document root read-write hands an attacker who finds an upload bug a much bigger prize.
Why it happened: mv vs cp
mv keeps the original label, cp gives the file the label of the destination directory, and tar/rsync may carry over whatever was on the source box. So mv /home/user/site /srv/www/ is the classic way to end up with user_home_t files under a web root.
Fix 2: a boolean, not a label
Plenty of behaviour is controlled by on/off switches called booleans. Outbound network access from the web server is off by default — that's the reverse-proxy and external-API case:
getsebool -a | grep httpd
sudo setsebool -P httpd_can_network_connect on
The -P makes it persistent; without it you'll be back here after the next reboot. Common ones:
| Symptom | Boolean |
|---|---|
| Reverse proxy or app calling an external API gets "Permission denied" | httpd_can_network_connect |
| PHP can't reach a remote MySQL/Postgres host | httpd_can_network_connect_db |
| Contact form mail silently fails | httpd_can_sendmail |
Site files live under /home/user/public_html | httpd_enable_homedirs |
| PHP opcode cache or JIT throws memory errors | httpd_execmem |
Prefer the narrowest one that fixes your case. httpd_can_network_connect_db beats httpd_can_network_connect if a database is all you need.
Fix 3: a non-standard port
SELinux labels ports too. Moving SSH or running a web service on an unusual port needs the port registered:
sudo semanage port -l | grep -E 'http_port_t|ssh_port_t'
sudo semanage port -a -t http_port_t -p tcp 8080
sudo semanage port -a -t ssh_port_t -p tcp 2222
If the port is already in the list under a different type, use -m (modify) instead of -a (add). And do this before you restart sshd on a remote box — ideally with a second SSH session open, so a mistake doesn't lock you out.
Fix 4: last resort — a custom policy module
When no boolean or label fits (usually some third-party agent or an unusual daemon), generate a module from the denials:
sudo ausearch -c 'nginx' --raw | audit2allow -M my-nginx
cat my-nginx.te # READ THIS BEFORE INSTALLING
sudo semodule -i my-nginx.pp
Read the .te file every single time. audit2allow transcribes whatever was denied, including denials caused by a genuine misconfiguration or an actual intrusion attempt. If the rules look broad — anything granting sweeping access across unrelated types — fix the underlying label instead.
Prevention
- Deploy with labels intact. Use
rsync -aAX(the-Xcarries extended attributes) orcp -Z, and finish every deploy script withrestorecon -Ron the target path. - Register custom paths once. Add the
semanage fcontextrule when you first create a non-standard web root, not the next time it breaks. - Check
ls -Zandausearch -m avc -ts recentearly. Two commands, before you start chmod-ing things to 777 out of frustration. - Don't disable it permanently. On RHEL 9-based systems,
SELINUX=disabledin/etc/selinux/configis deprecated — the supported way to fully disable is theselinux=0kernel parameter. If you must, useSELINUX=permissiveso denials are still logged. - Re-enabling? Relabel first. Coming back from disabled to enforcing without a relabel will break a lot at once:
sudo touch /.autorelabel && sudo reboot. It takes a few minutes on first boot.
Running cPanel/WHM on AlmaLinux?
Different situation. cPanel doesn't support SELinux in enforcing mode, and its own scripts assume it isn't. Leave it permissive or disabled there and rely on CSF, ModSecurity, and account-level isolation instead. This guide is aimed at self-managed VPS stacks — Nginx, Apache, PHP-FPM, Node, Docker hosts — where enforcing mode is a real security win worth keeping.
If you're on a Getwebup VPS and a denial has you stuck, send us the output of sudo ausearch -m avc -ts recent along with the path involved. That one paste usually tells us the answer immediately.
Frequently asked questions
Should I just disable SELinux to fix a 403?
No. It's a real security layer that confines a compromised web process to the files and ports it's supposed to touch. Use setenforce 0 only as a ten-second diagnostic, then switch it back and fix the label or boolean. If you genuinely can't keep it enforcing, use permissive mode so denials are still logged rather than disabling it outright.
What's the difference between chcon and semanage fcontext?
chcon changes a label right now but writes nothing to policy, so any relabel (a restorecon -R, a package update, an /.autorelabel boot) reverts it. semanage fcontext -a records the rule in local policy, and restorecon then applies it. Use semanage plus restorecon for anything you want to survive a reboot.
Why does my site work on Ubuntu but 403 on AlmaLinux?
Ubuntu ships AppArmor with a permissive default profile set, while AlmaLinux, Rocky, and CentOS Stream ship SELinux in enforcing mode. The same files and permissions that work fine on Ubuntu get denied on RHEL-family systems if they're not labelled httpd_sys_content_t. Run ls -Z on your document root to check.
SELinux is enforcing but ausearch shows nothing. What now?
Some denials are hidden by dontaudit rules. Run sudo semodule -DB to disable them, reproduce the failure, check ausearch -m avc -ts recent again, then run sudo semodule -B to restore the defaults. If it's still empty, the problem probably isn't SELinux — check the service error log and standard file ownership.
Is audit2allow safe to run on a production server?
Generating the module is safe; installing it blindly is not. audit2allow simply turns whatever was denied into allow rules, so a denial caused by a misconfiguration or an intrusion attempt becomes a permanent permission. Always read the generated .te file first, and prefer a targeted boolean or file-context fix when one exists.