SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-13

SELinux basics: wetin e fit break for server

nginx dey return 403 though permissions fine? Learn how to read the SELinux denial, use semanage and restorecon, and keep enforcing on.

Why nginx dey return 403 for file wey permissions fine

If nginx return 403 for file wey permission bits correct, na almost always SELinux (security-enhanced Linux) dey refuse the read. SELinux dey check another set of rules after normal permissions pass, and e only allow web server 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 only the mode:

ls -ldZ /data/www /data/www/index.html
drwxr-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.html

The dot wey print after drwxr-xr-x mean say the file get SELinux label. default_t na wetin path go 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 problem:

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 which layer talk no. No start with setenforce 0.

Di model wey you need

SELinux na mandatory access control, and dem dey usually write am as MAC. Every process dey run inside one domain, like httpd_t for web server. Every file and every network port carry one type, like httpd_sys_content_t. The policy na list of domain, type, and action combinations wey dem allow. Anything wey no dey for that list, SELinux 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 approve am.

Complete 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 work with almost all the time. Two commands fit show the live values:

ps -eZ | grep nginx
id -Z

The nginx workers show context wey end with 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 under least-privilege users. E limit wetin service fit reach after person breach am.

The three modes, and which images get SELinux

sestatus
getenforce

Enforcing dey block and log. Permissive dey allow everything and log wetin e for don block. Disabled no dey load any policy at all. getenforce dey print the current mode. sestatus still dey print the mode from /etc/selinux/config, and na this one go come back after reboot.

Rocky Linux, AlmaLinux, Fedora and RHEL dey ship with SELinux enforcing and the targeted policy. Ubuntu and Debian dey ship AppArmor instead. E dey do the same work with different mechanism. The last section cover am. So, the same application fit install cleanly for one of your servers but return 403 for another one.

Install the tools before you need them

sudo dnf install -y policycoreutils-python-utils setroubleshoot-server

semanage: command not found for minimal image mean say policycoreutils-python-utils no dey present: na that package hold semanage and audit2allow. setroubleshoot-server add sealert and write simple-English summary of each denial go journal. Install both for fresh server, because the time you need dem na when something don already spoil.

How to read an SELinux denial in the audit log

Audit daemon dey record every refusal as AVC (access vector cache) message:

sudo ausearch -m AVC,USER_AVC,SELINUX_ERR -ts recent
type=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=0

Four fields carry the whole story. 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 for the thing wey e try touch. tclass na the object kind, for here na file. If you read dem together: 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 the kernel ring buffer instead:

sudo journalctl -k | grep -i avc

Now 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.log

audit2why dey read the same records and name the cause wey e recognise: boolean wey dey switched off, label wey no match policy, or no rule at all. sealert dey scan 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 suggest custom policy module when one-line label fix na the correct answer.

One more thing you need know. 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 -B

Fix mislabelled path with semanage fcontext and restorecon

Commands two, and their order matter. semanage fcontext -a dey record the label wey path suppose get. restorecon dey apply that recorded default to 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.html

The path na regular expression. (/.*)? dey cover the directory itself and everything inside am, 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 clear 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 talk say the path suppose get another label. 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: if read-only site dey under writable type, e go 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 file existing label, so site wey you move comot from /root go arrive with label admin_home_t and remain that way. Plain cp dey give new file the destination directory default label, 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 well from /usr/share/nginx/html but fail from your own directory, na this be the reason.

Fix one behaviour with boolean

Some failures 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 upstream

Your upstream dey okay. The httpd_t domain no get permission to open outbound network connections by default, so dem refuse the connect() call before e reach loopback interface. One switch dey control the whole behaviour:

getsebool -a | grep httpd_can_network
sudo setsebool -P httpd_can_network_connect on

-P na the flag wey matter: e write the value to disk. Without -P, the change go lost for next reboot. This one give you service wey dey work until machine restart. Confirm am with semanage boolean -l | grep httpd_can_network_connect, wey print the running value beside the stored one.

Prefer boolean instead of hand-written rule whenever one dey available. Booleans ship with distribution policy, so dem dey maintained, documented, and easy for the next person to find. getsebool -a list every one wey dey for the system.

Make service listen for non-standard port

Ports get label too. Move nginx go 8081 and e no gree 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 8081

Check the list first. Plenty high ports don already get permission, including 8008 and 8443, and if you add one twice, ValueError: Port tcp/8081 already defined go fail. If the port don 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 for 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 the port still need open: sudo firewall-cmd --permanent --add-port=8081/tcp && sudo firewall-cmd --reload here, or ufw for Debian or Ubuntu image.

When boolean or label no dey available to change

This one rare for normal server, and na here people fit 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.pp

Read nginx_local.te before you install am. Two habits fit keep this safe. Use -c filter the input to only the program wey you dey fix, because if you pipe one week of unrelated denials enter audit2allow, e go grant all of dem at once. And never install module wey you build from denial wey you no fit explain: rule wey allow httpd_t read every file for the box 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 1

Permissive mode dey allow access and e dey log the access. The real value na completeness. For enforcing mode, service dey stop for the first denial. So you fix that one, restart, then you meet the second one. For permissive mode, the run dey continue and log dey collect every denial for one pass. After that, you switch back and repair all of 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 involve setenforce 0 go show again for the worst possible moment. If one service need space while you dey work on am, mark that domain instead of the whole machine: sudo semanage permissive -a httpd_t leaves everything else enforcing, and sudo semanage permissive -d httpd_t reverses am.

Why disabling SELinux costs more than fixing the label

Setting SELINUX=disabled inside /etc/selinux/config dey exchange one-line label fix for server wey security don weak permanently. You go see the difference on the day web application get breached. When enforcing dey active, attacker's code dey run inside httpd_t, so e fit read web content, but policy go refuse am from reading /etc/shadow or writing systemd unit, no matter wetin the Unix user permissions fit allow. If no policy load, that same code go get everything wey the service account get.

Disabling am too get cost wey you go pay later. While no policy dey loaded, new files dey create without label, so filesystem go gradually differ from policy. If you turn SELinux on again, you need full relabel, or many services fit fail together:

sudo fixfiles -F onboot
sudo reboot

This one writes /.autorelabel and relabels every filesystem during the next boot. For large disk, e fit take long time and the console fit look like e don hang, so start am when you fit wait. For Rocky Linux and AlmaLinux 9, the config file no longer switches off the kernel part by itself, and the documented way to disable SELinux fully na kernel argument (sudo grubby --update-kernel ALL --args selinux=0). If you inherit another person server, knowing this command fit help you. E no be the fix for 403.

Containers dey add one more label

For Red Hat family host, container processes dey run for container_t and dem fit only read files wey get container_file_t label. Bind mount from the host go fail with Permission denied inside the container, even though ls -l for the host look perfectly normal. The :Z suffix tell the runtime to relabel the mount:

docker run -d -v /data/appdata:/var/lib/app:Z myimage

:Z label the directory for only this container. :z label am so containers fit share am. If you point :Z to directory wey other services dey use, e go relabel that directory recursively and break those services. So give containers their own paths. Every other part of the setup dey same like any other image, as we cover for running Docker for VPS.

Ubuntu and Debian give you AppArmor

Same work, but different design. AppArmor dey confine program based on the path to its executable, with profile wey dey under /etc/apparmor.d/, instead of labelling files for disk. You no need relabel anything, and restorecon no dey. Start from here:

sudo aa-status
sudo journalctl -k | grep -i apparmor

Refusal dey show as apparmor="DENIED" operation="open" profile="/usr/sbin/nginx" name="/data/www/index.html" requested_mask="r". The workflow get 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 dey active instead of assuming.

One habit dey work for both systems. When service report Permission denied for something wey look correct, read the security log before you touch the permissions. The permission bits rarely dey be the problem twice.

FAQ

Why nginx dey return 403 when file permissions correct?

Na because SELinux deny the read, no be permission bits cause am. Web server dey run inside 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, e no be fix. Use am make you reproduce the problem once, so the log go collect every denial for one pass. Read dem with sudo ausearch -m AVC -ts recent, then run sudo setenforce 1 and repair the causes. If you leave server permissive, e go log every denial but e no go block any one. You go keep the noise and lose the protection. If one service need space 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 listed already, ValueError: Port tcp/8081 already defined go fail. If you skip 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. AppArmor 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 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.