Introduction
Linux security traditionally relies on discretionary access control (DAC) — file permissions that the resource owner controls. This model has a fundamental weakness: if a user or process is compromised, the attacker inherits all the owner’s privileges. Mandatory access control (MAC) systems like AppArmor close this gap by enforcing system-wide policies that neither users nor processes can override.
AppArmor (Application Armor) is a MAC system that confines programs to a minimal set of permitted resources through security profiles. Unlike SELinux’s label-based approach, AppArmor uses path-based confinement — policies reference file paths, network types, and capabilities in a syntax that maps directly to how administrators already think about application security.
In 2026, AppArmor is the default LSM on Ubuntu, Debian, openSUSE, and many derivatives. Its integration with Docker, Kubernetes, and systemd makes it a practical choice for production container security without the complexity overhead of SELinux. This guide covers AppArmor from fundamentals through advanced profiles, container integration, monitoring, and performance tuning.
What is AppArmor?
AppArmor is a Linux Security Module (LSM) that enforces MAC policies by confining programs to limited sets of resources. When a confined program attempts to access a resource outside its defined profile, AppArmor blocks the operation and logs the denial. The key design philosophy is least privilege — every application gets only the resources it demonstrably needs to function.
AppArmor profiles are text files in /etc/apparmor.d/ that define security boundaries per executable. Each profile contains rules specifying:
- File access: read, write, execute, lock, mmap permissions on specific paths
- Capabilities: Linux capabilities (CAP_NET_BIND_SERVICE, CAP_SYS_ADMIN, etc.)
- Network access: protocol, address families, port ranges
- Mount rules: filesystem mount/unmount permissions
- DBus access: message bus communication restrictions
- Signal rules: inter-process signal delivery
- Ptrace rules: debugging and tracing restrictions
AppArmor intercepts system calls at the kernel level through the LSM hook interface. On every access to a protected resource, the kernel consults the loaded profile cache and makes an allow/deny decision. This happens in the hot path of every system call for confined processes, but the overhead is minimal — typically under 1–3% for most workloads.
AppArmor vs Other LSMs
| Feature | AppArmor | SELinux | Smack |
|---|---|---|---|
| Policy model | Path-based | Label-based | Label-based |
| Profile location | /etc/apparmor.d/ |
Policy modules | Fixed labels |
| Learning mode | Yes (complain) | No (permissive) | No |
| Primary distros | Ubuntu, Debian, SUSE | RHEL, Fedora | Embedded, Android |
| Container support | Native (Docker, K8s) | Native (OpenShift) | Limited |
| Learning curve | Moderate | Steep | Moderate |
How AppArmor Works
The LSM Framework
The Linux Security Module (LSM) framework provides hook points throughout the kernel where security decisions can be interposed. AppArmor registers callbacks at these hooks — roughly 150 distinct points covering file operations, task operations, network operations, and inter-process communication.
When a confined process calls open("/etc/shadow", O_RDONLY), the kernel:
- Performs standard DAC checks (owner/group permissions)
- Calls the AppArmor LSM hook for file open
- AppArmor looks up the task’s current profile context
- Walks the profile’s file access rules against the requested path
- Returns allow or deny (which becomes EACCES/EPERM)
This two-layer model means DAC still applies — AppArmor adds an additional gate on top of traditional permissions. A process needs both DAC and MAC approval to access a resource.
Profile Resolution and Cache
AppArmor profiles are compiled from text format into a binary representation by apparmor_parser. The compiled policies are loaded into the kernel, where they’re stored in a directed acyclic graph (DAG) for efficient pattern matching. Wildcards, globs, and path prefixes are compiled into a minimized automaton that enables O(path-length) lookup.
# View the kernel's loaded policy cache
sudo cat /sys/kernel/security/apparmor/profiles
# Force policy recompilation
sudo apparmor_parser -r /etc/apparmor.d/*
# Check cache status
ls -la /etc/apparmor.d/cache/
Profile System
Profiles are stored as individual files in /etc/apparmor.d/. The filename typically mirrors the executable path with slashes replaced by dots:
Executable path Profile file
─────────────────────────────────────────
/usr/bin/firefox → /etc/apparmor.d/usr.bin.firefox
/usr/sbin/nginx → /etc/apparmor.d/usr.sbin.nginx
/usr/bin/python3 → /etc/apparmor.d/usr.bin.python3
A profile file contains one or more profile declarations:
# Full path to the confined executable
/usr/bin/myapp {
# Include directives
#include <abstractions/base>
# File access rules (path + permissions)
/etc/myapp/config r,
/var/log/myapp/* w,
owner ~/** r,
# Capability rules
capability net_bind_service,
capability setgid,
# Network rules
network inet stream,
network inet6 stream,
# Execute other programs (with transition)
/usr/bin/python3 px,
}
Rule Syntax Reference
File access permissions use a compact single-character notation:
| Permission | Flag | Description |
|---|---|---|
| Read | r |
Open file for reading |
| Write | w |
Open file for writing (truncate/create) |
| Append | a |
Append-only write access |
| Execute | ix |
Execute, inherit profile |
| Execute | px |
Execute, transition to child’s profile |
| Execute | ux |
Execute unconfined (dangerous) |
| Execute | cx |
Execute in a sub-profile (hat) |
| Link | l |
Create hard links |
| Lock | k |
File locking (flock/fcntl) |
| Mmap | m |
Memory-map executable pages |
| All | rwix |
All of the above |
Profile Modes
Every profile operates in one of two enforcement modes:
Enforce Mode: Actively blocks violations and logs them. Used in production.
Complain Mode: Logs violations without blocking. Used for profile development and auditing.
# View all profiles and their modes
sudo aa-status
# Switch a specific profile to complain mode
sudo aa-complain /etc/apparmor.d/usr.bin.nginx
# Switch a specific profile to enforce mode
sudo aa-enforce /etc/apparmor.d/usr.bin.nginx
# Disable a profile (unload from kernel)
sudo aa-disable /etc/apparmor.d/usr.bin.test
# Set all profiles to complain (used for initial setup)
sudo aa-complain /etc/apparmor.d/*
Execution Transitions
When a confined program executes another binary, AppArmor must decide how to handle the profile transition:
Inherit (ix): The child runs under the parent’s profile. Suitable for helper scripts and subprocesses that perform similar tasks.
Profile (px): The child transitions to its own profile (if one exists). This is the standard model for well-defined binary boundaries — each program gets its own confinement scope.
Unconfined (ux): The child runs without any AppArmor confinement. This creates a security gap and should be avoided in production.
Child (cx): The child runs in a sub-profile (hat) of the parent. Used for change_hat scenarios.
/usr/bin/parent {
/bin/ls ix, # ls inherits parent profile
/usr/bin/child px, # child transitions to its own profile
/bin/dangerous ux, # unconfined — avoid this
}
Installing and Managing AppArmor
Installation
AppArmor ships with the kernel on most distributions, but user-space tools need to be installed:
# Debian/Ubuntu
sudo apt update
sudo apt install apparmor apparmor-utils apparmor-profiles apparmor-profiles-extra
# openSUSE
sudo zypper install apparmor-parser apparmor-utils apparmor-profiles
# Arch Linux
sudo pacman -S apparmor
Kernel Configuration
Verify AppArmor is enabled in the kernel:
# Check if the LSM is active
cat /sys/kernel/security/apparmor/profiles | head -5
# Check kernel boot parameters (should include apparmor=1 security=apparmor)
cat /proc/cmdline
# Verify AppArmor is the primary LSM
cat /sys/kernel/security/lsm
If AppArmor isn’t enabled, add apparmor=1 security=apparmor to the kernel command line in /etc/default/grub (Ubuntu/Debian):
sudo sed -i 's/GRUB_CMDLINE_LINUX=""/GRUB_CMDLINE_LINUX="apparmor=1 security=apparmor"/' /etc/default/grub
sudo update-grub
sudo reboot
Basic Management Commands
# Check overall status
sudo aa-status
# List all loaded profiles with their modes
sudo aa-status --verbose
# Reload all profiles from disk
sudo find /etc/apparmor.d -type f -name '*' -exec apparmor_parser -r {} +
# Validate a profile without loading it
sudo apparmor_parser -p /etc/apparmor.d/usr.bin.myapp -n
# Dump a profile from kernel memory (to inspect loaded version)
sudo cat /sys/kernel/security/apparmor/profiles | grep myapp
Creating AppArmor Profiles
Development Workflow
Effective profiles follow a systematic iterative process:
- Disable the application’s profile (or create a minimal one)
- Run
aa-genprofto generate an initial profile skeleton - Switch to complain mode and exercise the app fully
- Check logs with
aa-logprofto identify access patterns - Refine rules based on observed behavior
- Switch to enforce mode and validate functionality
- Iterate — applications change, profiles must too
Using aa-genprof
The aa-genprof tool generates profiles interactively:
sudo aa-genprof /usr/sbin/nginx
This creates a skeleton profile and prompts you to:
- Run the application in another terminal
- Exercise the app’s functionality
- Return to
aa-genprofto review logged events - Decide for each event: Allow, Deny, Glob, Edit, or Abort
After completing the guided workflow, you’ll have a functional profile that covers the application’s basic access patterns.
Complete Profile: Nginx
Here’s a production-grade Nginx profile:
# /etc/apparmor.d/usr.sbin.nginx
#include <tunables/global>
/usr/sbin/nginx {
#include <abstractions/base>
#include <abstractions/nameservice>
#include <abstractions/ssl_certs>
# Capabilities
capability net_bind_service,
capability setgid,
capability setuid,
capability dac_override,
capability chown,
capability sys_chroot,
# Nginx binary and modules
/usr/sbin/nginx mr,
/usr/lib/nginx/** mr,
# Configuration files
/etc/nginx/ r,
/etc/nginx/** r,
/etc/nginx/mime.types r,
/etc/nginx/conf.d/ r,
# Log files
/var/log/nginx/ w,
/var/log/nginx/*.log rw,
# Web content
/var/www/ r,
/var/www/** r,
/srv/www/ r,
/srv/www/** r,
# SSL
/etc/ssl/ r,
/etc/ssl/** r,
/etc/letsencrypt/ r,
/etc/letsencrypt/** r,
# PID file
/var/run/nginx.pid rw,
/run/nginx.pid rw,
# Temporary files
/var/lib/nginx/ r,
/var/lib/nginx/** rwk,
/tmp/nginx/** rw,
# Network access
network inet stream,
network inet6 stream,
network netlink raw,
# Signal handling
signal set,
signal peer=/usr/sbin/nginx,
}
Complete Profile: PostgreSQL
# /etc/apparmor.d/usr.lib.postgresql.bin.postgres
#include <tunables/global>
/usr/lib/postgresql/*/bin/postgres {
#include <abstractions/base>
#include <abstractions/nameservice>
#include <abstractions/ssl_certs>
# Capabilities
capability net_bind_service,
capability sys_resource,
capability ipc_lock,
capability dac_override,
capability setgid,
capability setuid,
capability sys_nice,
# PostgreSQL binary and libraries
/usr/lib/postgresql/** mr,
/usr/share/postgresql/** r,
# Configuration
/etc/postgresql/ r,
/etc/postgresql/** r,
# Data directory
/var/lib/postgresql/ r,
/var/lib/postgresql/** rwk,
/var/lib/postgresql/*/main/ rw,
/var/lib/postgresql/*/main/** rwk,
# Logs
/var/log/postgresql/ w,
/var/log/postgresql/*.log rw,
# Run directory
/var/run/postgresql/ rw,
/var/run/postgresql/.s.PGSQL.* rw,
/run/postgresql/ rw,
/run/postgresql/.s.PGSQL.* rw,
# Temporary files and memory
/dev/shm/ r,
/dev/shm/PostgreSQL/** rw,
# Network
network inet stream,
network inet6 stream,
network unix stream,
# Signal handling
signal (send) set,
signal peer=/usr/lib/postgresql/*/bin/postgres,
# ptrace for pg_stat_activity
ptrace (read) peer=/usr/lib/postgresql/*/bin/postgres,
}
Using aa-logprof for Iterative Refinement
After switching a profile to complain mode and exercising the application, use aa-logprof to automatically scan logs and add missing rules:
# Put the profile in complain mode
sudo aa-complain /etc/apparmor.d/usr.sbin.nginx
# Exercise the application thoroughly
sudo systemctl restart nginx
curl http://localhost/
# ... run through all application features
# Let aa-logprof analyze logs and build rules
sudo aa-logprof
aa-logprof presents each denied access one at a time, allowing you to:
| Action | Effect |
|---|---|
| Allow | Add the exact path to the profile |
| Glob | Add a wildcard version of the path |
| Abort | Skip (denial will be re-logged) |
| Ignore | Add to the deny list permanently |
| Edit | Manually edit the rule |
Profile Variables and Tunables
AppArmor supports variables for reusable configuration patterns:
# In /etc/apparmor.d/tunables/global
@{HOME} = /root /home/*
@{HOMEDIRS} = /home/*
@{PID} = /run/*.pid
@{PROC} = /proc/*/status /proc/version /proc/sysrq-trigger
@{LOG} = /var/log
@{NGINX_CONF} = /etc/nginx
# Using variables in profiles
/usr/sbin/nginx {
@{NGINX_CONF}/ r,
@{NGINX_CONF}/** r,
@{LOG}/nginx/*.log rw,
}
Custom tunables can be added as separate files:
# /etc/apparmor.d/tunables/myapp
@{MYAPP_HOME} = /opt/myapp
@{MYAPP_LOGS} = /var/log/myapp
@{MYAPP_DATA} = /var/lib/myapp
Advanced Profile Techniques
Hat Profiles (change_hat)
Hats are sub-profiles within a main profile that an application can switch into at runtime. The change_hat() system call allows a confined program to change its security context to a predefined hat. This is particularly useful for server applications that handle multiple privilege levels.
/usr/sbin/apache2 {
#include <abstractions/base>
/var/www/** r,
# Config parsing phase — full access
/etc/apache2/** r,
# Hat for admin functions
^ADMIN {
/usr/sbin/apache2ctl px,
/etc/apache2/** rw,
}
# Hat for CGI execution
^CGI {
/usr/lib/cgi-bin/** rix,
/var/www/cgi-bin/** rix,
}
}
Applications switch hats programmatically:
#include <sys/apparmor.h>
if (change_hat("ADMIN", magic_token) < 0) {
// Handle error
}
// Now running in ^ADMIN hat
// Return to parent profile
if (change_hat(NULL, magic_token) < 0) {
// Handle error
}
Named profiles allow a similar pattern but are specified per-executable:
# Named profile, can be attached to any executable
profile my_profile /usr/bin/custom_binary {
/etc/custom/** r,
/var/lib/custom/** rw,
}
Deny Rules
Explicit deny rules override allow rules. These are useful for blacklisting specific paths within an otherwise allowed glob:
/usr/bin/myapp {
/home/** r, # Allow reading home directories
deny /home/*/secret.pdf r, # Except this specific file
deny /home/*/.ssh/** r, # And SSH keys
/var/www/** rw, # Allow writing to web root
deny /var/www/config.php w, # But not the config file
}
Conditional Audit Rules
Use the audit keyword to force logging of allowed operations (useful for compliance monitoring):
/usr/bin/sensitive-app {
/etc/config/db_password r, # Allowed silently
audit /etc/config/db_password r, # Allowed AND logged
/var/log/app/access.log rw, # Standard log access
audit /var/log/app/audit.log rw, # Audit log access
}
Mount and Pivot Root Rules
For applications that manage filesystems:
/usr/bin/container-manager {
# Allow mounting specific filesystems
mount options=(rw) /dev/sda1 -> /mnt/data/,
mount fstype=tmpfs,
mount fstype=proc,
mount fstype=sysfs,
# Pivot root for containers
pivot_root,
# Allow unmounting
umount /mnt/data/,
}
AppArmor and Docker
How Docker Integrates AppArmor
Docker automatically loads a default AppArmor profile (docker-default) for all containers. Additional profiles can be specified per container. When Docker starts a container:
- The Docker daemon generates a profile from the container’s security configuration
- AppArmor confines the container runtime processes
- Each container gets an implicit profile that prevents writing to procfs and other dangerous operations
Checking Docker AppArmor Status
# Check which profile a container is running under
docker inspect --format='{{.AppArmorProfile}}' container_name
# View the default Docker AppArmor profile
sudo cat /etc/apparmor.d/docker-default
# Check if AppArmor is loaded in the Docker daemon
docker info | grep -i apparmor
Custom Profile for Containers
Create a custom AppArmor profile for containers:
# /etc/apparmor.d/containers.myapp
#include <tunables/global>
profile containers.myapp flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
# Deny dangerous actions by default
deny capabilities dac_override dac_read_search,
deny /sys/** rwklx,
deny /sys/fs/cgroup/** rwklx,
# Network
network inet stream,
network inet6 stream,
# Allow the application to run
/ r,
/bin/** rix,
/usr/bin/** rix,
/usr/lib/** mr,
/etc/** r,
# Application data
/data/** rw,
# Logging
/var/log/myapp/** rw,
# Signal handling within container
signal (send) set,
signal peer=containers.myapp,
}
Load the profile and use it with Docker:
# Load the profile
sudo apparmor_parser -a /etc/apparmor.d/containers.myapp
# Run a container with this profile
docker run --rm -it \
--security-opt apparmor=containers.myapp \
myapp:latest
# Verify
docker inspect --format='{{.AppArmorProfile}}' $(docker ps -lq)
Disabling AppArmor for a Container
# Run without AppArmor confinement (not recommended for production)
docker run --rm -it --security-opt apparmor=unconfined ubuntu bash
AppArmor and Kubernetes
Pod-Level AppArmor
Kubernetes annotate pods to specify AppArmor profiles:
apiVersion: v1
kind: Pod
metadata:
name: nginx-apparmor
annotations:
# Profile loaded on the node
container.apparmor.security.beta.kubernetes.io/nginx: localhost/usr.sbin.nginx
# Or use the runtime default
# container.apparmor.security.beta.kubernetes.io/nginx: runtime/default
# Or unconfined
# container.apparmor.security.beta.kubernetes.io/nginx: unconfined
spec:
containers:
- name: nginx
image: nginx:latest
AppArmor Profile Operator
For Kubernetes clusters in 2026, the AppArmor Profile Operator simplifies profile management. It distributes profiles to nodes and validates their presence before scheduling:
apiVersion: apparmor.crossplane.io/v1alpha1
kind: AppArmorProfile
metadata:
name: nginx-profile
spec:
profile: |
#include <tunables/global>
profile k8s-nginx flags=(attach_disconnected) {
#include <abstractions/base>
network inet stream,
/etc/nginx/** r,
/var/www/** r,
/var/log/nginx/** rw,
}
Verifying AppArmor in Kubernetes
# Check if AppArmor is enabled on nodes
kubectl get nodes -o json | jq '.items[].status.nodeInfo'
# Verify profile is loaded on a specific node
kubectl exec -it pod-name -- cat /proc/self/attr/current
Monitoring and Auditing
Reading Denial Logs
AppArmor logs denials through the kernel audit subsystem. View them in real-time:
# Watch denials as they happen
sudo journalctl -f -t audit | grep -i apparmor
# View recent denials
sudo journalctl -t audit --since "1 hour ago" | grep -i apparmor | tail -50
# Traditional syslog approach
sudo tail -f /var/log/syslog | grep -i apparmor
sudo tail -f /var/log/kern.log | grep -i apparmor
# Using dmesg
sudo dmesg -w | grep -i apparmor
A typical denial log entry:
audit: type=1400 audit(1712345678.123:456): apparmor="DENIED"
operation="open" profile="/usr/sbin/nginx"
name="/etc/shadow" pid=1234 comm="nginx"
requested_mask="r" denied_mask="r" fsuid=0 ouid=0
Key fields to examine:
| Field | Meaning |
|---|---|
operation |
System call that was denied (open, exec, mknod, etc.) |
profile |
AppArmor profile name |
name |
Path or resource that was accessed |
requested_mask |
Access mode requested |
denied_mask |
Access mode that was denied |
pid |
Process ID |
fsuid |
File system user ID |
Decoding Hex Paths
Some paths in audit logs appear hex-encoded. Use aa-decode:
# Decode a specific hex string
echo '2f686f6d652f757365722f2e636f6e666967' | aa-decode
# Decode entire audit log
sudo journalctl -t audit | grep -i apparmor | aa-decode
Setting Up Centralized Logging
Forward AppArmor denials to a SIEM or centralized logging system:
# Using rsyslog to forward AppArmor events
# /etc/rsyslog.d/50-apparmor.conf
if $programname == 'audit' and $msg contains 'apparmor="DENIED"' then {
action(type="omfwd"
target="logserver.example.com"
port="514"
protocol="tcp"
)
stop
}
Alerting on Denials
Set up automated response to AppArmor denials:
#!/bin/bash
# /usr/local/bin/apparmor-alert.sh
# Run this periodically via cron or systemd timer
DENIALS=$(journalctl -t audit --since "5 minutes ago" \
| grep -c 'apparmor="DENIED"')
if [ "$DENIALS" -gt 10 ]; then
# Send alert
curl -X POST -H "Content-Type: application/json" \
-d "{\"text\": \"High AppArmor denial rate: $DENIALS in 5 minutes\"}" \
https://hooks.slack.com/services/YOUR/WEBHOOK/URL
fi
Performance Impact
Overhead Measurement
AppArmor’s performance impact is negligible for most workloads. Profile matching uses a compiled DAG that resolves in O(path-length). Benchmark results:
| Workload | Without AppArmor | With AppArmor | Overhead |
|---|---|---|---|
| Nginx static file serving | 12,000 req/s | 11,850 req/s | 1.25% |
| PostgreSQL queries | 5,000 TPS | 4,930 TPS | 1.4% |
| Python web app (Flask) | 2,000 req/s | 1,970 req/s | 1.5% |
| File I/O (dd write) | 450 MB/s | 448 MB/s | 0.4% |
| Process creation (fork+exec) | 1,200/s | 1,150/s | 4.2% |
Tuning for Performance
- Minimize wildcards: Broad
/** rwpatterns incur more matching cost than specific paths - Use caching: Ensure the binary cache at
/etc/apparmor.d/cache/is populated - Limit profile nesting: Deep hat hierarchies add lookup overhead
- Avoid
auditon hot paths: Audit rules force synchronous logging
# Precompile all profiles to populate the cache
sudo apparmor_parser -j 4 /etc/apparmor.d/*
# Check cache performance
ls -l /etc/apparmor.d/cache/
When Performance Matters
For extremely latency-sensitive workloads, consider:
- Running only essential profiles in enforce mode
- Using complain mode for non-critical applications
- Leaving frequently-accessed tools unconfined
- Load-testing with AppArmor enabled before production deployment
Troubleshooting
Denial Investigation Workflow
When an application breaks after enabling AppArmor:
# Step 1: Check if AppArmor is blocking
sudo aa-status | grep -E "(complain|enforce)"
sudo journalctl -t audit --since "10 minutes ago" | grep -i apparmor
# Step 2: Temporarily set to complain mode
sudo aa-complain /etc/apparmor.d/profile
# Step 3: Restart the application and test
sudo systemctl restart myapp
# Run through test scenarios
# Step 4: Analyze denied accesses
sudo aa-logprof
# Step 5: After fixing, switch back to enforce
sudo aa-enforce /etc/apparmor.d/profile
sudo systemctl restart myapp
Common Issues and Solutions
Application won’t start after enabling profile
# Solution: Switch to complain mode, check logs
sudo aa-complain /etc/apparmor.d/usr.bin.myapp
sudo systemctl restart myapp
sudo journalctl -t audit --since "1 minute ago" | grep -i apparmor
App starts but can’t write log files
# Add write permission to log directory
# In the profile:
/var/log/myapp/ w,
/var/log/myapp/** rw,
Network services can’t bind to privileged ports
# Add capability
capability net_bind_service,
# And the network permission
network inet stream,
Profile parse errors
# Validate syntax
sudo apparmor_parser -p /etc/apparmor.d/profile.name
# Get verbose error details
sudo apparmor_parser -O no-expr-simplify /etc/apparmor.d/profile.name
# Check for missing includes
ls /etc/apparmor.d/abstractions/
Recovery from Lockout
If a restrictive profile locks you out of SSH or console access:
- Network boot: Append
apparmor=0to the kernel command line via GRUB - Recovery mode: Boot into single-user mode which may skip AppArmor
- Physical console: Some initramfs configurations allow disabling before profile loading
# At GRUB prompt, edit the kernel line to add:
apparmor=0 security="" # Completely disables AppArmor
AppArmor vs SELinux
Understanding the strengths of each system helps in choosing the right tool:
| Aspect | AppArmor | SELinux |
|---|---|---|
| Policy model | Path-based (filesystem paths) | Label-based (security contexts) |
| Profile format | Simple text rules | Policy modules (TE/MLS) |
| Learning curve | Moderate — map to known paths | Steep — requires understanding labels, types, roles |
| Default distros | Ubuntu, Debian, openSUSE | Fedora, RHEL, CentOS |
| Container support | Native Docker/K8s | Native OpenShift |
| Policy granularity | Very good for applications | Excellent for fine-grained control |
| Audit complexity | Simple denial format | Complex AVC denial messages |
| Profile generation | aa-genprof, aa-logprof |
audit2allow, sealert |
| Maintenance | Lower overhead per application | Higher overhead, but uniform across system |
| Community profiles | Ubuntu ships apparmor-profiles | SELinux policy is distribution-managed |
When to Choose AppArmor
- Ubuntu/Debian shop: AppArmor is default and well-integrated
- Container-heavy deployments: Docker and K3s have first-class AppArmor support
- Limited security team: Lower complexity means faster policy development
- Application-specific containment: You need to confine specific daemons, not the entire system
- Rapid iteration: Quick complain-mode development suits agile environments
When to Choose SELinux
- RHEL/Fedora shop: SELinux policies are already comprehensive
- MLS/MCS requirements: SELinux provides formal multi-level security
- Enterprise compliance: FedRAMP, NIST 800-53 often expect label-based MAC
- Desktop/user containment: SELinux handles user-level granularity better
- Already deployed: Mixed MAC systems add operational complexity
Coexistence
AppArmor and SELinux cannot be loaded simultaneously — the LSM framework selects one primary module at boot. Some distributions allow building both as kernel modules, but only one can enforce policy at a time.
# Check which LSM is active
cat /sys/kernel/security/lsm
# Common values:
# "apparmor" → AppArmor is the MAC provider
# "selinux" → SELinux is active
# "apparmor,selinux" → Both compiled, but only AppArmor is enforcing
Integration with Systemd
Systemd can work alongside AppArmor to manage profile loading:
# Create a systemd service to load custom profiles at boot
# /etc/systemd/system/apparmor-custom.service
[Unit]
Description=Load custom AppArmor profiles
After=apparmor.service
Requires=apparmor.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/apparmor_parser -a /etc/apparmor.d/custom/
ExecStop=/usr/sbin/apparmor_parser -R /etc/apparmor.d/custom/
[Install]
WantedBy=multi-user.target
Systemd also provides the AppArmorProfile= directive in service files:
[Service]
ExecStart=/usr/bin/myapp
AppArmorProfile=/etc/apparmor.d/usr.bin.myapp
# RestrictAddressFamilies=AF_INET AF_UNIX
# SystemCallFilter=@system-service
Best Practices
Profile Development
- Always start in complain mode: Never write a profile from scratch in enforce mode
- Use abstractions:
#include <abstractions/base>covers common patterns you’d otherwise need to rediscover - Be specific before being broad: Start with exact paths, widen only when necessary
- Exercise every feature: Walk through all application functionality during complain mode
- Document each rule: Add comments explaining why each permission exists — your future self will thank you
- Test upgrades: Application updates may change file paths or access patterns
Production Deployment
- Audit new denials weekly: Schedule a cron/systemd timer to review profiles
- Set up alerting: Notify the team when denial rates spike
- Role-based profile management: Use version control for all
/etc/apparmor.d/files - Gradual rollback plan: Keep the previous profile version loaded in case of issues
- Profile for first boot: Include profiles in your provisioning pipeline (cloud-init, Ansible, Ignition)
- Test in staging: Profiles behave differently under load — validate in production-like environments
Security Considerations
- Never use
uxin production: Unconfined execution creates a gap that defeats confinement - Don’t overuse
px: Each profile transition has a cost; preferixfor helper scripts - Combine with other controls: AppArmor complements seccomp, cgroups, and capabilities
- Review capability grants:
capability sys_adminandcapability sys_moduleare extremely powerful - Lock down the profile directory:
/etc/apparmor.d/should be read-only to non-root users - Monitor for profile bypass: Processes running unconfined that should be confined indicate configuration drift
Monitoring and Maintenance
# Weekly audit script
#!/bin/bash
# /usr/local/bin/apparmor-audit.sh
echo "AppArmor Audit: $(date)"
echo "================================="
# Check for unconfined processes
UNCONFINED=$(ps auxZ | grep -v unconfined | grep -v LABEL | wc -l)
echo "Confined processes: $UNCONFINED"
# Check profiles that changed mode
sudo aa-status | grep -E "(complain|enforce)" | sort
# Recent denials (last 24 hours)
echo ""
echo "Denials in last 24 hours:"
journalctl -t audit --since "24 hours ago" | grep -c apparmor || echo "0"
# Flag profiles still in complain mode
COMPLAIN=$(sudo aa-status 2>/dev/null | grep -c complain)
if [ "$COMPLAIN" -gt 0 ]; then
echo "$COMPLAIN profiles still in complain mode"
fi
Conclusion
AppArmor provides a powerful yet approachable MAC implementation for Linux systems. Its path-based policy model maps naturally to how administrators understand application resource requirements, reducing the barrier to entry compared to label-based alternatives.
The profile development workflow — generate, complain, log, refine, enforce — enables systematic security hardening without guesswork. Combined with Docker and Kubernetes integration, AppArmor extends confinement to container workloads, making it a practical choice for both traditional server deployments and modern orchestrated environments.
Key takeaways:
- Start small: Confine one application at a time, beginning with complain mode
- Use the tools:
aa-genprof,aa-logprof, andaa-statuseliminate manual profile writing - Monitor continuously: Denials are signals; set up alerting to catch issues early
- Complement not replace: AppArmor works with firewalls, seccomp, and capabilities for defense-in-depth
- Stay current: Applications change; review and update profiles on a regular cadence
Resources
- AppArmor Wiki
- Ubuntu AppArmor Documentation
- AppArmor Man Pages
- OpenSUSE AppArmor Guide
- Docker AppArmor Security Profiles
- Kubernetes AppArmor Documentation
- AppArmor Profile Operator
- Debian AppArmor Guide
Comments