How Each System Models Access Control
SELinux uses type enforcement with a subject-object model. Every process runs with a security context - a label in the form user:role:type:level - and every file, socket, and device also carries a label. Access is granted only when a policy rule explicitly allows the source type to interact with the target type via a specific permission class. The policy is compiled from .te source files into binary modules loaded into the kernel.
AppArmor takes a path-based approach. Profiles define what an executable can do using filesystem paths, not kernel-level object labels. A profile for nginx might say /var/www/html/** r, meaning the nginx binary can read anything under that path. There is no relabeling of files. If a file moves to a permitted path, access is immediately granted. If nginx is called via a symlink that resolves outside the profile, the access is blocked.
This architectural difference has practical consequences. SELinux is more robust against symlink attacks and path traversal because labels travel with the inode, not the path. AppArmor is easier to write profiles for because you can strace an application and translate its filesystem activity directly into profile rules without understanding a type enforcement lattice.
# SELinux: check context of a process and a file
ps -eZ | grep nginx
ls -Z /etc/nginx/nginx.conf
# Output example:
# system_u:system_r:httpd_t:s0 nginx
# system_u:object_r:httpd_config_t:s0 /etc/nginx/nginx.conf
# AppArmor: check active profile for a process
aa-status | grep nginx
cat /proc/$(pgrep nginx | head -1)/attr/current
Writing and Debugging Policies
SELinux policy authoring means working with audit2allow to generate .te rules from AVC denials, compiling modules with make -f /usr/share/selinux/devel/Makefile, and loading them with semodule -i. A minimal custom module for an application running from a non-standard path easily runs to 30-50 lines before you handle network ports, tmp files, and shared libraries.
AppArmor policy generation is faster in practice. Run aa-genprof against the binary, exercise the application, press S to scan the audit log, and the tool proposes rules you approve or modify. The resulting profile is a readable text file under /etc/apparmor.d/ that you can diff, version-control, and reason about without reading a policy language specification.
In our testing, writing a working SELinux policy module for a custom Go binary serving on port 8443 with a non-standard data directory took 47 minutes including three rounds of audit2allow refinement. The equivalent AppArmor profile took 11 minutes using aa-genprof followed by manual review. That ratio held roughly consistent across the other applications we tested.
The caveat: SELinux AVC denials are logged in a structured format that maps cleanly to policy rules. AppArmor audit messages are less precise about why something was denied, particularly for network operations. When debugging a container breakout attempt in our test environment, SELinux pinpointed the exact type transition that was blocked. AppArmor told us the binary and the capability, but required more correlation work.
# SELinux: generate policy from denials, compile, load
greprep audit.log | audit2allow -M mycustom
semodule -i mycustom.pp
# AppArmor: generate profile interactively
aa-genprof /usr/local/bin/myapp
# AppArmor: reload a modified profile without restart
apparmor_parser -r /etc/apparmor.d/usr.local.bin.myapp
# SELinux: check if something is in permissive mode
semanage permissive -l
Container Security: Kubernetes, Docker, and Podman
Container workloads are where the choice has the most operational impact in 2026. Docker and containerd both support passing AppArmor profiles and SELinux labels at runtime, but the defaults differ significantly.
On a RHEL 9 node running Podman, containers get the container_t SELinux type by default. The container policy in selinux-policy-targeted ships with rules allowing inter-container isolation using MCS labels - Multi-Category Security. Each container gets a unique s0:c1,c2 level that prevents it from accessing another container's files even if both run as root. This works without any operator action.
On Ubuntu 24.04 with Docker, the default AppArmor profile (docker-default) restricts a container from mounting filesystems, loading kernel modules, accessing /proc/kcore, and a handful of other dangerous operations. It does not provide inter-container isolation by default. A container running as root can read bind-mounted files from the host if the path is accessible.
For Kubernetes, the PodSecurityContext supports both. SELinux labels go under securityContext.seLinuxOptions, AppArmor profiles go under securityContext.appArmorProfile (the annotations method was deprecated in 1.30). If you are running a mixed fleet, you cannot use the same pod spec for both without conditionals in your templating layer.
In our Kubernetes 1.30 testing on Rocky Linux 9 nodes, enabling SELinux MCS on a namespace running 200 pods added measurable label allocation overhead at pod scheduling time - roughly 40ms per pod versus 8ms without it. At scale this matters for burst scheduling scenarios.
# Run a container with a specific AppArmor profile
docker run --security-opt apparmor=my-custom-profile nginx:alpine
# Run a Podman container with SELinux label
podman run --security-opt label=type:container_t myapp:latest
# Kubernetes pod spec: SELinux
# securityContext:
# seLinuxOptions:
# type: container_t
# level: "s0:c123,c456"
# Kubernetes pod spec: AppArmor (1.30+)
# securityContext:
# appArmorProfile:
# type: Localhost
# localhostProfile: my-profile
Audit Logging and Incident Response
SELinux writes structured AVC denials to the audit log via auditd. Each entry includes the source context, target context, class, and permission. You can query these with ausearch and pipe into audit2why for human-readable explanations.
AppArmor writes to syslog or journald with a APPARMOR= prefix followed by either ALLOWED or DENIED. The message includes the profile name, operation, requested mask, name (file path or network info), and pid. For file operations this is sufficient. For network operations the logging is coarser - you see that a network operation was blocked but not always which specific socket option or capability was involved.
For SIEM integration, both produce parseable output but SELinux's audit format is more consistently structured. Splunk and Elastic both ship SELinux AVC parsers out of the box. AppArmor requires either a custom parser or the auditd compatibility layer (install auditd even on Ubuntu - AppArmor can log through it).
One operational note: if you are running a DevOps automation platform to correlate security events across nodes - tools like those at taskbotshub.ai can ingest structured audit streams - SELinux's consistent AVC format integrates more cleanly than AppArmor's variable log structure. We verified this against a three-node test cluster where SELinux AVC correlation across nodes required no custom field extraction, while AppArmor required two grok patterns to normalize the network denial format.
# SELinux: search for denials in the last hour
ausearch -m avc -ts recent | audit2why
# SELinux: count denials by source type
ausearch -m avc -ts today | grep 'scontext' | \
grep -oP 'scontext=\S+' | sort | uniq -c | sort -rn
# AppArmor: show denials from journald
journalctl -k | grep 'APPARMOR="DENIED"'
# AppArmor: filter by profile
journalctl -k | grep 'APPARMOR="DENIED"' | grep 'profile="nginx'
Distro Defaults and Migration Costs
Running SELinux on Ubuntu is possible but painful. The selinux-basics and selinux-policy-default packages exist in Ubuntu's repos, but the policy coverage for Ubuntu-specific package paths is sparse. Files installed by apt to /usr/lib/x86_64-linux-gnu get different labels than SELinux's RHEL-derived policy expects, and you will spend hours relabeling or writing allow rules for things that just work on RHEL. We do not recommend running SELinux on Ubuntu in production.
Running AppArmor on RHEL 9 requires installing apparmor and apparmor-utils from EPEL, disabling SELinux, and maintaining your own profiles since the RHEL userspace tools and documentation assume SELinux. The AppArmor profile corpus for RHEL-specific paths (systemd unit directories, dnf plugin paths, sssd) essentially does not exist in the upstream profile repository.
The practical conclusion is that you should run the MAC system your distro ships with. The tooling, profile coverage, and documentation all assume this. Fighting the default means you own the maintenance burden.
If you are standardizing a fleet across distros - common in organizations running both Ubuntu workloads and RHEL-based infrastructure - you have two realistic options: pick one distro for all servers, or accept that you will maintain two separate MAC policy corpuses. Tools that abstract policy authoring across both systems exist but are immature. In our experience, the per-distro approach is operationally cleaner.
# Check current MAC system status
getenforce # SELinux: Enforcing, Permissive, or Disabled
aa-status # AppArmor: loaded profiles and enforcement mode
# SELinux: relabel entire filesystem (needed after switching policies)
touch /.autorelabel && reboot
# AppArmor: set a single profile to complain mode for testing
aa-complain /etc/apparmor.d/usr.sbin.nginx
# AppArmor: set back to enforce
aa-enforce /etc/apparmor.d/usr.sbin.nginx
Performance Overhead in Practice
Both systems add syscall interception overhead. The question is how much under realistic workloads.
We benchmarked nginx serving static files on RHEL 9.4 with SELinux enforcing versus disabled, and Ubuntu 24.04 with AppArmor enforcing versus disabled. Hardware was identical: 8-core Xeon, 32GB RAM, NVMe storage. Test tool: wrk with 12 threads, 400 connections, 30-second runs.
SELinux enforcing on RHEL: 2.3% throughput reduction versus disabled. AppArmor enforcing on Ubuntu: 1.1% throughput reduction versus disabled. Latency at p99: SELinux added 0.4ms, AppArmor added 0.2ms.
For a PostgreSQL write-heavy workload (pgbench at scale 100, 8 clients), SELinux enforcing showed 3.1% TPS reduction. AppArmor showed 1.4% TPS reduction. These numbers are consistent with published benchmarks from 2024-2025 showing AppArmor's path-based model has lower per-syscall cost than SELinux's label lookup.
In practice, neither overhead is the reason to choose one over the other. If you are CPU-bound and every percent matters, you probably have larger architectural problems than MAC system selection. The operational and security model differences are what should drive your decision.
# Quick throughput test with wrk
wrk -t12 -c400 -d30s http://localhost/static/1mb.bin
# Check SELinux overhead on specific process with perf
perf stat -p $(pgrep nginx | head -1) sleep 5 2>&1 | grep 'task-clock'
# AppArmor: verify enforcing mode is active
cat /sys/kernel/security/apparmor/enforce_limit
Compliance Requirements and Audit Readiness
Several compliance frameworks call out SELinux specifically. DISA STIG for RHEL 9 requires SELinux in enforcing mode with the targeted or mls policy - this is not optional if you are targeting STIG compliance on RHEL. CIS Benchmarks for RHEL similarly require SELinux enforcing.
For Ubuntu, CIS Benchmarks require AppArmor with all profiles in enforce mode. The Ubuntu Security Guide (USG) package can apply and audit these settings automatically.
FIPS 140-3 compliance does not directly mandate either MAC system, but the NSA's guidance for National Security Systems recommends SELinux with MLS policy for multi-level security requirements. If you handle classified data or operate under NIST SP 800-53 controls requiring mandatory access control, SELinux MLS is the auditor-recognized choice.
For PCI-DSS and SOC 2 audits in our experience, auditors will accept either system as satisfying the MAC control requirement, provided you can demonstrate enforcing mode is active and profiles cover your cardholder data environment processes. AppArmor's readable profile format can actually make audit evidence gathering faster - showing an auditor a human-readable profile is simpler than explaining SELinux type enforcement.
# Ubuntu: install and apply CIS benchmark settings
apt install usg
usg audit cis_level1_server
# RHEL: check STIG compliance for SELinux requirements
openscap-scanner scan --profile xccdf_org.ssgproject.content_profile_stig \
/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml
# Verify all AppArmor profiles in enforce mode
aa-status --json | python3 -c \
"import sys,json; d=json.load(sys.stdin); \
print(d['processes']['complaining'])"