RHEL Intermediate #1: Intro to SELinux — Enforcing/Permissive, Labels, Troubleshooting
Basics #7 only touched on SELinux in one line: “On RHEL 9, SELinux is Enforcing by default — don’t turn it off.” But once you’re in operations, that line is what trips you up most often. This post is about solving problems without turning SELinux off — how to change modes, how to check labels, how to unblock denied requests — all in one place. A solid understanding of SELinux noticeably improves RHEL operational stability.
The position of this post in the RHEL Intermediate series:
- #1 Intro to SELinux — Enforcing/Permissive, labels, troubleshooting ← this post
- #2 LVM — PV/VG/LV, snapshots, expansion
- #3 Advanced storage — Stratis, NFS, Samba
- #4 Networking — NetworkManager (nmcli), bonding, teaming
- #5 Log management — journald, rsyslog, log rotation
- #6 Job scheduling — cron, systemd timer, at
- #7 Intro to containers — Podman/Buildah/Skopeo (differences from Docker)
What is SELinux #
Linux’s basic permission model is DAC (Discretionary Access Control) — the model governed by the user/group/rwx bits on each file, covered in Basics #5. The problem with DAC comes down to one line: a process holding root permissions can access every file.
Suppose a web server is breached and an attacker takes control of the httpd process. That process runs as root or the apache user, and either way it can roam freely within its own permissions — reading /etc/shadow, accessing /var/lib/mysql, stealing SSH keys. DAC alone can’t block any of that.
SELinux (Security-Enhanced Linux) is MAC (Mandatory Access Control) layered one more level on top. Every file, process, socket, and port is given a label (context), and the kernel forcibly checks “may a process with this label access a resource with that label” according to policy. Even if DAC passes, SELinux can block, and even root cannot bypass SELinux.
request: httpd process tries to read /etc/shadow
│
▼
┌─────────────────────────────────────┐
│ 1. DAC check │
│ Does httpd's user pass │
│ /etc/shadow's permissions │
│ (usually 600 root)? │
│ → almost always denied. If pass ↓ │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 2. SELinux (MAC) check │
│ Did policy allow │
│ a process of type httpd_t │
│ to read a file of type shadow_t? │
│ → if not in policy, denied │
└─────────────────────────────────────┘
│
▼
access grantedEven when DAC is bypassed, SELinux catches it. Think of it as the second lock — the defining difference in RHEL 9’s security model.
Three modes — Enforcing / Permissive / Disabled #
SELinux has three operating modes.
| Mode | Behavior | Recommendation |
|---|---|---|
| Enforcing | Block policy violations + log | ✅ Operational standard (RHEL 9 default) |
| Permissive | Allow policy violations + log only | Temporary for debugging / policy authoring |
| Disabled | SELinux itself doesn’t operate | ❌ Absolutely forbidden in operations |
Check:
$ 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: 33sestatus gives richer info. A Loaded policy name of targeted is the norm in most environments. MLS is used only in specialized environments such as military or government.
Temporary toggle — setenforce
#
$ sudo setenforce 0 # to Permissive (revert on reboot)
$ sudo setenforce 1 # to Enforcing
$ getenforce
PermissiveOnly during debugging — drop briefly to Permissive, then raise it back to Enforcing when done.
Permanent toggle — /etc/selinux/config
#
SELINUX=enforcing
SELINUXTYPE=targetedChange the SELINUX= value to permissive / enforcing / disabled and reboot to apply. However:
- Never use
disabled. Once you boot with disabled, all file labels are wiped, and to come back to Enforcing requires a full filesystem relabel (tens of minutes to hours). - To temporarily turn off SELinux checks, always use
permissive. Debugging and log collection work the same in Permissive.
autorelabel — when labels are broken #
When something went wrong and you want to relabel the entire filesystem:
$ sudo touch /.autorelabel
$ sudo rebootOn boot, all files are relabeled per policy. This command appears in the final step of disabled → enforcing transitions — and is generally something you’ll type once in a lifetime.
The shape of label (context) #
Every SELinux decision is based on labels. ls -Z and ps -Z are the commands that show labels.
$ ls -Z /var/www/html/index.html
unconfined_u:object_r:httpd_sys_content_t:s0 /var/www/html/index.html
$ ls -Z /etc/shadow
system_u:object_r:shadow_t:s0 /etc/shadow
$ ls -Z /home/curtis
unconfined_u:object_r:user_home_dir_t:s0 /home/curtis$ ps -eZ | grep httpd
system_u:system_r:httpd_t:s0 1234 ? 00:00:01 httpd
$ id -Z
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023Labels are four parts separated by colons.
system_u : object_r : httpd_sys_content_t : s0
────┬─── ───┬──── ─────────┬────────── ─┬─
│ │ │ │
user role type level (MLS)| Field | Meaning | Common values |
|---|---|---|
| user | SELinux user (different from Linux user) | system_u, unconfined_u, staff_u |
| role | Role (RBAC) | object_r (file), system_r (system process), unconfined_r |
| type | Type — the actual key of policy | httpd_t, httpd_sys_content_t, shadow_t, ssh_port_t |
| level | MLS level (almost always s0 in targeted policy) | s0 |
In targeted policy, you essentially only need to care about type. So 90% of SELinux work is examining “which type is assigned to which file.” User and role almost always match automatically, and level has no meaning in targeted policy.
Frequently encountered types #
| type | Meaning |
|---|---|
httpd_t | Web server (Apache/nginx) process |
httpd_sys_content_t | Static content readable by web server |
httpd_sys_rw_content_t | Content readable and writable by web server |
shadow_t | Password files like /etc/shadow |
ssh_home_t | SSH keys inside ~/.ssh |
user_home_dir_t | Regular user home directory |
var_log_t | Log files inside /var/log |
bin_t | Executables inside /usr/bin |
unconfined_t | Process barely constrained by SELinux (user shell, etc.) |
The combination of these types and policy decides “may this access that.” For example, policy allows httpd_t → httpd_sys_content_t reads but absolutely never httpd_t → shadow_t reads.
Fixing labels — chcon / restorecon
#
The most common fix in day-to-day operations. Placing a file at the wrong path or copying it with the wrong command produces incorrect labels and SELinux blocks access. Two commands handle this.
chcon — temporary change
#
$ sudo chcon -t httpd_sys_content_t /var/www/html/new.html
$ ls -Z /var/www/html/new.html
unconfined_u:object_r:httpd_sys_content_t:s0 /var/www/html/new.html
$ sudo chcon -R -t httpd_sys_content_t /var/www/html/ # recursivechcon changes the type manually. Fast and intuitive, but with a trap: when restorecon or a relabel runs, it reverts to the type the original policy specifies. Use it for temporary work only.
restorecon — restore per policy
#
$ sudo restorecon -v /var/www/html/new.html
Relabeled /var/www/html/new.html from unconfined_u:object_r:user_home_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
$ sudo restorecon -Rv /var/www/html/ # recursiverestorecon looks at what type the policy defines for that path and resets to it. If labels are broken, restorecon is almost always the answer — and it’s safer than chcon.
General flow #
1. Copy file to new path (cp / mv)
↓ label follows from original path (wrong)
2. Web server denies read (SELinux AVC denial)
3. Check wrong label with ls -Z
4. Recover per policy with restorecon -Rv <path>
5. Try again → passTrap —
cpmostly attaches new labels per the destination path’s policy, whilecp -a(archive) andmv-style operations preserving metadata can keep the source label. So a simple move likemv ~/page.html /var/www/html/is the #1 cause of label incidents. Runningrestorecononce after the move is safe.
Permanent label policy change — semanage fcontext
#
What if you want to use a different directory as the web content path instead of /var/www/html? Even applying chcon repeatedly, a single restorecon reverts it. The policy itself must be changed.
$ sudo dnf install -y policycoreutils-python-utils
$ sudo semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?"
$ sudo restorecon -Rv /srv/wwwMeaning of the three lines:
policycoreutils-python-utils— package providingsemanagecommand. Sometimes not in default RHEL 9 installsemanage fcontext -a -t <type> "<regex>"— register in policy that the regex path pattern gets that typerestorecon -Rv— apply registered policy to actual files
After that, anything inside /srv/www is always httpd_sys_content_t, and newly created files automatically receive that type. This approach is the operational standard.
$ sudo semanage fcontext -l | grep '/srv/www'
/srv/www(/.*)? all files system_u:object_r:httpd_sys_content_t:s0
$ sudo semanage fcontext -d "/srv/www(/.*)?" # unregisterPorts also have labels — semanage port
#
We briefly saw this in Basics #7 when changing the SSH port. Port numbers also carry SELinux labels, so policy denies any daemon that tries to listen on a port not registered for its type.
$ sudo semanage port -l | grep ssh
ssh_port_t tcp 22
$ sudo semanage port -a -t ssh_port_t -p tcp 2222 # add
$ sudo semanage port -l | grep ssh
ssh_port_t tcp 2222, 22Web server port (http_port_t), DB port (postgresql_port_t), etc. follow the same model. When a daemon runs on a non-standard port, this step is always needed.
Booleans — on/off switches for policy #
Rather than writing new policy each time, commonly needed options are pre-packaged as booleans — on/off switches, as the name suggests.
$ getsebool -a | head -10
abrt_anon_write --> off
abrt_handle_event --> off
...
$ getsebool -a | grep httpd | head -10
httpd_anon_write --> off
httpd_can_check_spam --> off
httpd_can_connect_ftp --> off
httpd_can_network_connect --> off
httpd_can_network_connect_db --> off
httpd_can_sendmail --> off
httpd_enable_cgi --> on
...Frequently turned on:
# Allow httpd to go to external network (e.g., nginx → upstream)
$ sudo setsebool -P httpd_can_network_connect on
# Allow httpd to connect to DB (postgres/mysql, etc.)
$ sudo setsebool -P httpd_can_network_connect_db on
# Mount remote home as NFS client for SSH key auth
$ sudo setsebool -P use_nfs_home_dirs on-P makes it permanent. Without it, reverts on reboot. In operations, always attach -P.
$ getsebool httpd_can_network_connect
httpd_can_network_connect --> on
$ semanage boolean -l | grep httpd_can_network_connect
httpd_can_network_connect (on , on) Allow HTTPD scripts and modules to connect to the network using TCP.AVC denial — the signal SELinux blocked #
When SELinux blocks something, an AVC denial log is written. (AVC = Access Vector Cache.) This is the starting point of troubleshooting.
$ sudo ausearch -m avc -ts recent
time->Wed Apr 16 10:23:12 2026
type=AVC msg=audit(1713248592.123:456):
avc: denied { read } for pid=1234 comm="httpd"
name="config.json" dev="vda2" ino=12345
scontext=system_u:system_r:httpd_t:s0
tcontext=unconfined_u:object_r:user_home_t:s0
tclass=file permissive=0Unpacking the key fields:
denied { read }— what was denied (read / write / open / connectto, etc.)comm="httpd"— what command triedscontext— source context — label of the trying process (httpd_t)tcontext— target context — label of the resource being accessed (user_home_t)tclass— resource kind (file / dir / tcp_socket, etc.)
Almost all diagnosis starts with these fields. In the example above, httpd_t tried to read a user_home_t file and was denied — the policy doesn’t allow that combination. The fix is almost always to relabel that file as httpd_sys_content_t.
Also see via journalctl #
$ sudo journalctl -t setroubleshoot
Apr 16 10:23:14 rhel9-lab setroubleshoot[5678]:
SELinux is preventing httpd from read access on the file config.json.
For complete SELinux messages run: sealert -l ...The setroubleshoot daemon converts AVC entries into human-readable messages — fairly friendly notifications.
sealert — friendly troubleshooting notice for humans
#
When the setroubleshoot-server package is installed, you can use a friendlier tool.
$ sudo dnf install -y setroubleshoot-server
$ sudo systemctl enable --now setroubleshootd$ sudo sealert -a /var/log/audit/audit.log
100% done
found 1 alerts in /var/log/audit/audit.log
--------------------------------------------------------------------------------
SELinux is preventing /usr/sbin/httpd from read access on the file config.json.
***** Plugin restorecon (99.5 confidence) suggests ************************
If you want to fix the label.
/var/www/html/config.json default label should be httpd_sys_content_t.
Then you can run restorecon. The access attempt may have been stopped due
to insufficient permissions to access a parent directory in which case
try this command:
# restorecon -Rv /var/www/html/config.json
...The Plugin section shows recommended solutions with confidence scores. At 99.5%, you can generally follow the suggestion as is. Roughly half of SELinux troubleshooting in operations is resolved with this single command.
Creating policy — audit2allow
#
Use this when sealert can’t give an answer, or when you want to write policy directly. It takes AVC denials as input and automatically generates a policy module that allows them.
# 1) Drop to Permissive and run all work once (logs even would-deny, not just denials)
$ sudo setenforce 0
$ # ... run app once normally ...
$ sudo setenforce 1
# 2) Gather accumulated AVCs and convert to policy module
$ sudo ausearch -m avc -ts recent | sudo audit2allow -M myapp
$ ls
myapp.pp myapp.te
# 3) Apply module
$ sudo semodule -i myapp.ppThe .te file is human-readable policy and .pp is the compiled module. Open the .te and review it before applying — audit2allow turns every visible denial into an allow rule, so a mistake can bake a security hole into policy permanently.
$ sudo semodule -l | grep myapp # list of applied modules
$ sudo semodule -r myapp # removeRecommended operational order — (1) try
restoreconfirst, (2) if not, register in policy viasemanage fcontext/semanage port, (3) check if it’s a case solved by boolean, (4) if still not, finallyaudit2allow. Cases solved by 1–3 are over 90%.
Five frequently encountered traps #
“Changed the port and the daemon won’t come up” #
Also covered in Basics #7. When sshd is moved to a non-standard port like 2222, SELinux blocks it. Register the port with semanage port -a -t ssh_port_t -p tcp 2222.
“Web content placed in home directory isn’t readable” #
When web content is placed in paths like ~/public_html, the label is user_home_t so httpd_t can’t read it. Two options:
# 1) Via boolean — let httpd read user home directories
$ sudo setsebool -P httpd_enable_homedirs on
$ sudo setsebool -P httpd_read_user_content on
# 2) Move content to /var/www/html or registered path (operational recommendation)“File moved with mv isn’t readable”
#
mv can carry over the source label. Running restorecon -Rv <path> after moving is safer.
“DB needs to open on a non-standard port but it’s blocked” #
$ sudo semanage port -a -t postgresql_port_t -p tcp 5433Web, DB, and messaging daemons all have their own port type. Check with semanage port -l | grep <service>.
“Container mounted host directory but it’s not visible” #
When Podman/Docker container mounts a host volume but SELinux blocks. Adding :Z (private) or :z (shared) to the mount option auto-attaches the label.
$ podman run -v /host/path:/container/path:Z myimageDetailed container + SELinux in #7 of this series.
When setroubleshoot notifications appear on desktop
#
In GUI environments, setroubleshoot shows AVC denials as desktop notifications. Keeping it on in a learning environment gives you immediate visibility into what SELinux is blocking, which speeds things up. In production it’s often turned off due to notification volume, but it’s recommended on learning VMs.
AlmaLinux / Rocky differences #
Every command in this post works as is. SELinux policy is taken directly from RHEL’s selinux-policy package, so there’s no difference. semanage, restorecon, audit2allow, and sealert are all identical.
Frequently used commands in one table #
| Command | What it does |
|---|---|
getenforce / sestatus | Current mode / detailed status |
setenforce 0/1 | Runtime mode toggle |
ls -Z <file> / ps -eZ / id -Z | File / process / my labels |
chcon -t <type> <file> | Temporary label change |
restorecon -Rv <path> | Recover labels per policy |
semanage fcontext -a -t <type> "<regex>" | Register permanent label policy |
semanage port -a -t <type> -p tcp <port> | Register port label |
getsebool -a / setsebool -P <bool> on/off | boolean settings |
ausearch -m avc -ts recent | Recent AVC denials |
sealert -a /var/log/audit/audit.log | Human-readable diagnosis |
audit2allow -M <name> | Convert denial logs to policy module |
semodule -l / -i / -r | Policy module management |
Summary #
The picture held in this post:
- SELinux is MAC layered on top of DAC — policy checks that even root can’t bypass.
- Modes are Enforcing (default) / Permissive (debugging) / Disabled (absolutely forbidden). Temporary via
setenforce, permanent via/etc/selinux/config. - All files, processes, and ports have labels (context), and in targeted policy, essentially only need to care about type.
- Fixing labels is
restorecon(reset per policy) 90% of the time, withchcon(temporary) as an auxiliary. Permanent policy changes go throughsemanage fcontext. - Non-standard ports registered via
semanage port. - Frequently used policy options are booleans as on/off switches —
setsebool -Pis standard. - When something is blocked, the flow is: AVC denial log →
sealertfor diagnosis →restorecon/semanage/ boolean →audit2allowas a last resort.
Next — LVM #
Covering LVM, briefly seen in Basics #6, in earnest. The standard for operational disk management.
#2 LVM — PV/VG/LV, snapshots, expansion works through the three-layer relationship of PV / VG / LV hands-on, the flow of adding a new PV to grow an LV when a disk fills up, the pattern of capturing a pre-backup state with snapshots and rolling back, and deeper options like thin provisioning and striping.