SELinux & AppArmor
Normal Linux permissions, owner, group and others, are called discretionary access control (DAC): the owner of a file decides who may use it, and root may do anything. That leaves a gap. If an attacker breaks into nginx through a bug, the attacker gets everything the nginx user can touch, and if a service runs as root, everything at all.
Mandatory access control (MAC) closes that gap. A central policy says what each program may do, regardless of file permissions and even for root: the web server may read web content and connect to its backend, and nothing else. Linux has two widely used MAC systems: SELinux, enabled by default on RHEL, Rocky, AlmaLinux and Fedora, and AppArmor, enabled by default on Ubuntu and Debian. This lesson explains both and shows the most common real problem: a denial that breaks an application, and how to fix it correctly instead of switching protection off.
SELinux: modes and labels
SELinux runs in one of three modes: enforcing (denials are blocked and logged), permissive (nothing is blocked, but would-be denials are logged) and disabled.
getenforce
sestatus | head -n 5Enforcing SELinux status: enabled SELinuxfs mount: /sys/fs/selinux SELinux root directory: /etc/selinux Loaded policy name: targeted Current mode: enforcing
The targeted policy confines network-facing services such as web servers, databases and SSH, while ordinary user programs run largely unconfined. The mode set at boot lives in /etc/selinux/config; sudo setenforce 0 switches to permissive until the next reboot, which is useful for one quick test but nothing more.
SELinux decides using labels (also called contexts) on every process and file, shown with -Z:
ps -eZ | grep nginx | head -n 1
ls -Zd /usr/share/nginx/html /srv/shopsystem_u:system_r:httpd_t:s0 1520 ? 00:00:00 nginx system_u:object_r:httpd_sys_content_t:s0 /usr/share/nginx/html unconfined_u:object_r:var_t:s0 /srv/shop
A label has four parts, user:role:type:level, but you mostly care about the type, the third part. The policy is a long list of rules such as "processes of type httpd_t may read files of type httpd_sys_content_t". nginx runs as httpd_t (all web servers share this type). The default web folder has the right type; Priya's new folder /srv/shop has the generic var_t, so nginx will get 403 Forbidden there even though chmod permissions are perfect.
Fixing SELinux denials properly
First, confirm that SELinux really is the cause. Denials are recorded by the audit system:
sudo ausearch -m AVC -ts recent----
time->Mon Nov 2 10:14:07 2026
type=AVC msg=audit(1793594647.212:412): avc: denied { read } for pid=1521 comm="nginx" name="index.html" dev="dm-0" ino=1310722 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:var_t:s0 tclass=file permissive=0Read it as a sentence: process nginx with type httpd_t (scontext) was denied read on a file (tclass) named index.html with type var_t (tcontext). The fix is to give the folder the right type, permanently. semanage fcontext adds a rule to the policy's list of default labels, and restorecon applies the list to the files:
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/shop(/.*)?"
sudo restorecon -Rv /srv/shopRelabeled /srv/shop from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0 Relabeled /srv/shop/index.html from unconfined_u:object_r:var_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
The pattern "/srv/shop(/.*)?" covers the folder and everything inside. Because the rule is now in the policy, new files and future relabels keep the right type. (semanage comes from the policycoreutils-python-utils package.) Never use chcon for permanent fixes: it changes the label directly, and the next full relabel undoes it.
The second common case is a behaviour that the policy can switch on with a boolean, an on/off setting. For example, nginx acting as a reverse proxy must connect to an application on another port, which the policy blocks by default:
getsebool httpd_can_network_connect
sudo setsebool -P httpd_can_network_connect on
getsebool httpd_can_network_connecthttpd_can_network_connect --> off httpd_can_network_connect --> on
-P makes the change permanent. There are dozens of booleans for web servers alone, covering things like sending mail or reading users' home folders; turn on only the one you need.
AppArmor on Ubuntu
AppArmor confines programs with profiles stored in /etc/apparmor.d/. Instead of labels, a profile lists file paths a program may use, with permissions such as r (read), w (write) and k (lock). Profiles are either in enforce mode (blocked and logged) or complain mode (only logged).
sudo aa-status | head -n 4
sudo aa-status | grep -A1 'processes are in enforce'apparmor module is loaded. 121 profiles are loaded. 68 profiles are in enforce mode. /usr/bin/man 2 processes are in enforce mode. /usr/sbin/mysqld (1120)
The counts vary from system to system. Not every service has a profile. On Ubuntu, packages such as MySQL, chrony and tcpdump ship one, and Ubuntu 25.04 added many new application profiles, but many other services, including nginx by default, run unconfined.
The classic AppArmor problem is moving data to a new disk. Rohan moves the MySQL data folder to /srv/mysql, updates datadir in the MySQL config, and the database refuses to start. The kernel log shows why:
journalctl -k -g 'apparmor="DENIED"' -n 1Nov 02 10:20:11 shop-server kernel: audit: type=1400 audit(1793595011.004:88): apparmor="DENIED" operation="open" class="file" profile="/usr/sbin/mysqld" name="/srv/mysql/ibdata1" pid=1120 comm="mysqld" requested_mask="wr" denied_mask="wr" fsuid=113 ouid=113
The profile /usr/sbin/mysqld allows the standard /var/lib/mysql/ path but not /srv/mysql/. Do not edit the packaged profile: add local rules to its local include file, which package updates leave alone, then reload the profile:
printf '/srv/mysql/ r,\n/srv/mysql/** rwk,\n' | sudo tee -a /etc/apparmor.d/local/usr.sbin.mysqld
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.mysqld
sudo systemctl start mysqlThe first rule allows reading the folder itself, the second read, write and lock on everything below it. With the apparmor-utils package, sudo aa-complain /usr/sbin/mysqld puts one profile in complain mode while you investigate, sudo aa-enforce turns enforcement back on, and sudo aa-logprof reads the logged denials and offers to add matching rules.
Common mistakes
- Disabling SELinux or AppArmor to "fix" an error. You remove a whole layer of protection; fix the label, boolean or profile instead.
- Using
chconormvand expecting labels to stay.mvkeeps the old label from the source;restoreconwith anfcontextrule is the lasting fix. - Assuming permissions are the problem. If
ls -llooks right but access fails, checkausearch -m AVCor the AppArmor kernel log. - Editing packaged AppArmor profiles. Use
/etc/apparmor.d/local/so updates keep your rules. - Leaving a profile in complain mode after testing. It protects nothing.
Key points
- MAC limits what each program may do, even as root; DAC is ordinary owner/group permissions.
- SELinux (RHEL family) uses type labels; check with
getenforce,ls -Zandps -eZ. - Fix SELinux labels with
semanage fcontextplusrestorecon, and behaviours withsetsebool -P. - Find SELinux denials with
ausearch -m AVCand explain them withaudit2why. - AppArmor (Ubuntu) uses path-based profiles; check with
aa-status, add rules in/etc/apparmor.d/local/and reload withapparmor_parser -r.
Exercise
On a Rocky Linux server, Kabir deploys a site in /data/www and configures nginx to proxy /api/ to an app on 127.0.0.1:8000. The site returns 403 and the API returns 502, although file permissions are correct. Write the commands to confirm SELinux is responsible and to fix both problems permanently.
Show solution
sudo ausearch -m AVC -ts recent | audit2why
sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
sudo restorecon -Rv /data/www
sudo setsebool -P httpd_can_network_connect on
curl -sI http://localhost/ | head -n 1
curl -sI http://localhost/api/health | head -n 1HTTP/1.1 200 OK HTTP/1.1 200 OK
audit2why confirms two denials: reading files labelled default_t and a network connection from httpd_t. The fcontext rule plus restorecon give the content the web type, and the boolean allows nginx to connect to the backend. SELinux stays in enforcing mode the whole time.
Quick Quiz
1. Which part of an SELinux label matters most in the targeted policy?
Type enforcement rules such as "httpd_t may read httpd_sys_content_t" make most decisions.
2. Which pair of commands gives /srv/shop the web content label permanently?
semanage records the rule; restorecon applies it, and future relabels keep it.
3. Where do you add your own rules for a packaged AppArmor profile on Ubuntu?
Local include files survive package updates.
4. What does SELinux permissive mode do?
Permissive is for diagnosis; enforcing is for production.
Learn Linux with a mentor
Free lessons get you started. Our Ethical Hacking & Cyber Security training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
