Introduction: Why Mandatory Access Control Is No Longer Optional
Traditional Linux file permissions — the familiar user-group-other model known as Discretionary Access Control (DAC) — were designed for a simpler era. And honestly, they did a decent job for a long time. But in today's threat landscape, where privilege escalation, container breakouts, and zero-day exploits are everyday concerns, relying on DAC alone is like locking your front door but leaving every window wide open. Mandatory Access Control (MAC) frameworks like SELinux and AppArmor provide that critical additional layer, enforcing security policies at the kernel level regardless of what a compromised process or user tries to do.
The numbers back this up pretty convincingly.
A 2024 CNCF report found a 3x decline in privilege escalation attempts when containerized applications used SELinux type enforcement. Red Hat separately reported a 60% reduction in privilege escalation incidents with SELinux versus permissive-mode deployments. And the Canonical Security Team's 2024 report indicated that kernel-based access control systems reduce exploitation risk by over 70%. Those aren't small margins — they're the difference between a contained incident and a full breach.
This guide walks you through both SELinux and AppArmor, hands-on. You'll learn how each system works architecturally, how to configure and harden them for traditional servers and containerized workloads, how to write custom policies, and how to troubleshoot the issues that'll inevitably come up — all with production-ready examples you can apply immediately.
Understanding the Architecture: How SELinux and AppArmor Differ
Both SELinux and AppArmor are implemented as Linux Security Modules (LSMs) — kernel-level hooks that intercept every access request before the kernel permits or denies the operation. Their approaches to defining security boundaries, however, differ fundamentally.
SELinux: Label-Based Security
SELinux, originally developed by the National Security Agency (NSA), applies security labels to every object on the system — files, processes, ports, devices, you name it. Each label consists of a user, role, type, and optional level (for MLS/MCS). Access decisions are made by comparing the label of the requesting process (the subject) against the label of the target resource (the object).
Here's what makes it particularly robust: because labels are attached to inodes rather than file paths, SELinux is immune to symlink and hardlink attacks that can bypass path-based systems. That's a meaningful advantage in adversarial environments.
SELinux supports three policy models:
- Type Enforcement (TE): The most commonly used model. Processes run in domains, and access to resources is controlled by rules specifying which domains can interact with which types.
- Role-Based Access Control (RBAC): Controls which domains a user can enter based on their assigned role.
- Multi-Level Security (MLS) / Multi-Category Security (MCS): Adds classification levels and categories for environments requiring data compartmentalization — think government or financial systems.
AppArmor: Path-Based Profiles
AppArmor takes a simpler approach by confining processes based on file path rules defined in human-readable profile files. Each profile specifies which files a process can read, write, or execute, which network operations it can perform, and which Linux capabilities it can use. Profiles live in /etc/apparmor.d/ and can be set to either enforce mode (violations are blocked and logged) or complain mode (violations are only logged).
AppArmor's path-based model is significantly easier to learn and manage. I've seen teams go from zero to functional profiles in an afternoon, which is something you really can't say about SELinux. The trade-off? It lacks some of SELinux's advanced features — notably MCS/MLS support and the ability to isolate containers from each other (as opposed to just isolating containers from the host).
Quick Comparison
| Feature | SELinux | AppArmor |
|---|---|---|
| Access Model | Label/inode-based | Path-based profiles |
| Default On | RHEL, Fedora, CentOS, Rocky | Ubuntu, Debian, openSUSE |
| Learning Curve | Steep | Moderate |
| Container Isolation | Strong (MCS separates containers) | Host isolation only |
| Multi-Level Security | Yes | No |
| Symlink/Hardlink Resilience | Yes (inode-based) | No (path-based) |
| Policy Language | TE/CIL with M4 macros | Simple text profiles |
SELinux: From Basics to Custom Policy Modules
Checking and Setting SELinux Mode
Let's start with the basics. Begin by verifying your current SELinux status. The getenforce command returns the current mode, while sestatus gives you the full picture:
# Check current SELinux mode
getenforce
# Detailed status including policy name and mode
sestatus
# Temporarily switch to permissive (for testing only — reverts on reboot)
sudo setenforce 0
# Switch back to enforcing
sudo setenforce 1
For persistent configuration, edit /etc/selinux/config:
# /etc/selinux/config
SELINUX=enforcing
SELINUXTYPE=targeted
Critical rule: Never disable SELinux in production. I know it's tempting when you hit a wall of AVC denials at 2 AM, but don't do it. If you encounter denials, use permissive mode temporarily to diagnose, then create targeted policy exceptions.
Understanding SELinux Contexts and Labels
Every file, process, and port on an SELinux system carries a security context. Use the -Z flag with common commands to inspect them:
# View file contexts
ls -lZ /var/www/html/
# Output: system_u:object_r:httpd_sys_content_t:s0 index.html
# View process contexts
ps auxZ | grep httpd
# Output: system_u:system_r:httpd_t:s0 ... /usr/sbin/httpd
# View port contexts
sudo semanage port -l | grep http
# Output: http_port_t tcp 80, 443, 488, 8008, 8009, 8443
In the context system_u:object_r:httpd_sys_content_t:s0, the four fields are:
- system_u — SELinux user
- object_r — Role
- httpd_sys_content_t — Type (this is the most important field for type enforcement)
- s0 — Sensitivity level (MLS/MCS)
Working with SELinux Booleans
Booleans are pre-built policy switches that control common access patterns without requiring you to write custom policy modules. They're surprisingly powerful, and more often than not, the fix you need is just a boolean toggle away:
# List all booleans related to httpd
sudo getsebool -a | grep httpd
# Allow Apache to make network connections (e.g., to a database)
sudo setsebool -P httpd_can_network_connect on
# Allow Apache to send mail
sudo setsebool -P httpd_can_sendmail on
# List all booleans with descriptions
sudo semanage boolean -l | grep httpd
The -P flag makes changes persistent across reboots. Always check available booleans before writing custom policy — there's a good chance the solution to your denial already exists.
Fixing File Context Issues
One of the most common SELinux headaches occurs when files are moved (not copied) to a new location, causing them to retain their original context. This trips up almost everyone at some point. Use restorecon to apply the correct context:
# Restore default context for a directory recursively
sudo restorecon -Rv /var/www/html/
# Add a custom file context rule for a non-standard web root
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/myapp(/.*)?"
sudo restorecon -Rv /srv/myapp/
# Verify the context was applied
ls -lZ /srv/myapp/
Writing a Custom SELinux Policy Module
When booleans and file context adjustments aren't enough, you'll need a custom policy module. This is where things get interesting (and admittedly, a bit tedious). Here's the complete workflow for creating one for a custom application:
Step 1: Generate the initial policy skeleton
# Install policy development tools
sudo dnf install policycoreutils-devel setools-console
# Generate a skeleton policy for a custom daemon
sudo sepolicy generate --init /usr/local/bin/myapp
This creates three files:
myapp.te— Type enforcement rulesmyapp.fc— File context definitionsmyapp.if— Interface definitions for other modules
Step 2: Analyze AVC denials and build allow rules
# Run your application in permissive mode and collect denials
sudo setenforce 0
sudo systemctl start myapp
# Review denial messages
sudo ausearch -m AVC,USER_AVC -ts recent
# Generate allow rules from denials
sudo ausearch -m AVC -ts recent | audit2allow -m myapp > myapp_custom.te
# IMPORTANT: Review the generated rules before compiling
cat myapp_custom.te
Step 3: Compile and install the policy module
# Compile the type enforcement file into a module
checkmodule -M -m -o myapp_custom.mod myapp_custom.te
# Package the module
semodule_package -o myapp_custom.pp -m myapp_custom.mod
# Install the module
sudo semodule -i myapp_custom.pp
# Return to enforcing mode
sudo setenforce 1
# Verify the module is loaded
sudo semodule -l | grep myapp
A few best practices for custom modules:
- Always review
audit2allowoutput carefully — it can generate overly permissive rules that defeat the purpose of MAC - Follow the principle of least privilege: only allow what's strictly necessary
- Store policy source files (
.te,.fc,.if) in version control - Test thoroughly in permissive mode before enforcing
- Use
optional_policyblocks when referencing interfaces from other modules
AppArmor: From Basics to Custom Profiles
Checking AppArmor Status
On Ubuntu and Debian systems, AppArmor is enabled by default. Let's verify its status:
# Check overall AppArmor status
sudo aa-status
# Example output shows:
# - Number of profiles loaded
# - Profiles in enforce mode
# - Profiles in complain mode
# - Processes with profiles
# Check if AppArmor kernel module is loaded
cat /sys/module/apparmor/parameters/enabled
# Output: Y
Understanding AppArmor Profile Structure
AppArmor profiles are plain text files in /etc/apparmor.d/. Here's the anatomy of a basic profile:
# /etc/apparmor.d/usr.local.bin.myapp
#include <tunables/global>
/usr/local/bin/myapp {
#include <abstractions/base>
#include <abstractions/nameservice>
# Allow reading configuration
/etc/myapp/** r,
# Allow reading and writing data directory
/var/lib/myapp/** rw,
# Allow writing logs
/var/log/myapp/** w,
# Allow writing PID file
/run/myapp.pid w,
# Network access
network inet stream,
network inet dgram,
# Deny access to sensitive locations
deny /etc/shadow r,
deny /etc/passwd w,
deny /home/** rwx,
}
Key permission flags: r (read), w (write), x (execute), m (memory map), k (lock), l (link). Straightforward and readable — that's AppArmor's biggest selling point.
Generating Profiles Automatically
AppArmor includes tools that can learn your application's behavior and generate profiles automatically. This is honestly one of my favorite features — it takes a lot of the guesswork out of profile creation:
# Install AppArmor utilities
sudo apt install apparmor-utils
# Generate a profile interactively by running the app and learning its behavior
sudo aa-genprof /usr/local/bin/myapp
# This puts the app in complain mode and monitors its access patterns
# Run your application through its typical workflows, then press S to scan logs
# Alternatively, put an existing profile into complain mode
sudo aa-complain /etc/apparmor.d/usr.local.bin.myapp
# After testing, switch to enforce mode
sudo aa-enforce /etc/apparmor.d/usr.local.bin.myapp
# Reload a specific profile after editing
sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.myapp
# Reload all profiles
sudo systemctl reload apparmor
Hardening an Nginx Profile
Here's a production-hardened AppArmor profile for Nginx. This is a solid starting point — you'll likely need to adjust paths for your specific setup, but the structure covers most common deployments:
# /etc/apparmor.d/usr.sbin.nginx
#include <tunables/global>
/usr/sbin/nginx {
#include <abstractions/base>
#include <abstractions/nameservice>
#include <abstractions/openssl>
# Binary and library access
/usr/sbin/nginx mr,
/usr/lib/** mr,
# Configuration files (read-only)
/etc/nginx/** r,
/etc/ssl/** r,
# Web content (read-only)
/var/www/** r,
/usr/share/nginx/** r,
# Logs (append-only where possible)
/var/log/nginx/** w,
# Runtime files
/run/nginx.pid w,
/var/lib/nginx/** rw,
# Temp files for request processing
/var/lib/nginx/tmp/** rw,
# Network capabilities
capability net_bind_service,
capability setuid,
capability setgid,
capability dac_override,
network inet stream,
network inet6 stream,
# Deny everything else explicitly
deny /home/** rwx,
deny /root/** rwx,
deny /etc/shadow r,
deny /proc/*/mem rw,
}
MAC for Containers: Hardening Docker and Podman
SELinux Container Security
On SELinux-enabled systems, container runtimes automatically apply the container_t type to container processes and container_file_t to container storage. This default policy is pretty restrictive — containers can only read or execute files in /usr and interact with files labeled container_file_t.
But here's where SELinux really shines for containers.
MCS (Multi-Category Security) labels provide inter-container isolation that AppArmor simply can't match. Each container receives a unique category pair (e.g., s0:c100,c200), preventing it from accessing files or processes belonging to other containers — even though they all share the container_t type. It's elegant and effective.
# Run a container with a custom SELinux type
podman run --security-opt label=type:container_t \
--security-opt label=level:s0:c100,c200 \
--name myapp myimage:latest
# Mount a host volume with proper SELinux labeling
# :z = shared label (accessible by multiple containers)
# :Z = private label (only this container can access)
podman run -v /data/myapp:/app/data:Z myimage:latest
# Inspect the SELinux context of a running container
podman inspect myapp | grep ProcessLabel
# Verify host volume labeling
ls -lZ /data/myapp/
Custom Container Policies with Udica
Udica is a tool specifically designed for generating SELinux policies tailored to individual containers. Instead of modifying the generic container_t policy (which would affect all containers — definitely not what you want), Udica inspects a container's JSON specification and creates a targeted policy based on its actual needs:
# Install udica
sudo dnf install udica
# Run your container first (so udica can inspect it)
podman run -d --name myapp -v /data/myapp:/app/data:Z \
-p 8080:8080 myimage:latest
# Generate the SELinux policy from the running container
podman inspect myapp | sudo udica myapp_container
# Install the generated policy
sudo semodule -i myapp_container.cil /usr/share/udica/templates/*.cil
# Restart the container with the custom policy
podman stop myapp && podman rm myapp
podman run -d --name myapp \
--security-opt label=type:myapp_container.process \
-v /data/myapp:/app/data:Z \
-p 8080:8080 myimage:latest
Udica uses CIL block inheritance to compose policies from templates, which makes it possible to create fine-grained container policies without deep SELinux expertise. It supports Podman, Docker, and CRI-O.
AppArmor Container Security
Docker applies a default AppArmor profile called docker-default to all containers. While this provides basic host protection, you should definitely create custom profiles for sensitive workloads:
# /etc/apparmor.d/docker-myapp
#include <tunables/global>
profile docker-myapp flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
# Application-specific access
/app/** r,
/app/bin/* ix,
/app/data/** rw,
/tmp/** rw,
# Network
network inet stream,
network inet6 stream,
# Deny sensitive host paths
deny /proc/*/mem rw,
deny /sys/firmware/** r,
deny /proc/sysrq-trigger rw,
deny /proc/kcore r,
# Deny mount operations
deny mount,
deny umount,
# Capability restrictions
capability net_bind_service,
capability chown,
capability setuid,
capability setgid,
deny capability sys_admin,
deny capability sys_rawio,
deny capability sys_ptrace,
}
# Load the custom profile
sudo apparmor_parser -r /etc/apparmor.d/docker-myapp
# Run a container with the custom profile
docker run --security-opt apparmor=docker-myapp \
--name myapp myimage:latest
# Verify the profile is applied
docker inspect myapp | grep AppArmorProfile
Docker Compose with MAC Enforcement
Here's a production Docker Compose configuration showing both SELinux and AppArmor hardening options. I've included both variants so you can grab whichever fits your environment:
# docker-compose.yml — SELinux variant (RHEL/Fedora)
services:
webapp:
image: myapp:latest
security_opt:
- label=type:myapp_container.process
- label=level:s0:c100,c200
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
volumes:
- app-data:/app/data:Z
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
database:
image: postgres:16
security_opt:
- label=type:container_t
- label=level:s0:c300,c400
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
- /run/postgresql
volumes:
- db-data:/var/lib/postgresql/data:Z
cap_drop:
- ALL
volumes:
app-data:
db-data:
# docker-compose.yml — AppArmor variant (Ubuntu/Debian)
services:
webapp:
image: myapp:latest
security_opt:
- apparmor=docker-myapp
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp
volumes:
- app-data:/app/data
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
volumes:
app-data:
MAC in Kubernetes: Pod-Level Enforcement
SELinux in Kubernetes
Kubernetes supports SELinux through the securityContext at both the pod and container level. So, let's look at a properly hardened pod spec:
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
securityContext:
seLinuxOptions:
type: myapp_container.process
level: "s0:c100,c200"
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myapp:latest
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
readOnlyRootFilesystem: true
volumeMounts:
- name: data
mountPath: /app/data
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data-pvc
For generating policies for Kubernetes pods, the Udica project provides a SELinux policy helper operator that watches for pods annotated with generate-selinux-policy and automatically creates tailored policies. It's a real time-saver if you're managing a lot of workloads.
AppArmor in Kubernetes
AppArmor profiles for Kubernetes pods are specified via annotations (or the securityContext in newer Kubernetes versions):
apiVersion: v1
kind: Pod
metadata:
name: secure-app
annotations:
container.apparmor.security.beta.kubernetes.io/app: localhost/docker-myapp
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myapp:latest
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
readOnlyRootFilesystem: true
One important caveat: the AppArmor profile must be loaded on every node where the pod might be scheduled. Use a DaemonSet or your configuration management tool to ensure consistent profile deployment across your cluster. Miss one node and you'll get scheduling failures that can be surprisingly hard to track down.
Troubleshooting MAC Denials
Let's be real — you will run into denials. It's part of the process. The good news is that both SELinux and AppArmor have solid troubleshooting workflows once you know where to look.
SELinux Troubleshooting Workflow
When SELinux blocks an operation, it generates an AVC (Access Vector Cache) denial message in the audit log. Here's the systematic approach I recommend:
# Step 1: Find recent AVC denials
sudo ausearch -m AVC -ts recent
# Step 2: Get human-readable analysis with sealert
sudo sealert -a /var/log/audit/audit.log
# Step 3: Check if a boolean can resolve the issue
sudo ausearch -m AVC -ts recent | audit2why
# Step 4: If a boolean exists, enable it
sudo setsebool -P httpd_can_network_connect on
# Step 5: If no boolean exists, generate a targeted module
sudo ausearch -m AVC -ts recent | audit2allow -M myfix
# REVIEW the generated .te file before installing
cat myfix.te
sudo semodule -i myfix.pp
# Step 6: Monitor for new denials after applying the fix
sudo ausearch -m AVC -ts recent --just-one
AppArmor Troubleshooting Workflow
# Step 1: Check kernel log for AppArmor denials
sudo dmesg | grep "apparmor.*DENIED"
# Or check the audit log
sudo journalctl -k | grep "apparmor.*DENIED"
# Step 2: Put the profile in complain mode to allow the operation
sudo aa-complain /etc/apparmor.d/usr.sbin.nginx
# Step 3: Reproduce the issue and collect logs
sudo aa-logprof
# This interactively suggests profile changes based on logged denials
# Step 4: Review and apply suggested changes
# Edit the profile manually or accept aa-logprof suggestions
# Step 5: Return to enforce mode
sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx
Combining MAC with Other Security Layers
MAC is most effective as part of a defense-in-depth strategy. It's not a silver bullet on its own, but it becomes incredibly powerful when layered with other controls. Here's how the pieces fit together:
- Seccomp + MAC: While MAC controls file and network access, Seccomp restricts which system calls a process can make. Together, they create a tight sandbox where even a compromised process can neither access unauthorized files nor invoke dangerous syscalls.
- Firewalls (nftables) + MAC: nftables controls network traffic at the packet level, while MAC controls which processes can use the network at all. An SELinux policy can prevent a compromised web server from initiating outbound connections, even if your firewall rules would otherwise allow them.
- Intrusion Detection (Auditd/Wazuh) + MAC: MAC denial logs feed directly into intrusion detection systems. A spike in AVC denials can indicate an active exploitation attempt, letting your IDS trigger alerts and automated responses.
- Container Runtime Security: Combine MAC profiles with capability dropping (
cap_drop: ALL), read-only root filesystems,no-new-privileges, and user namespaces for comprehensive container isolation.
Production Hardening Checklist
Use this checklist to audit your MAC configuration across your infrastructure. I'd recommend running through it quarterly at minimum:
- MAC is enabled and enforcing on all production systems — never disabled, never left in permissive mode
- All custom applications have dedicated policy modules (SELinux) or profiles (AppArmor), not just the defaults
- Container workloads use custom MAC policies generated with Udica or handwritten AppArmor profiles — not just
container_tordocker-default - SELinux booleans are reviewed and only the minimum required booleans are enabled
- File contexts are correct — run
restorecon -Rvafter any file operations, and verify withls -lZ - Policy source files are stored in version control with clear documentation
- Denial logs are monitored — AVC denials and AppArmor DENIED messages are forwarded to your SIEM or IDS
- Kubernetes pods specify SELinux or AppArmor security contexts and don't run as privileged
- Regular audits: Periodically run
sestatus/aa-statusacross your fleet to detect drift - Compliance mappings: Document how your MAC policies satisfy CIS Benchmarks, PCI-DSS, or SOC 2 requirements
Industry Trends: The Shifting MAC Landscape in 2025-2026
The MAC world isn't standing still. Several significant shifts are worth paying attention to:
- openSUSE's switch to SELinux: In February 2025, openSUSE Tumbleweed switched its default MAC from AppArmor to SELinux, with Leap 16 following suit. This is a big deal — it aligns SUSE's community distributions with broader enterprise demand for SELinux type enforcement.
- Azure's move to SELinux: Microsoft's Azure Kubernetes Service (AKS) dropped AppArmor support in Azure Linux 3.0, recommending SELinux for mandatory access control on AKS nodes. If you're on Azure, take note.
- AppArmor 4.0 in Ubuntu 24.04 LTS: Ubuntu's latest LTS introduced AppArmor 4.0, which adds support for specifying allowed network addresses and ports within security policy — a significant improvement over the previous coarse-grained network controls.
- Immutable OS architectures: Distributions like Fedora Silverblue, openSUSE MicroOS, and NixOS combine read-only root filesystems with MAC enforcement, creating systems where malware can't persist even with elevated privileges. This is arguably where the industry is headed.
Conclusion: Make MAC Your Security Foundation
Mandatory Access Control isn't an advanced topic reserved for government systems anymore. It's the baseline expectation for any security-conscious Linux deployment, whether you're running a single server or managing thousands of Kubernetes pods.
SELinux and AppArmor each have their strengths — SELinux for deep, label-based enforcement and container isolation; AppArmor for accessibility and rapid profile development. Pick the one your distro ships with, learn it well, and extend it to every workload.
The key principles remain constant regardless of which framework you choose: always enforce (never disable), follow the principle of least privilege, write custom policies for your applications, version-control your policy files, and integrate denial logs into your monitoring pipeline. Combined with the other hardening layers covered in this series — kernel sandboxing, firewall rules, intrusion detection, and container security — MAC provides the critical confinement layer that limits the blast radius of any compromise.
Start with your distribution's default framework, master it, and extend it to every workload you deploy. Your future self — and your incident response team — will thank you for it.