A how-to manual: modes, labels, reading denials, semanage and restorecon, ports, booleans, custom modules, confined users and relabeling, all tested on an enforcing Rocky Linux lab
SELinux: labels, denials and the art of not turning it off
sestatus
Everyone has met SELinux the same way: a service that works perfectly on the test box fails on the real one, the file permissions are right, the config is right, and the log says "Permission denied" or nothing at all. Somebody on a forum says "just run setenforce 0", it works, and a security feature quietly gets switched off for the next five years. This guide is the other way: how SELinux thinks, how to read what it is telling you, and the four or five fixes that cover nearly every real case, so that turning it off stops being the shortest path.
Every example below ran for real: Rocky Linux 9.8 (kernel 5.14, selinux-policy-targeted 38.1, Apache 2.4.62) in a virtual machine with SELinux in enforcing mode, as it comes out of the box. SELinux is the default on the Red Hat family (RHEL, Rocky, Alma, Fedora, CentOS Stream); Debian and Ubuntu use AppArmor instead and SELinux there is an optional extra. The lab VM was a plain Rocky 9 cloud image; the commands are for that family, and need root, so they are run with sudo on a real server (left out of the examples where the lab ran as root). Anything not run in the lab is marked "not run in the lab".
What SELinux is, in one page
Normal Linux security (the "DAC", discretionary access control) asks one question: who are you (user, group) and do the permission bits on this file allow it? Whoever owns a file decides, and root is allowed everything. SELinux adds a second, independent check (a "MAC", mandatory access control) that the owner and even root cannot talk their way around: it asks what is this PROGRAM, and what is this FILE, and does the policy say that kind of program may do that to that kind of file? Both checks must say yes.
The mechanism is a LABEL on everything: files, processes, ports, users. A label has four parts, user:role:type:level, and in the targeted policy that ships with Red Hat systems nearly all the action is in the third part, the TYPE. Apache runs as type httpd_t. A web page is httpd_sys_content_t. The policy is a long list of rules like "httpd_t may read httpd_sys_content_t". Anything not listed is denied, and the denial is written to the audit log.
So the short version of the whole subject: when SELinux blocks something, either a label is wrong (a file, a port), or there is a switch (a boolean) you did not flip, or the program is doing something the policy writers did not expect. The first is by far the most common, the second is next, the third is rare. Notice what is NOT on the list: "SELinux is broken", and "SELinux is too strict for production".
The policy decides, per program, how much of this applies. The targeted policy "confines" the network services (Apache, nginx, sshd, MariaDB, Postfix and a few hundred more) and leaves ordinary logins and anything unknown "unconfined", with SELinux watching but mostly not interfering. Your own admin shell is unconfined. The services are the ones in the cage.
Modes: enforcing, permissive, disabled
enforcing denials are blocked AND logged. The normal state.
permissive denials are only logged, and the action is allowed. For diagnosing: it shows everything that WOULD have been blocked.
disabled SELinux does not run at all, no labels are kept up to date. Re-enabling it later needs a full relabel.
$ getenforce
Enforcing
$ sestatus
SELinux status: enabled
SELinuxfs mount: /sys/fs/selinux
SELinux root directory: /etc/selinux
Loaded policy name: targeted
Current mode: enforcing
Mode from config file: enforcing
Policy MLS status: enabled
Policy deny_unknown status: allowed
Memory protection checking: actual (secure)
Max kernel policy version: 33
Two lines to read: "Current mode" is what is happening right now, "Mode from config file" is what will happen after the next boot. They are separate on purpose, and the lab showed it: with SELINUX=permissive written to the config file, getenforce still said Enforcing and sestatus said "Current mode: enforcing, Mode from config file: permissive". The change takes effect at the next reboot, not before. The config file:
$ grep -v '^#' /etc/selinux/config | grep -v '^$'
SELINUX=enforcing
SELINUXTYPE=targeted
Changing the mode:
setenforce 0 permissive until the next reboot (setenforce 1 = enforcing again)
setenforce Permissive the same, with the word
edit SELINUX= in /etc/selinux/config the setting for the next boot
enforcing=0 on the kernel command line boot once in permissive mode (a rescue option at the boot menu; not run in the lab)
selinux=0 on the kernel command line boot with SELinux off (not run in the lab)
setenforce 0 is a diagnostic tool, not a fix: use it for ONE test ("does it work if SELinux cannot block it?"), then set it back. The lab did exactly that, see the case below. If it works in permissive mode, you know the problem IS SELinux, and the audit log now tells you what. If it does not work in permissive mode either, SELinux is innocent and you have saved yourself an afternoon of blaming it.
Going from disabled back to enforcing is the one change that needs care: files created while SELinux was off have no labels, so you set permissive first, relabel (see the relabel section), reboot, and only then enforce. Do not go straight from disabled to enforcing: the system may not boot. (Not run in the lab.)
Labels: looking at files, processes, users and ports
The -Z option, on most commands, adds the SELinux label. Files:
$ ls -Z /etc/passwd /etc/shadow /usr/sbin/httpd /usr/sbin/sshd
system_u:object_r:passwd_file_t:s0 /etc/passwd
system_u:object_r:shadow_t:s0 /etc/shadow
system_u:object_r:httpd_exec_t:s0 /usr/sbin/httpd
system_u:object_r:sshd_exec_t:s0 /usr/sbin/sshd
$ ls -dZ /var/www/html /var/www/cgi-bin /home/lab /root /var/log/audit/audit.log
system_u:object_r:httpd_sys_content_t:s0 /var/www/html
system_u:object_r:httpd_sys_script_exec_t:s0 /var/www/cgi-bin
unconfined_u:object_r:user_home_dir_t:s0 /home/lab
system_u:object_r:admin_home_t:s0 /root
system_u:object_r:auditd_log_t:s0 /var/log/audit/audit.log
Read a label as USER:ROLE:TYPE:LEVEL, left to right: system_u:object_r:shadow_t:s0 is the system user, the role every file has (object_r), the type shadow_t (the part that matters) and the sensitivity level s0. The type names say what the thing is: passwd_file_t, shadow_t, httpd_sys_content_t. The _exec_t suffix marks a program that, when started, moves into a matching process type. The first part of the name is the service it belongs to.
Processes. The first column of ps -Z is the process's own type, the "domain":
$ id -Z
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
$ ps -eZ | grep -E 'sshd|auditd|chronyd|systemd$'
system_u:system_r:init_t:s0 1 ? 00:00:02 systemd
system_u:system_r:auditd_t:s0 702 ? 00:00:00 auditd
system_u:system_r:chronyd_t:s0 749 ? 00:00:00 chronyd
system_u:system_r:sshd_t:s0-s0:c0.c1023 1003 ? 00:00:00 sshd
$ ps -eZ | awk '{print $1}' | sort | uniq -c | sort -rn | head -3
247 system_u:system_r:kernel_t:s0
10 unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
2 system_u:system_r:system_dbusd_t:s0-s0:c0.c1023
Each daemon has its own type (sshd_t, auditd_t, chronyd_t), and your own shell is unconfined_t. That is the picture: every service in its own box, you outside.
Who is listening, and as what:
$ ss -ltnpZ | grep ':22 '
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1003,proc_ctx=system_u:system_r:sshd_t:s0-s0:c0.c1023,fd=7))
Ports have labels too. A service may bind only to ports whose type the policy allows it:
$ semanage port -l | grep -E '^(http_port_t|ssh_port_t|mysqld_port_t)'
http_port_t tcp 80, 81, 443, 488, 8008, 8009, 8443, 9000
mysqld_port_t tcp 1186, 3306, 63132-63164
ssh_port_t tcp 22
Logins are mapped to SELinux users, and on a default install everyone is unconfined:
$ semanage login -l
Login Name SELinux User MLS/MCS Range Service
__default__ unconfined_u s0-s0:c0.c1023 *
root unconfined_u s0-s0:c0.c1023 *
The policy itself is large. How large, from the loaded policy:
$ getsebool -a | wc -l
350
$ seinfo | grep -E 'Types|Allow:|Booleans'
Types: 5163 Attributes: 253
Booleans: 350 Cond. Expr.: 379
Allow: 64320 Neverallow: 0
Over five thousand types and sixty thousand allow rules, which is why nobody writes policy by hand and why the right habit is to use the labels and switches that already exist.
Case 1: files in the wrong place (a website that answers 403)
The most common SELinux problem in the world. A website is moved out of /var/www/html into /srv/mysite, with the right owner and the right permissions, and Apache answers 403 Forbidden. Everything on the Unix side looks perfect:
$ mkdir -p /srv/mysite && echo "<h1>hello from /srv/mysite</h1>" > /srv/mysite/index.html
$ ls -ldZ /srv/mysite /srv/mysite/index.html /var/www/html
drwxr-xr-x. 2 root root unconfined_u:object_r:var_t:s0 24 Oct 8 18:52 /srv/mysite
-rw-r--r--. 1 root root unconfined_u:object_r:var_t:s0 32 Oct 8 18:52 /srv/mysite/index.html
drwxr-xr-x. 2 root root system_u:object_r:httpd_sys_content_t:s0 6 Aug 25 23:00 /var/www/html
With this virtual host, which is plain Apache and correct:
<VirtualHost *:80>
DocumentRoot /srv/mysite
<Directory /srv/mysite>
Require all granted
</Directory>
</VirtualHost>
$ apachectl configtest
Syntax OK
$ systemctl enable --now httpd
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost/
403
The ls tells the story. The default website folder is httpd_sys_content_t, "what Apache may serve". The new folder is var_t, the generic label of everything under /var and /srv that nobody has classified. Apache runs as httpd_t, the policy does not let httpd_t read var_t files, so the kernel says no, with the Unix permissions never even consulted. The error page is Apache's, and says nothing about SELinux. The log does.
Reading a denial: audit.log, ausearch, audit2why, setroubleshoot
Every SELinux denial is an AVC ("access vector cache") message in /var/log/audit/audit.log, written by the audit daemon. Do NOT read the raw file with your eyes if you can avoid it: ask for what you want with ausearch. The lab's first denial, as ausearch -m avc -ts recent -i shows it (-m avc: only SELinux denials, -ts recent: the last ten minutes, -i: translate numbers into names):
$ ausearch -m avc -ts recent -i
type=AVC msg=audit(10/08/2026 18:52:24.296:330) : avc: denied { getattr } for pid=15454 comm=httpd path=/srv/mysite/index.html dev="vda4" ino=50331949 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:var_t:s0 tclass=file permissive=0
Read an AVC line like a sentence:
denied { getattr } what was attempted (here: look at the file's properties; others are read, write, open, execute, name_bind, name_connect ...)
comm=httpd, pid=15454 the program that tried
path=/srv/mysite/index.html what it tried it on
scontext=...httpd_t... the SOURCE label: who asked
tcontext=...var_t... the TARGET label: what was asked about
tclass=file the kind of object (file, dir, tcp_socket, process ...)
permissive=0 0 = it really was blocked. 1 = only logged (permissive mode)
So: httpd_t was denied getattr on a file of type var_t. The fix is in the two types: httpd_t must be reading something of type httpd_sys_content_t. audit2why turns an AVC into a reason:
$ ausearch -m avc -ts recent -if /var/log/audit/audit.log | audit2why
type=AVC msg=audit(1791485544.281:328): avc: denied { getattr } for pid=15454 comm="httpd" path="/srv/mysite/index.html" ... tclass=file permissive=0
Was caused by:
Missing type enforcement (TE) allow rule.
You can use audit2allow to generate a loadable module to allow this access.
Careful with that last sentence. "Missing type enforcement allow rule" is the generic answer for "no rule allows this", which is true of every denial, and "generate a module" is the answer that leads to the mistake described later. The reason is more useful when it is a boolean (see below), and it says so.
aureport gives a summary of what is being denied, by target:
$ aureport -a --summary -i -if /var/log/audit/audit.log
Avc Object Summary Report
=================================
total obj
=================================
4 unconfined_u:object_r:var_t:s0
Two things about running these tools. First, give ausearch and aureport the log file with -if /var/log/audit/audit.log when you run them from a script, cron or anything without a terminal: given nothing, they check whether standard input is a pipe and wait for input from it, and a script that did not give them a file simply hangs forever (the lab learned this the hard way). Second, you need root to read the audit log.
On a Red Hat system the setroubleshoot service reads those denials and writes plain-language advice into the system journal and /var/log/messages, with a confidence rating, which is often the whole answer:
$ journalctl -t setroubleshoot --no-pager -n 1
setroubleshoot[15638]: SELinux is preventing /usr/sbin/httpd from getattr access on the file /srv/mysite/index.html.
***** Plugin catchall_labels (83.8 confidence) suggests *******************
If you want to allow httpd to have getattr access on the index.html file
Then you need to change the label on /srv/mysite/index.html
Do
# semanage fcontext -a -t FILE_TYPE '/srv/mysite/index.html'
where FILE_TYPE is one of the following: NetworkManager_dispatcher_chronyc_script_t, ...
Then execute:
restorecon -v '/srv/mysite/index.html'
***** Plugin catchall (17.1 confidence) suggests **************************
If you believe that httpd should be allowed getattr access on the index.html file by default.
Then you should report this as a bug.
You can generate a local policy module to allow this access.
# ausearch -c 'httpd' --raw | audit2allow -M my-httpd
# semodule -X 300 -i my-httpd.pp
Read the confidence numbers. 83.8 for "change the label" and 17.1 for "generate a module": the first one is almost always right, and the second is the one that gets pasted into forums. (The FILE_TYPE list is hundreds of names long: the one you want is httpd_sys_content_t, the type of the default web folder.) Install it with dnf install setroubleshoot-server; sealert -a /var/log/audit/audit.log reads the whole log at once and prints the same kind of advice (not run in the lab; the lab read the same advice from the journal).
Is it SELinux at all? One test settles it. Flip to permissive, retry, flip back:
$ setenforce 0 ; getenforce
Permissive
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost/
200
$ setenforce 1 ; getenforce
Enforcing
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost/
403
200 in permissive and 403 in enforcing: it is SELinux, and nothing else. The test took ten seconds. Leaving it at 0 "because it works now" is the mistake.
The fix that lasts: semanage fcontext and restorecon
Two ways to change a label, and only one of them lasts.
THE QUICK ONE: chcon. It sets the label on the file now, and nothing else. It fixes the symptom, and it fails silently later:
$ chcon -t httpd_sys_content_t /srv/mysite /srv/mysite/index.html
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost/
200
$ restorecon -n -v -R /srv/mysite # -n: dry run, show what it WOULD do
Would relabel /srv/mysite from unconfined_u:object_r:httpd_sys_content_t:s0 to unconfined_u:object_r:var_t:s0
Would relabel /srv/mysite/index.html from unconfined_u:object_r:httpd_sys_content_t:s0 to unconfined_u:object_r:var_t:s0
$ restorecon -v -R /srv/mysite
Relabeled /srv/mysite from unconfined_u:object_r:httpd_sys_content_t:s0 to unconfined_u:object_r:var_t:s0
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost/
403
restorecon puts every file back to what the POLICY says its label should be. The policy has never heard of /srv/mysite, so it says var_t, and the next relabel (or the next restorecon someone runs, or a package update, or the touch /.autorelabel from a rescue procedure) undoes the chcon. A website that works for three weeks and dies after a maintenance reboot is chcon.
THE RIGHT ONE: teach the policy once, then apply it. semanage fcontext adds a rule "files matching this path pattern get this label", stored permanently, and restorecon applies it:
$ matchpathcon /srv/mysite /srv/mysite/index.html # what the policy says today
/srv/mysite system_u:object_r:var_t:s0
/srv/mysite/index.html system_u:object_r:var_t:s0
$ semanage fcontext -a -t httpd_sys_content_t "/srv/mysite(/.*)?"
$ matchpathcon /srv/mysite /srv/mysite/index.html # and after
/srv/mysite system_u:object_r:httpd_sys_content_t:s0
/srv/mysite/index.html system_u:object_r:httpd_sys_content_t:s0
$ semanage fcontext -l -C # -C: only the rules YOU added
SELinux fcontext type Context
/srv/mysite(/.*)? all files system_u:object_r:httpd_sys_content_t:s0
$ restorecon -R -v /srv/mysite
Relabeled /srv/mysite from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
Relabeled /srv/mysite/index.html from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
$ curl -s http://localhost/ ; curl -s -o /dev/null -w "%{http_code}\n" http://localhost/
<h1>hello from /srv/mysite</h1>
200
$ matchpathcon -V /srv/mysite/index.html # does the file match what the policy says?
/srv/mysite/index.html verified.
Read the pattern: "/srv/mysite(/.*)?" is a regular expression meaning the folder itself and everything below it. Without the (/.*)? part you label the folder only. The type name comes from the table of types for the service: httpd_sys_content_t for pages to read, httpd_sys_rw_content_t for folders the site must WRITE to (uploads, caches), httpd_sys_script_exec_t for CGI programs. Choose the least that works. The rule is permanent (it survives reboots and relabels), and from now on a new file in the folder gets the right label by itself:
$ echo new > /srv/mysite/new.html ; ls -Z /srv/mysite/new.html
unconfined_u:object_r:httpd_sys_content_t:s0 /srv/mysite/new.html
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost/new.html
200
So the pair, and it is always the pair: semanage fcontext -a -t TYPE "PATH(/.*)?" then restorecon -Rv PATH. Forgetting the second half is the classic "I added the rule and it still does not work".
mv keeps the label, cp does not
A file gets its label when it is CREATED, from the folder it is created in. A new file made by cp is created in the destination, and is labelled for it. A file moved with mv, on the same file system, is not created at all: it is the same file under a new name, with the OLD label. This is how a perfectly labelled website folder collects a few files that give 403:
$ ls -Z /home/lab/moved.html /home/lab/copied.html
unconfined_u:object_r:user_home_t:s0 /home/lab/copied.html
unconfined_u:object_r:user_home_t:s0 /home/lab/moved.html
$ mv /home/lab/moved.html /srv/mysite/ ; cp /home/lab/copied.html /srv/mysite/
$ ls -Z /srv/mysite/moved.html /srv/mysite/copied.html
unconfined_u:object_r:httpd_sys_content_t:s0 /srv/mysite/copied.html
unconfined_u:object_r:user_home_t:s0 /srv/mysite/moved.html
$ curl -s -o /dev/null -w "moved.html %{http_code}\n" http://localhost/moved.html
moved.html 403
$ curl -s -o /dev/null -w "copied.html %{http_code}\n" http://localhost/copied.html
copied.html 200
$ restorecon -v /srv/mysite/moved.html
Relabeled /srv/mysite/moved.html from unconfined_u:object_r:user_home_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
$ curl -s -o /dev/null -w "moved.html %{http_code}\n" http://localhost/moved.html
moved.html 200
The cure is the same one-liner: restorecon -Rv on the destination after you move things in. Make it a reflex. The same applies to tar, rsync and scp: by default they create files in the destination (good), but tools that preserve extended attributes (tar --selinux, rsync -X) can bring the old labels along. (Not run in the lab: the tar, rsync and cp -a preservation options.)
Case 2: a service on a non-standard port
Apache is told to listen on 8081 as well. The configuration is valid, and the service will not start:
$ echo "Listen 8081" > /etc/httpd/conf.d/port8081.conf
$ apachectl configtest
Syntax OK
$ systemctl restart httpd
Job for httpd.service failed because the control process exited with error code.
See "systemctl status httpd.service" and "journalctl -xeu httpd.service" for details.
$ journalctl -u httpd --no-pager -n 3
httpd[16062]: AH00015: Unable to open logs
systemd[1]: httpd.service: Main process exited, code=exited, status=1/FAILURE
systemd[1]: Failed to start The Apache HTTP Server.
"Unable to open logs" is Apache's way of saying it could not bind a port. Not one word about SELinux. The audit log:
$ ausearch -m avc -ts recent -i -if /var/log/audit/audit.log | grep ^type=AVC | tail -1
type=AVC msg=audit(10/08/2026 18:55:10.923:634) : avc: denied { name_bind } for pid=16062 comm=httpd src=8081 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:transproxy_port_t:s0 tclass=tcp_socket permissive=0
name_bind on a tcp_socket: a program tried to listen on a port, and port 8081 is labelled transproxy_port_t, which httpd_t may not bind. The web ports are labelled http_port_t. Add the port to the right type:
$ semanage port -l | grep ^http_port_t
http_port_t tcp 80, 81, 443, 488, 8008, 8009, 8443, 9000
$ semanage port -a -t http_port_t -p tcp 8081
Port tcp/8081 already defined, modifying instead
$ semanage port -l -C
http_port_t tcp 8081
$ systemctl restart httpd ; systemctl is-active httpd
active
With a virtual host for it, served from the folder above:
$ curl -s -o /dev/null -w "port 8081: %{http_code}\n" http://localhost:8081/
port 8081: 200
The message "already defined, modifying instead" is worth reading: the port already had a label (somebody's policy reserved 8081 for something else), so the command changed it. Check what you are taking it away from with semanage port -l before you reuse an odd port. If the port is unused anywhere, you get -a (add) cleanly; to take a port back out of a type, semanage port -d -t http_port_t -p tcp 8081. The same recipe works for every service: sshd on 2222 needs semanage port -a -t ssh_port_t -p tcp 2222 (and the firewall) or sshd will not start on it (not run in the lab, as the lab's own ssh was the way in); MariaDB on 3307 needs mysqld_port_t; and so on. The change is permanent and applies at once, no relabel needed.
Case 3: booleans (Apache proxying to another service)
Some things a service may do are real choices (should a web server be allowed to connect out to other servers?) and the policy ships them as switches, "booleans", off by default for safety. Here Apache is set up as a reverse proxy for an application on 127.0.0.1:3000:
ProxyPass "/app/" "http://127.0.0.1:3000/"
ProxyPassReverse "/app/" "http://127.0.0.1:3000/"
$ curl -s http://127.0.0.1:3000/ # the backend itself works
backend says hi
$ curl -s -o /dev/null -w "/app/ -> %{http_code}\n" http://localhost/app/
/app/ -> 503
503 Service Unavailable from the proxy, and the backend is fine. The denial:
$ ausearch -m avc -ts recent -i -if /var/log/audit/audit.log | grep ^type=AVC | tail -1
type=AVC msg=audit(10/08/2026 18:55:14.750:639) : avc: denied { name_connect } for pid=16311 comm=httpd dest=3000 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:ntop_port_t:s0 tclass=tcp_socket permissive=0
name_connect: now the web server is trying to connect out to a port, 3000 (labelled ntop_port_t), and the policy forbids it. This time audit2why does better:
$ ausearch -m avc -ts recent -if /var/log/audit/audit.log | audit2why | tail -8
Was caused by:
The boolean httpd_can_network_connect was set incorrectly.
Description:
Allow httpd to can network connect
Allow access by executing:
# setsebool -P httpd_can_network_connect 1
The words "was caused by: the boolean ... was set incorrectly" are the best news audit2why can give: a switch exists for exactly this. List the switches for a service with getsebool -a | grep httpd, and read the descriptions with semanage boolean -l:
$ getsebool httpd_can_network_connect
httpd_can_network_connect --> off
$ setsebool httpd_can_network_connect on # now, until reboot
$ curl -s -o /dev/null -w "/app/ -> %{http_code}\n" http://localhost/app/ ; curl -s http://localhost/app/
/app/ -> 200
backend says hi
$ setsebool httpd_can_network_connect off
$ curl -s -o /dev/null -w "after off again: %{http_code}\n" http://localhost/app/
after off again: 503
$ setsebool -P httpd_can_network_connect on # -P = permanent, survives reboots
$ semanage boolean -l -C # only the booleans changed from their defaults
SELinux boolean State Default Description
httpd_can_network_connect (on , on) Allow httpd to can network connect
Without -P the change is runtime only and quietly disappears at the next reboot: a classic. The State and Default columns of semanage boolean -l show whether a boolean is currently different from what is saved. The ones people meet most:
httpd_can_network_connect Apache/PHP may connect to other servers (proxies, APIs, a database on another host)
httpd_can_network_connect_db just database ports, narrower
httpd_enable_homedirs serve ~user directories
httpd_can_sendmail PHP or CGI may send mail
httpd_unified, httpd_read_user_content, httpd_use_nfs ... and 300 more; getsebool -a | grep ^httpd_
samba_enable_home_dirs, samba_export_all_rw Samba sharing homes, or everything
ftpd_full_access, nfs_export_all_rw, virt_use_nfs ...
user_exec_content users may run programs from their home and /tmp (default on; used below)
A boolean is a broad switch: httpd_can_network_connect lets Apache connect to ANY port. Often that is fine; when you want to allow exactly one thing, the next section is narrower.
When there is no boolean: a small custom policy module
audit2allow reads denials and writes the policy that would allow them. It is a power tool, and the mistake is to use it on the whole log. This is what it proposes when it sees everything denied so far in the lab session, including the label problem from the first case:
$ ausearch -m avc -ts recent -if /var/log/audit/audit.log | audit2allow
#============= httpd_t ==============
#!!!! This avc can be allowed using the boolean 'httpd_can_network_connect'
allow httpd_t ntop_port_t:tcp_socket name_connect;
allow httpd_t transproxy_port_t:tcp_socket name_bind;
#!!!! This avc can be allowed using the boolean 'httpd_read_user_content'
allow httpd_t user_home_t:file read;
allow httpd_t var_t:file { getattr open read };
#!!!! This avc can be allowed using the boolean 'domain_can_mmap_files'
allow httpd_t var_t:file map;
Read it with a cold eye: it would let the web server read EVERY file labelled var_t (a vast amount of /var) and user_home_t (people's home directories), because that is what the denials, taken together, "asked for". Every one of those lines is a hole, and every one of them was a wrong label instead. audit2allow documents, honestly, which boolean already covers each line. RULES: never feed it the whole log, never paste its output into a module without reading it line by line, and fix labels and booleans first.
When a narrow rule really is what you need, build it from the ONE denial, and look at it before you install it. Here the proxy case again, with the boolean off, as a module that allows Apache to connect to port type ntop_port_t and nothing else:
$ setsebool -P httpd_can_network_connect off
$ curl -s -o /dev/null -w "/app/ -> %{http_code}\n" http://localhost/app/
/app/ -> 503
$ ausearch -m avc -ts recent --raw -if /var/log/audit/audit.log | grep name_connect | audit2allow -M httpd_app_backend
******************** IMPORTANT ***********************
To make this policy package active, execute:
semodule -i httpd_app_backend.pp
$ cat httpd_app_backend.te
module httpd_app_backend 1.0;
require {
type ntop_port_t;
type httpd_t;
class tcp_socket name_connect;
}
#============= httpd_t ==============
#!!!! This avc can be allowed using the boolean 'httpd_can_network_connect'
allow httpd_t ntop_port_t:tcp_socket name_connect;
$ semodule -i httpd_app_backend.pp && semodule -l | grep httpd_app
httpd_app_backend
$ curl -s -o /dev/null -w "/app/ -> %{http_code}\n" http://localhost/app/
/app/ -> 200
$ getsebool httpd_can_network_connect # the broad switch is still off
httpd_can_network_connect --> off
$ semodule -r httpd_app_backend ; curl -s -o /dev/null -w "after removing the module: %{http_code}\n" http://localhost/app/
after removing the module: 503
The .te file (type enforcement source) is five lines and says exactly what is allowed: httpd_t may name_connect on ntop_port_t sockets. That is the sort of change you can review, keep in version control and explain to an auditor, which a blanket boolean is not. -M NAME builds NAME.pp, the loadable package; semodule -i installs, semodule -l lists, semodule -r removes. Modules built this way are stored under /var/lib/selinux and survive reboots and policy updates. Keep the .te file somewhere safe: it is the source.
Permissive for one service only
Switching the whole system to permissive to debug one daemon throws away protection everywhere else. A permissive DOMAIN is the surgical version: only httpd_t is allowed to do anything, and everything it does is logged:
$ semanage permissive -a httpd_t
$ semanage permissive -l
Builtin Permissive Types
dhcpc_hook_t
qgs_t
...
Customized Permissive Types
httpd_t
$ getenforce # the system is still enforcing
Enforcing
$ curl -s -o /dev/null -w "/app/ with httpd_t permissive: %{http_code}\n" http://localhost/app/
/app/ with httpd_t permissive: 200
$ ausearch -m avc -ts recent -i -if /var/log/audit/audit.log | grep ^type=AVC | tail -1
type=AVC msg=audit(10/08/2026 18:56:16.312:718) : avc: denied { name_connect } for pid=16605 comm=httpd dest=3000 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:ntop_port_t:s0 tclass=tcp_socket permissive=1
$ semanage permissive -d httpd_t
The request worked and the denial was logged, with permissive=1 instead of 0: that is permissive mode, and it is how you collect ALL the things a new application needs in one session instead of one denial per restart. Run the application through its whole life (start, normal use, shutdown), then read the log, fix labels and booleans, and take the permissive flag off again (-d). Do not leave it on.
One more thing to know: some denials are never logged, on purpose. The policy contains "dontaudit" rules for harmless-but-noisy denials, and they can hide the very one you are looking for. Switch them off for a debugging session, and rebuild the policy afterwards:
$ seinfo | grep -i 'Dontaudit:'
Auditallow: 169 Dontaudit: 8546
$ semodule -DB # D = disable dontaudit rules, B = rebuild
$ seinfo | grep -i 'Dontaudit:'
Auditallow: 169 Dontaudit: 0
$ semodule -B # rebuild normally: dontaudit back
$ seinfo | grep -i 'Dontaudit:'
Auditallow: 169 Dontaudit: 8546
The audit log gets noisy in the meantime, so keep it short. If a program fails and the log shows nothing at all, semodule -DB, retry, read, and semodule -B.
Confined users and what "unconfined" really means
By default every login is unconfined_u, and so is your whole admin session: SELinux is watching the services and leaving people alone. For accounts that should never be able to do much (a kiosk, a contractor, a shared jump account) there are confined users. Here a user alice is mapped to user_u. The mapping is applied at LOGIN, so the lab logged in with ssh (su keeps the SELinux user of the person who ran it and shows nothing):
$ semanage login -a -s user_u alice
$ semanage login -l | grep alice
alice user_u s0 *
$ ssh alice@server1 'id -Z; id'
user_u:user_r:user_t:s0
uid=1001(alice) gid=1001(alice) groups=1001(alice) context=user_u:user_r:user_t:s0
$ ssh alice@server1 '~/hello.sh ; sudo -n true ; cat /etc/shadow ; ps -eZ | head -3'
my script ran
sudo: PERM_SUDOERS: setresuid(-1, 1, -1): Operation not permitted
cat: /etc/shadow: Permission denied
LABEL PID TTY TIME CMD
- 1 ? 00:00:02 systemd
- 15993 ? 00:00:00 (sd-pam)
user_t cannot use sudo (it cannot change identity, whatever the sudoers file says), cannot read /etc/shadow, sees no labels in the process list (the LABEL column shows only dashes), and its ability to run programs from its own folders is a switch. user_exec_content on: it may run what is in its home. Off:
$ setsebool user_exec_content off
$ ssh alice@server1 '~/hello.sh; echo rc=$?'
bash: /home/alice/hello.sh: Permission denied
rc=126
$ ausearch -m avc -ts recent -i -if /var/log/audit/audit.log | grep ^type=AVC | tail -1
type=AVC msg=audit(10/08/2026 18:57:37.601:1011) : avc: denied { execute } for pid=17543 comm=bash name=hello.sh dev="vda4" ino=50337025 scontext=user_u:user_r:user_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0
The users the policy knows: guest_u and xguest_u (very restricted guests), user_u (an ordinary confined user, used above), staff_u (confined, but may become an administrator role through sudo), sysadm_u (administrator), unconfined_u (the default), root and system_u (for the system and its services). List them with semanage user -l, and put the whole login group on a mapping other than unconfined_u with semanage login -m -s staff_u -r s0 __default__ (not run in the lab: changing the default is a decision for a whole system).
The honest summary of "unconfined": it is not "no protection". An unconfined user is not stopped by SELinux, but a service the user starts (Apache, sshd) is still in its own box. Removing a login mapping again: semanage login -d alice.
Categories (MCS), and custom services that run unconfined
The last part of a label, s0:c1 or s0-s0:c0.c1023, is the level and its CATEGORIES. Categories (MCS, multi-category security) were designed to keep identical programs apart: two web servers of the same type, each with its own category, cannot read each other's files. Containers and virtual machines use this: every container gets a random pair like s0:c123,c456 so that one container cannot touch another's files even though both are container_t. One thing the lab showed directly: categories only bind CONFINED domains. A process in an unconfined domain ignores them:
$ runcon -l s0:c1 sh -c 'id -Z; echo secret-of-c1 > /tmp/c1.txt; ls -Z /tmp/c1.txt'
unconfined_u:unconfined_r:unconfined_t:s0:c1
unconfined_u:object_r:user_tmp_t:s0 /tmp/c1.txt
$ runcon -l s0:c2 cat /tmp/c1.txt # a different category: still reads it, because unconfined_t is not restricted by it
secret-of-c1
So categories are not a tool for the administrator's own shell. They matter for containers (podman, Docker with SELinux enabled, and the :z and :Z volume options that relabel a host folder for a container; not run in the lab, which had no container engine) and for virtual machines under libvirt.
The other thing about "unconfined": a program of your own, started by systemd from a folder with no SELinux policy of its own, does not get confined at all. It gets a special type, unconfined_service_t, which is as free as a logged-in administrator:
$ ls -Z /opt/mydaemon/run.sh
unconfined_u:object_r:usr_t:s0 /opt/mydaemon/run.sh
$ systemctl start mydaemon ; ps -eZ | grep -E 'run.sh|sleep'
system_u:system_r:unconfined_service_t:s0 16975 ? 00:00:00 run.sh
system_u:system_r:unconfined_service_t:s0 16977 ? 00:00:00 sleep
(a systemd unit with only an ExecStart= pointing at that script.) That is why a hand-made service "just works" with SELinux on, and also why it gets none of the protection. Writing a policy for your own daemon is a project of its own (the sepolicy generate tool starts one; not run in the lab). The practical point is the reverse: if your own service works and Apache's does not, it is not that SELinux is "random": one is confined and the other was never in the cage.
Relabeling the whole system
Sometimes labels are wrong in many places at once: files restored from a backup that did not keep attributes, a disk mounted from another system, a switch from disabled to enforcing. Ask for a full relabel on the next boot with one empty file:
$ ls -Z /etc/hostname /var/www/html/probe.html # two files deliberately mislabelled with chcon
system_u:object_r:user_home_t:s0 /etc/hostname
unconfined_u:object_r:user_home_t:s0 /var/www/html/probe.html
$ touch /.autorelabel
$ systemctl reboot
The boot is slower than usual (the console says "*** Warning -- SELinux targeted policy relabel is required." and then relabels every file on every file system, which on a large disk is minutes to hours), the flag file removes itself, and the machine reboots once more by itself. After it:
$ ls -Z /etc/hostname /var/www/html/probe.html
system_u:object_r:hostname_etc_t:s0 /etc/hostname
unconfined_u:object_r:httpd_sys_content_t:s0 /var/www/html/probe.html
$ ls -ld /.autorelabel
ls: cannot access '/.autorelabel': No such file or directory
Both files are back to what the policy says: /etc/hostname to its own type, the stray file in the web folder to httpd_sys_content_t. Everything you added with semanage (the fcontext rule, the port, the boolean) survived, which is the point of using semanage and not chcon:
$ semanage port -l -C # still there after the reboot
http_port_t tcp 8081
$ semanage fcontext -l -C
/srv/mysite(/.*)? all files system_u:object_r:httpd_sys_content_t:s0
Apache was up and answering on both ports after the reboot, with no further changes. For a smaller job, relabel one tree without rebooting: restorecon -Rv /var/www (and add -n first to see what it would change). fixfiles relabel (with a prompt) and fixfiles -F onboot do the whole thing with extra checks (not run in the lab). One error worth knowing: semanage run without root answers
ValueError: SELinux policy is not managed or store cannot be accessed.
which sounds like a damaged system and is only "you are not root".
The troubleshooting flow, start to finish
When something that should work does not, and you suspect SELinux:
1. Is it enforcing at all? getenforce. If it says Disabled or Permissive, it is not the reason.
2. One test: setenforce 0, retry, setenforce 1. If it works only in permissive, it is SELinux. If not, look elsewhere.
3. Read the denial: ausearch -m avc -ts recent -i -if /var/log/audit/audit.log, or journalctl -t setroubleshoot. Note the comm (who), the tclass and the permission (what), and BOTH labels.
4. Decide which of the three it is:
the TARGET label is wrong for what the program should access (var_t, user_home_t, default_t, tmp_t where a service type belongs) => semanage fcontext + restorecon
the denial is name_bind or name_connect on a port type the service may not use => semanage port
audit2why says "the boolean X was set incorrectly" => setsebool -P X on (after reading what it allows)
none of those, and the access is legitimate => a small, reviewed module built from that ONE denial
5. Check the fix: curl, systemctl, whatever failed, and restore enforcing mode if you changed it.
6. If nothing appears in the audit log at all: semodule -DB (hidden denials), reproduce, read, semodule -B. If still nothing, it is not SELinux.
7. Write down what you changed (the semanage commands). They are the documentation, and the way to repeat it on the next server.
Tools worth installing once: policycoreutils-python-utils (semanage), setools-console (seinfo, sesearch), setroubleshoot-server, and policycoreutils-devel for sepolicy (not run in the lab).
Cheat sheet
getenforce / sestatus current mode and policy
setenforce 0|1 permissive / enforcing until reboot (a test, not a fix)
ls -Z ps -eZ id -Z ss -ltnpZ labels on files, processes, you, listeners
restorecon -Rv PATH (-n = dry run) apply the policy's labels to PATH
semanage fcontext -a -t TYPE "PATH(/.*)?" permanent file-label rule, then restorecon
semanage fcontext -l -C my own file rules
matchpathcon PATH / matchpathcon -V PATH what the label should be / does it match
chcon -t TYPE PATH temporary label (does not survive a relabel)
semanage port -a -t TYPE -p tcp PORT / -l / -d port labels
getsebool -a / setsebool -P NAME on|off booleans (-P = permanent)
semanage boolean -l / -l -C booleans with descriptions / changed ones
ausearch -m avc -ts recent -i -if /var/log/audit/audit.log the denials (the -if matters in scripts)
ausearch ... | audit2why why, and which boolean
ausearch ... | audit2allow -M NAME ; semodule -i NAME.pp a custom module (read the .te first)
semodule -l / -r NAME list / remove modules
semanage permissive -a|-d DOMAIN / -l one permissive domain
semodule -DB / semodule -B show hidden denials / go back to normal
semanage login -l / semanage user -l users and roles
touch /.autorelabel ; reboot full relabel on the next boot
journalctl -t setroubleshoot plain-language advice for each denial
seinfo / sesearch -A -s SRC -t TGT -c CLASS look inside the loaded policy
Common problems
403 Forbidden, permissions look right the files are not httpd_sys_content_t: ls -Z, then semanage fcontext + restorecon. The error page never mentions SELinux
Service will not start on a port that is "free" the port label: ausearch shows name_bind. semanage port -a -t TYPE -p tcp PORT
Proxy or PHP gets "connection refused" or 503 for a service that works httpd_can_network_connect (or a narrow module for the one port): audit2why names it
Files work, then break after a reboot or an update they were labelled with chcon. Use semanage fcontext, then restorecon
Files moved in with mv give 403, copied ones work mv keeps the old label: restorecon -Rv the destination
I ran the semanage command and it still fails the second half is missing: restorecon -Rv PATH (fcontext) or a restart (port); or check with matchpathcon -V
setsebool worked, then it was off again no -P: setsebool -P NAME on. semanage boolean -l -C shows the saved value
ausearch or aureport hangs in a script it is waiting on a pipe on stdin: give the file with -if /var/log/audit/audit.log (or redirect < /dev/null)
ValueError: SELinux policy is not managed ... not root: use sudo
Nothing in the audit log, but it fails only with SELinux on a dontaudit rule hides it: semodule -DB, retry, read, semodule -B. Also check auditd is running
audit2allow wants to allow things that look wrong they are: that is wrong labels, not missing policy. Fix the labels and rerun with only the one real denial
The whole disk was restored from a backup and nothing works restore with attributes next time (tar --selinux, rsync -aX, cp -a); now touch /.autorelabel and reboot
System does not boot after enabling SELinux it was disabled: relabel under permissive first (SELINUX=permissive, touch /.autorelabel, reboot), enforce only afterwards. Rescue: enforcing=0 on the kernel line
A container cannot read a mounted host folder the folder needs the container's label: the :z or :Z option on the volume (not run in the lab), or semanage fcontext with container_file_t
My own daemon works, Apache does not the custom daemon runs as unconfined_service_t: not confined, not an example of anything
It works in permissive and I do not know why that is what the audit log from the permissive run is for: ausearch, then fix the labels or booleans it points at
"setenforce 0" is in the install guide of a product read the denials and fix the labels instead; permissive is for diagnosing. If the vendor insists, ask them for their policy module
Tips
Do not turn SELinux off. Turning it off removes the one layer that still protects a compromised web server from the rest of the machine. The shortest correct fix is almost always one semanage line and one restorecon.
Use setenforce 0 for exactly one test and then put it back. If it still fails in permissive, SELinux is not your problem.
Read the labels before reading anything else: ls -Z on the file, ps -Z on the process. The two types tell you most of the story.
semanage + restorecon, never chcon alone. A fix that does not survive a relabel is a future outage.
After moving or restoring files, run restorecon -Rv on them. After a tar or rsync from another box, the same.
Never feed audit2allow the whole log, and never install a module you have not read. Build it from the single denial you understand.
Prefer a boolean or a label over a module, and a narrow module over a broad boolean when the thing you allow is one port or one path.
Use -P with setsebool. A runtime-only boolean is an incident waiting for the next reboot.
Give ausearch and aureport the log file (-if) whenever they run without a terminal.
Keep a short file of every semanage and setsebool command you ran for a server. It is both the documentation and the way to rebuild it.
Test with permissive=1 for one domain (semanage permissive -a) when a new application needs many permissions, and remove it afterwards.
Install setroubleshoot-server on servers you debug: its confidence numbers are a good first opinion.