Why nginx 403 Even When File Permission Correct
nginx dey return 403 even when permissions look fine? Read the SELinux denial, use semanage and restorecon to fix the label, and keep enforcing on.
Why nginx dey return 403 for file wey permission correct
If nginx return 403 for file wey permission bits correct, almost always na SELinux (security-enhanced Linux) dey refuse the read. SELinux dey check another set of rules after normal permissions don pass, and web server fit only read files wey get web content label. Your file get different label, so the open fail and nginx no get anything to send.
Check the label, no be mode only:
ls -ldZ /data/www /data/www/index.htmldrwxr-xr-x. root root unconfined_u:object_r:default_t:s0 /data/www
-rw-r--r--. root root unconfined_u:object_r:default_t:s0 /data/www/index.htmlThe dot wey print after drwxr-xr-x mean say the file get SELinux label. default_t na wetin path dey get when policy never hear about am, and nothing for web server rules allow reading that type. The error log show normal Unix error, na why e fit look like permissions bug:
2026/08/11 09:14:02 [error] 1183#1183: *1 open() "/data/www/index.html" failed (13: Permission denied), client: 203.0.113.9, server: _, request: "GET / HTTP/1.1"The kernel return 13: Permission denied for both types of refusal: the normal one and the SELinux one. So, the first work na to find out which layer talk no. No start with setenforce 0.
Di model wey you need
SELinux na mandatory access control, and dem usually write am as MAC. Every process dey run inside one domain, like httpd_t for web server. Every file and network port get one type, like httpd_sys_content_t. The policy na list of domain, type, and action combinations wey dem allow. Anything wey no dey that list, dem go deny am. E dey run after the classic Unix check, so permission bits for drwxr-xr-x still need allow the access first. Both layers must agree before access go work.
Full context get four fields wey colon separate, like system_u:system_r:httpd_t:s0: SELinux user, role, type, and level. For server, na the third field, the type, you go dey work with almost all the time. Two commands fit show the values wey dey active:
ps -eZ | grep nginx
id -ZThe nginx workers show context wey end for httpd_t. Your login shell show unconfined_u:unconfined_r:unconfined_t:s0, because the default targeted policy dey confine services and leave interactive users alone. You need know this, because SELinux no replace running services with least-privilege users. E limit wetin service fit reach after person break into am.
Di three modes, and which images get SELinux
sestatus
getenforceEnforcing dey block and log. Permissive dey allow everything and log wetin e for block. Disabled no load any policy at all. getenforce dey print the current mode. sestatus still dey print the mode from /etc/selinux/config, and na that one go come back after reboot.
Rocky Linux, AlmaLinux, Fedora and RHEL dey ship with SELinux enforcing and the targeted policy. This shared default no be coincidence, because all four come from the same Red Hat lineage wey pass through CentOS before Rocky Linux and AlmaLinux show. Which one you run no change anything for this page, because dem ship the same policy and same tools. So choosing between Rocky Linux and AlmaLinux depend on their compatibility promises and support for older CPU, not security defaults. Ubuntu and Debian dey ship AppArmor instead. E dey do the same work with another mechanism (the last section cover am). So the same application fit install cleanly for one of your servers and return 403 for another one.
Install the tools before you need dem
sudo dnf install -y policycoreutils-python-utils setroubleshoot-serverFor a minimal image, semanage: command not found mean say policycoreutils-python-utils no dey there: na that package dey hold semanage and audit2allow. setroubleshoot-server adds sealert and writes plain-English summary of each denial into the journal. Install both for fresh server, because the time you need dem na the same time something don already break.
How to read an SELinux denial for audit log
Audit daemon dey record each refusal as AVC (access vector cache) message:
sudo ausearch -m AVC,USER_AVC,SELINUX_ERR -ts recenttype=AVC msg=audit(1754896442.881:412): avc: denied { read } for pid=1183 comm="nginx" name="index.html" dev="vda1" ino=17203 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=0Four fields carry the full matter. comm na the program wey dem block. scontext na the source context, the domain wey the process dey run inside. tcontext na the target context, the label wey dey on the thing wey e try touch. tclass na the type of object, for here na file. If you read dem together: the process for httpd_t try read file wey get default_t label, and permissive=0 show say dem really block the request, no be say dem only log am.
If ausearch no print anything, audit daemon fit no dey run. Denials go then enter kernel ring buffer instead:
sudo journalctl -k | grep -i avcNow turn the record into one English sentence:
sudo ausearch -m AVC -ts recent | audit2why
sudo journalctl -t setroubleshoot --since today
sudo sealert -a /var/log/audit/audit.logaudit2why dey read the same records and name the cause wey e recognise: boolean wey dey off, label wey no match policy, or no rule at all. sealert dey check the whole log and print one suggested command for each denial. Treat the suggestion as hint. The wording dey change between releases, and sealert sometimes propose custom policy module when one-line label fix na the correct answer.
Make you know one more thing. Policy get dontaudit rules wey hide denials wey dem consider harmless, so program fit misbehave while log remain empty. Unhide dem for the duration of one test:
sudo semodule -DB
# reproduce the problem, then read the log again
sudo semodule -BFix path wey get wrong label with semanage fcontext and restorecon
Na two commands, and the order matter. semanage fcontext -a dey record the label wey path suppose get. restorecon dey apply that recorded default to the files wey dey disk.
sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
sudo restorecon -Rv /data/www
ls -ldZ /data/www/index.htmlThe path na regular expression. (/.*)? dey cover the directory itself and everything wey dey inside am, and na wetin document root need. See wetin go change before you change am: sudo restorecon -Rvn /data/www dey print the relabels wey e plan, because -n mean say make e no do anything. After real restorecon, the label go read httpd_sys_content_t and the 403 go disappear without service restart.
Use chcon only for test. chcon -t httpd_sys_content_t index.html dey set the label directly, and the next restorecon, package update, or full relabel go reset am, because policy still dey say the path suppose be something else. For machine wey dnf-automatic dey apply security updates on timer, that reset go happen according to its own schedule, no be when you dey sit in front of the machine. So the site fit break hours after the last thing wey you touch. semanage fcontext na the version wey go survive. List wetin you don record with sudo semanage fcontext -l | grep '^/data'.
Content wey service must write to need different type. Use httpd_sys_rw_content_t for upload directory or cache, and restrict am to those paths: read-only site under writable type dey give more access than the application need.
Why the label wrong in the first place? Almost always na because of how the files enter the system. mv dey keep the file existing label, so site wey you move out of /root go arrive with label admin_home_t and remain like that. Plain cp go give the new file the default label of the destination directory, and na usually wetin you want. But cp -a and rsync -X dey copy the source labels together with the file. A git clone into new top-level directory go produce default_t. When page load fine from /usr/share/nginx/html but fail from your own directory, na this be the reason.
Fix one behaviour class with boolean
Some failure no be label problem. Reverse proxy for fresh Rocky or AlmaLinux box dey return 502, and error log dey talk say:
2026/08/11 10:02:55 [crit] 1183#1183: *3 connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstreamYour upstream dey okay. The httpd_t domain no get permission to open outbound network connections by default, so dem dey refuse the connect() call before e reach loopback interface. One switch dey control this whole behaviour:
getsebool -a | grep httpd_can_network
sudo setsebool -P httpd_can_network_connect on-P na the flag wey matter: e dey write the value to disk. Without -P, the change go disappear for next reboot, and your service go work only until machine restart. Confirm am with semanage boolean -l | grep httpd_can_network_connect, wey dey print the running value beside the stored one.
Prefer boolean instead of hand-written rule whenever one dey available. Booleans dey ship with distribution policy, so dem dey maintained, documented, and easy for the next person to find. getsebool -a lists every one wey dey for the system.
Make service listen for non-standard port
Ports get labels too. Move nginx go 8081 and e go refuse start:
nginx: [emerg] bind() to 0.0.0.0:8081 failed (13: Permission denied)httpd_t fit bind ports wey get label http_port_t, and 8081 no dey among dem. Add am:
sudo semanage port -l | grep -w http_port_t
sudo semanage port -a -t http_port_t -p tcp 8081Check the list first. Some high ports don already get permission, including 8008 and 8443, and if you add one twice, e go fail with ValueError: Port tcp/8081 already defined. If port already belong to another type, change am with semanage port -m -t http_port_t -p tcp 8081 instead of adding am.
Na the same command dey make moved SSH port work. Bind to port 2222 on 0.0.0.0 failed: Permission denied for journalctl -u sshd mean say 2222 no dey inside ssh_port_t, so run sudo semanage port -a -t ssh_port_t -p tcp 2222 before you restart the daemon and close your session. Na this step people dey skip when dem follow generic guide to harden SSH for VPS on Red Hat family image. SELinux no be firewall too, so port still need dey open: sudo firewall-cmd --permanent --add-port=8081/tcp && sudo firewall-cmd --reload here, or ufw for Debian or Ubuntu image. That --permanent flag get the same reboot trap as -P for boolean, and e good make you read the zones wey decide which interfaces rule go apply to, once for firewalld basics for Rocky or AlmaLinux VPS.
When no boolean and no label dey available to change
Dis one rare for normal server, and na here people dey cause damage. audit2allow fit build policy module from the denials wey dey for log:
sudo ausearch -m AVC -ts recent -c nginx | audit2allow -M nginx_local
cat nginx_local.te
sudo semodule -i nginx_local.ppRead nginx_local.te before you install am. Two habits go help keep dis safe. Filter the input to only the program wey you dey fix with -c, because if you pipe one week of unrelated denials enter audit2allow, e go grant all of dem at once. And no ever install module wey you build from denial wey you no fit explain: rule wey allow httpd_t read every file for the machine easy to generate, but e hard to notice months later. Remove module with sudo semodule -r nginx_local.
Permissive na diagnostic mode, e no be fix
sudo setenforce 0
# reproduce the problem once, all the way through
sudo ausearch -m AVC -ts recent
sudo setenforce 1Permissive mode dey allow the access and log am. The real value na say e complete. For enforcing mode, service go stop for the first denial. You go fix am, restart, then meet the second one. For permissive mode, the run go continue and the log go collect every denial for one pass. After that, you fit switch back and repair dem together.
setenforce no dey touch /etc/selinux/config, so reboot go return the box to enforcing. This na safety net. Na also why any “fix” wey only be setenforce 0 go show again for the worst possible time. If one service need more room while you dey work on am, mark that domain instead of the whole machine: sudo semanage permissive -a httpd_t go leave everything else enforcing, and sudo semanage permissive -d httpd_t go reverse am.
Wetin e cost to disable SELinux pass fixing the label
To set SELINUX=disabled for /etc/selinux/config na to exchange one-line label fix for server wey security don weak permanently. You go see the difference the day web application get breached. When enforcing dey on, attacker code dey run for httpd_t, so e fit read web content, but policy go refuse am to read /etc/shadow or write systemd unit, no matter wetin the Unix user suppose get permission to do. If no policy load, that same code go get everything wey the service account get.
Disabling SELinux still get cost wey you go pay later. While no policy load, new files go create without label, so filesystem go begin differ from wetin policy expect. If you turn SELinux on again, you need full relabel, otherwise plenty services fit fail at once:
sudo fixfiles -F onboot
sudo rebootThis one go write /.autorelabel and relabel every filesystem during the next boot. For large disk, e fit take long time and console fit look like say e hang, so start am when you fit wait. Since the machine go shut down anyway, e make sense to first check wetin else don queue for restart. Na wetin needs-restarting reports after dnf update leave old kernels and libraries for memory be this. For Rocky Linux and AlmaLinux 9, the config file no longer switch off the kernel part by itself. The documented way to disable SELinux fully na to use kernel argument (sudo grubby --update-kernel ALL --args selinux=0). If you inherit another person server, knowing this command fit help. But this no be the fix for 403.
Containers add one more label
For Red Hat family host, container processes dey run inside container_t and dem fit only read files wey get container_file_t label. Bind mount from host go fail with Permission denied inside container, while ls -l for host dey look completely normal. :Z suffix dey tell runtime make e relabel the mount:
docker run -d -v /data/appdata:/var/lib/app:Z myimage:Z dey label the directory for this container alone. :z dey label am for sharing between containers. If you point :Z to directory wey other services dey use, e go relabel that directory recursively, and this one go break those services. So give containers their own paths. If engine never dey for the box, remember say docker command for these distributions often na podman wey dey answer to that name. Rocky and AlmaLinux installation steps go sort out this detail before you reach this point. Everything else for the setup match any other image, as running Docker on a VPS explain.
Ubuntu and Debian give you AppArmor
Na the same job, but the design different. AppArmor dey confine program based on the path to its executable, using profile under /etc/apparmor.d/, instead of labelling files for disk. You no need relabel anything, and no restorecon dey. Start here:
sudo aa-status
sudo journalctl -k | grep -i apparmorRefusal go show as apparmor="DENIED" operation="open" profile="/usr/sbin/nginx" name="/data/www/index.html" requested_mask="r". The workflow still follow the same pattern: read the denial, find the profile, then change the rule. sudo apt install apparmor-utils give you aa-complain (permissive for one profile), while aa-enforce go put am back. Ubuntu dey confine selected packaged services and leave the rest unconfined, so read aa-status to see wetin really active instead of assuming.
One habit work for both systems. When service report Permission denied for something wey look correct, read the security log before you change permissions. The permission bits rarely be the problem twice.
FAQ
Why nginx dey return 403 when file permissions correct?
Because SELinux deny the read, no be permission bits cause am. Web server dey run for the httpd_t domain, and e fit only read files wey get label for web content. So e go refuse file wey get default_t or admin_home_t label, and nginx no get anything to serve. Confirm am with sudo ausearch -m AVC -ts recent. E go show scontext ending for httpd_t, while tcontext get the wrong type. Then record the correct label and apply am: sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?" followed by sudo restorecon -Rv /data/www.
E safe to run setenforce 0 to make service work?
setenforce 0 na diagnostic step, no be fix. Use am to reproduce the problem one time, so the log fit collect every denial for one pass. Read dem with sudo ausearch -m AVC -ts recent, then run sudo setenforce 1 and repair the causes. Server wey remain permissive go log every denial but e no go block any one. So you go keep the noise and lose the protection. If one service need temporary room while you dey work, run sudo semanage permissive -a httpd_t so the rest of the machine remain enforcing.
How I fit run service for non-standard port with SELinux enforcing?
Add the port to the type wey that service get permission to bind. For web server on 8081: sudo semanage port -a -t http_port_t -p tcp 8081. For SSH on 2222: sudo semanage port -a -t ssh_port_t -p tcp 2222. First check the current list with sudo semanage port -l | grep -w http_port_t, because if the port dey there already, ValueError: Port tcp/8081 already defined go fail. Without this step, daemon go exit during startup with bind() ... Permission denied, even when no other process dey hold the port.
Ubuntu get SELinux?
No. Ubuntu and Debian ship AppArmor. E dey enforce profile wey tie to executable path, instead of labels for files. Check am with sudo aa-status and look for apparmor="DENIED" lines inside sudo journalctl -k. Ubuntu dey confine selected packaged services, so many programs dey run unconfined by default. Na for Rocky Linux and AlmaLinux you go meet SELinux enforcing out of the box, together with Fedora and RHEL.