AppArmor ("Application Armor") is a Linux kernel Security Module (LSM) that enforces Mandatory Access Control (MAC) by confining programs to a defined set of resources. It is active by default on Ubuntu (the OS used inside Lima guests on macOS in this repo, and inside WSL 2 distros on Windows) and is the primary host-side security layer that Docker, containerd, and Kubernetes rely on to harden containers beyond what namespaces and cgroups alone provide.
- 0. Where AppArmor fits in the stack
- 1. Core concepts
- 2. AppArmor and Docker
- 3. AppArmor and Kubernetes
- 4. Writing a custom profile
- 5. Operational commands
- 6. AppArmor vs. seccomp — complementary, not redundant
- 7. Interaction with Lima guests
- 8. Threats and limitations of relying on AppArmor
- See also
flowchart TB
subgraph guest["Lima guest (macOS) · WSL 2 distro (Windows) — Ubuntu, where AppArmor lives"]
subgraph kernel["Linux kernel — LSM hook intercepts every syscall touching a labeled resource (file, socket, capability, …) → allows / denies / logs the access"]
subgraph runtime["Container runtime (runc / crun) — applies seccomp filter + AppArmor profile at exec()"]
pod["Container / Pod — process (nginx, sidecar, …)"]
end
end
end
AppArmor operates inside the Linux guest — the Lima VM on macOS, or the WSL 2 distro on Windows — not on the macOS or Windows host itself. On macOS the host has no AppArmor (it uses its own TCC / SIP / mandatory sandbox frameworks); on Windows the host uses its own security model. Everything below assumes you are working inside a Lima guest, a WSL 2 distro, or a Linux host directly.
An AppArmor profile is a text file that describes what a single program binary is
allowed to do. Profiles are stored in /etc/apparmor.d/ and identified by the absolute
path of the confined executable (e.g. /usr/sbin/nginx).
## on LiMa guest
prayagupa@lima-lima-qemu-dockerd:/Users/prayagupa$ ls -l /etc/apparmor.d/
total 472
-rw-r--r-- 1 root root 354 Aug 15 2025 1password
-rw-r--r-- 1 root root 352 Aug 15 2025 Discord
-rw-r--r-- 1 root root 386 Aug 15 2025 MongoDB_Compass
-rw-r--r-- 1 root root 404 Aug 15 2025 QtWebEngineProcess
drwxr-xr-x 2 root root 4096 Mar 21 09:14 abi
drwxr-xr-x 4 root root 4096 Mar 21 09:14 abstractions
-rw-r--r-- 1 root root 374 Aug 15 2025 balena-etcher
-rw-r--r-- 1 root root 348 Aug 15 2025 brave
-rw-r--r-- 1 root root 342 Aug 15 2025 buildah
-rw-r--r-- 1 root root 342 Aug 15 2025 busybox
.
.
.A profile specifies:
| Resource type | Example rule |
|---|---|
| File read/write/exec | /var/log/nginx/** rw, |
| Networking | network inet stream, |
| Linux capabilities | capability net_bind_service, |
| Signal sending | signal send set=(term) peer=/usr/bin/nginx, |
| Mount operations | deny mount, |
| Unix sockets | unix (create connect) type=stream, |
| Mode | Kernel behavior | Use when |
|---|---|---|
| enforce | Denies and logs any access not explicitly allowed | Production — default for Docker's built-in profile |
| complain | Logs violations but allows them | Auditing a new application before writing a tight profile |
| disabled | Profile loaded but not active | Emergency bypass; avoid in production |
| unconfined | No profile loaded at all | Never intentional — indicates the profile was never applied |
Check the mode of any profile:
aa-status # summary of all loaded profiles and their modes
# apparmor module is loaded.
# apparmor filesystem is not mounted.
cat /sys/kernel/security/apparmor/profiles # kernel-level viewUnlike SELinux (which labels every file inode), AppArmor is path-based: the kernel
matches the pathname of the file being accessed against the rules in the profile. This
makes profiles easier to write but means that hard links, bind mounts, and
/proc/*/fd/ tricks can sometimes route around a rule — a relevant consideration when
reviewing container escape CVEs.
Docker ships a built-in profile named docker-default. It is loaded automatically into
the kernel when the Docker daemon starts and is applied to every container that does not
specify a custom profile.
- Denies raw network access (
CAP_NET_RAWblocked for most containers). - Blocks access to
/proc/sysrq-trigger,/proc/kcore,/proc/kmem, and similar sensitive kernel interfaces. - Denies
mountsyscalls inside the container. - Restricts writes to
/sys/**(read-only by default). - Allows all file access inside the container's overlayfs layer (the profile is deliberately coarse at the file level — seccomp handles syscall filtering).
View the profile:
# inside the Lima guest
cat /etc/apparmor.d/docker # or the generated path under /var/lib/docker/# Run with no AppArmor confinement (privileged debugging only)
docker run --security-opt apparmor=unconfined …
# Run with a custom profile already loaded into the kernel
docker run --security-opt apparmor=my-custom-profile …Design rule: Never ship
apparmor=unconfinedin a production manifest. If a container legitimately needs extra capabilities (e.g.NET_ADMINfor a CNI plugin), write a narrow custom profile or use a targetedsecurityContext.capabilities.addand keep the AppArmor profile in enforce mode.
Kubernetes applies AppArmor profiles at the Pod / container level via the
securityContext.appArmorProfile field, GA since Kubernetes 1.30. (The older
container.apparmor.security.beta.kubernetes.io/* annotation API is deprecated — use the
field on 1.30+.)
securityContext:
appArmorProfile:
type: RuntimeDefault # use the container runtime's default (= docker-default or equivalent)
# type: Localhost # load a profile that already exists on the node
# localhostProfile: my-nginx-profile
# type: Unconfined # no confinement — avoid in productionThe profile must be loaded on every k8s node that can schedule the Pod. Use a DaemonSet or a node provisioning step (cloud-init, Ansible, etc.) to push and load custom profiles:
# Load a profile from a file
apparmor_parser -r -W /etc/apparmor.d/my-nginx-profile
# Confirm it is loaded
aa-status | grep my-nginx-profileIf the profile is absent on a node and type: Localhost is set, the kubelet will refuse
to start the container — the Pod stays in Pending with an event like
apparmor profile not found: my-nginx-profile.
- Run the application in complain mode to capture every access it actually makes.
- Generate a profile stub from the audit log.
- Tighten the profile, remove overly broad rules.
- Switch to enforce mode and regression-test.
# Install tooling (Ubuntu)
apt-get install apparmor-utils auditd
# Create a stub profile for a binary
aa-genprof /usr/sbin/nginx
# → interactively walks you through an exercise run, outputs a profile
# Or load the binary in complain mode manually, exercise it, then scan logs
aa-logprof # reads /var/log/audit/audit.log or /var/log/syslog and proposes rules#include <tunables/global>
/usr/sbin/nginx {
#include <abstractions/base>
#include <abstractions/nameservice>
capability net_bind_service,
capability setuid,
capability setgid,
/etc/nginx/** r,
/var/log/nginx/** rw,
/var/www/html/** r,
/run/nginx.pid rw,
network inet stream,
network inet6 stream,
deny /proc/sys/** w,
deny /sys/** w,
}
Load and enforce:
apparmor_parser -r -W /etc/apparmor.d/usr.sbin.nginx
aa-enforce /etc/apparmor.d/usr.sbin.nginx# Full status — loaded profiles, modes, and confined processes
aa-status
# Put a profile into complain mode (non-destructive audit)
aa-complain /etc/apparmor.d/usr.sbin.nginx
# Put a profile into enforce mode
aa-enforce /etc/apparmor.d/usr.sbin.nginx
# Reload a profile after editing (without restarting the daemon)
apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
# Disable a profile (unload from kernel)
aa-disable /etc/apparmor.d/usr.sbin.nginx
# View live denials (requires auditd or readable syslog)
grep "apparmor=\"DENIED\"" /var/log/syslog
journalctl -k | grep apparmorA common interview question: "If you have seccomp, why do you need AppArmor?"
| seccomp | AppArmor | |
|---|---|---|
| Filters | Syscall numbers + arguments | File paths, network families, capabilities, mounts |
| Layer | Kernel syscall table | LSM hook (after syscall dispatch) |
| Granularity | Per-syscall, per-arg | Per-binary, per-resource path |
| Docker default | default.json seccomp profile |
docker-default AppArmor profile |
| Bypassed by | Using allowed syscalls creatively | Hard links, /proc/*/fd/ path aliasing |
They defend against different attack surfaces. seccomp reduces the kernel attack surface (fewer syscalls reachable from a container). AppArmor constrains what resources an already-running process can touch. Both should be active; neither replaces the other.
Lima's Ubuntu guests ship with AppArmor enabled (/sys/module/apparmor/parameters/enabled = Y).
cat /sys/module/apparmor/parameters/enabled
YThe Docker provisioner installed by lima-qemu-dockerd.yaml inherits this — so docker-default
is loaded and applied to every container run inside the VM.
Key operational notes:
-
If you see container startup failures with
permission deniederrors that disappear with--privileged, check AppArmor before assuming a filesystem permission issue. AppArmor denials are silent to the container process (it seesEACCESorEPERM) but are logged in the guest'sjournalctl -k. -
Custom profiles you load inside the Lima guest are ephemeral unless you add a provisioning step (Lima
provision:scripts inlima-qemu-dockerd.yaml) to reload them on VM restart./etc/apparmor.d/contents persist on the disk image, but the kernel cache is rebuilt at boot —apparmor_parsermust run again. -
The Lima
vzbackend (macOS Virtualization.framework) and theqemubackend both run the same Ubuntu guest kernel, so AppArmor behavior is identical regardless of whichvmTypeyou choose. -
Windows / WSL 2 parallel. The same model applies to Docker under WSL 2 on Windows: AppArmor lives inside the WSL 2 Linux distro (Ubuntu, or Docker Desktop's managed
docker-desktopdistro), never on the Windows host. Profiles are loaded withapparmor_parserinside the distro and — exactly like Lima — the kernel cache is rebuilt at boot, so awsl --shutdown/restart drops any profile that a startup step does not re-apply. Seedocker-windows.md.
AppArmor is defense-in-depth, not a hard security boundary. It raises the cost of an attack and shrinks what a compromised process can touch, but several failure modes silently erode or eliminate that protection. Know them before you count on it.
| Threat / limitation | Why it matters | Mitigation |
|---|---|---|
| Path-based, not label-based | Rules match pathnames, so hard links, bind mounts, symlinks, and /proc/<pid>/fd/ aliasing can route around a rule; moving or renaming a confined binary can drop its profile entirely. |
Deny rename/link vectors in the profile, keep the rootfs read-only (container-security.md); use SELinux (inode labels) where that bypass class is unacceptable. |
| Only what the profile names is constrained | Uncovered paths default to allow. Coarse profiles like docker-default are deliberately loose at the file level and give a false sense of coverage. |
Write tight per-workload profiles (§4); treat docker-default as a floor, not a ceiling; pair with seccomp (§6). |
| Mode is silently defeatable | complain logs but allows; unconfined, --privileged, or a profile that never loaded means zero enforcement — with no error surfaced to the workload. |
Verify aa-status and docker inspect --format '{{.AppArmorProfile}}' in CI; forbid apparmor=unconfined and --privileged in review. |
| Ephemeral profiles drift to unconfined | The kernel cache is rebuilt at boot; after a reboot / wsl --shutdown a custom profile that isn't re-applied leaves the workload unconfined. |
Load profiles from a provisioning step (cloud-init, DaemonSet, Lima provision:); alert on unexpected unconfined. |
| Not a kernel-exploit boundary | AppArmor is an in-kernel LSM; a kernel bug — or a syscall the profile cannot express — bypasses it. It cannot contain a kernel-level compromise. | Layer with seccomp, user namespaces, and a VM/microVM boundary for untrusted code; keep the guest kernel patched. |
| Availability risk → the "unconfined" escape hatch | An over-tight profile breaks the app with silent EACCES/EPERM; the tempting fix is to disable confinement globally, losing all protection. |
Diagnose the specific denial in journalctl -k and allow it narrowly; never blanket-unconfined (§2, §5). |
| Privileged actors can unload it | Replacing or unloading a profile needs CAP_MAC_ADMIN in the initial namespace; root on the host/guest can aa-disable or swap a profile. |
Protect the host/guest boundary; restrict who can run apparmor_parser/aa-*; audit profile changes. |
| Distro / portability gap | AppArmor is Ubuntu/Debian/SUSE only; RHEL, Amazon Linux, and Azure Linux use SELinux — the same profile-based control simply is not present. | Do not assume AppArmor exists across a multi-cloud fleet; map controls per distro and bake them into the image. |
Design rule: Treat AppArmor as one layer in a stack (seccomp + dropped caps + read-only rootfs + user namespaces + VM isolation), never as the sole control. The most common real-world failure is not a clever bypass — it is a profile weakened to
unconfinedto make an app work, or one that silently stopped loading. Verify enforcement; do not assume it.
container-security.md— full runtime hardening checklist (caps, seccomp, read-only rootfs)glossary.md— definitions for HVF, KVM, TCGlima.md— how the Lima VM guest is structureddocker-windows.md— the WSL 2 equivalent on Windowsacceleration.md— VM execution paths (HVF / KVM / TCG)- AppArmor kernel docs
- Docker AppArmor security profiles
- Kubernetes AppArmor