SELinux and AppArmor Hardening Guide: Practical MAC for Servers and Containers

A hands-on guide to hardening Linux servers and containers with SELinux and AppArmor. Covers custom policy writing, container integration with Udica, Kubernetes enforcement, troubleshooting, and the shifting MAC landscape.

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

FeatureSELinuxAppArmor
Access ModelLabel/inode-basedPath-based profiles
Default OnRHEL, Fedora, CentOS, RockyUbuntu, Debian, openSUSE
Learning CurveSteepModerate
Container IsolationStrong (MCS separates containers)Host isolation only
Multi-Level SecurityYesNo
Symlink/Hardlink ResilienceYes (inode-based)No (path-based)
Policy LanguageTE/CIL with M4 macrosSimple 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 rules
  • myapp.fc — File context definitions
  • myapp.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 audit2allow output 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_policy blocks 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:

  1. MAC is enabled and enforcing on all production systems — never disabled, never left in permissive mode
  2. All custom applications have dedicated policy modules (SELinux) or profiles (AppArmor), not just the defaults
  3. Container workloads use custom MAC policies generated with Udica or handwritten AppArmor profiles — not just container_t or docker-default
  4. SELinux booleans are reviewed and only the minimum required booleans are enabled
  5. File contexts are correct — run restorecon -Rv after any file operations, and verify with ls -lZ
  6. Policy source files are stored in version control with clear documentation
  7. Denial logs are monitored — AVC denials and AppArmor DENIED messages are forwarded to your SIEM or IDS
  8. Kubernetes pods specify SELinux or AppArmor security contexts and don't run as privileged
  9. Regular audits: Periodically run sestatus / aa-status across your fleet to detect drift
  10. 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.

About the Author Editorial Team

Our team of expert writers and editors.