Most sysadmins know AppArmor exists. It ships enabled on Ubuntu, Debian, and most SUSE-based distros. Most sysadmins also leave it in "complain" mode forever, generate a profile once, call it done, and quietly forget it when the app breaks six months later.
That approach gives you zero actual security. A profile in complain mode is just an expensive audit log that nobody reads.
This article walks through the full cycle: instrumenting an application in learning mode, turning logs into a real profile, stress-testing it before it matters, and flipping to enforce without waking up at 3am to rollback. I’ll use a real-world target — a Python web app run under systemd — but every technique applies equally to bare binaries, containers, or daemons.
Why AppArmor, Not Just "chmod and call it a day"
Traditional Unix DAC (discretionary access control) only controls who runs a process. AppArmor controls what a process can do regardless of who runs it. A compromised nginx running as www-data can still traverse your filesystem, open /etc/shadow, or exec a shell — unless an AppArmor policy says otherwise.
AppArmor implements MAC (mandatory access control) as an LSM (Linux Security Module). It attaches a profile to an executable path. The kernel enforces the policy at every syscall boundary. The attacker who popped your app is now trapped in a box.
The catch is writing the profile. Get it wrong and your app breaks silently. Get it too permissive and you wasted your time.
Tooling You Actually Need
Install these first. Everything else flows from them:
sudo apt install apparmor apparmor-utils apparmor-profiles auditd
aa-genprof— the interactive profile generator. Runs your app, watches syscalls, proposes rules.aa-logprof— re-reads audit logs and updates an existing profile. Your best friend for iterative refinement.aa-status— shows which profiles are loaded and in what mode.auditd— ships AppArmor denials to/var/log/audit/audit.log. Without it, denials go to syslog with less detail.
Check that AppArmor is actually active:
sudo aa-status | head -5
If it says "0 profiles loaded," your kernel has AppArmor compiled in but it’s not enforcing anything. On Ubuntu that normally means the systemd service failed — check systemctl status apparmor.
The Profile Lifecycle in 30 Seconds
Absent → Complain → Enforce
↑
(iterate here)
- Absent: No profile attached. App runs unconstrained.
- Complain (learning mode): Profile exists but violations are only logged, never blocked. App runs normally.
- Enforce: Violations are blocked AND logged. App breaks if the profile is incomplete.
You never jump from absent to enforce on a production service unless you enjoy root-cause analysis under pressure.
Step 1 — Write the Skeleton with aa-genprof
aa-genprof works by putting a profile in complain mode, asking you to exercise the app, then scanning the resulting audit log to propose rules. Run it as:
sudo aa-genprof /usr/bin/python3
Wait — that’s too broad. AppArmor profiles attach to the absolute path of the binary, and /usr/bin/python3 is every Python script on the machine. For real deployments you want to profile the specific wrapper or the venv interpreter:
sudo aa-genprof /opt/myapp/venv/bin/python3
aa-genprof drops you into an interactive loop:
Please start the application to be profiled in another window and
exercise its functionality now.
[(S)can system log for AppArmor events] / (F)inish
In a second terminal, exercise the application. Hit every code path you care about: startup, a normal request, error handling, a file read, a DB connection. The more thorough you are here, the less you’ll chase missing rules in production.
When done, press S to scan. You’ll get a series of prompts like:
Profile: /opt/myapp/venv/bin/python3
Execute: /opt/myapp/venv/bin/python3
Severity: 4
(I)nherit / (C)hild / (P)rofile / (N)amed / (U)nconfined / (X) ix On / Deny / Abort / Finish
Gotcha #1: The "Unconfined" trap. aa-genprof often proposes Unconfined execution for child processes. Never accept that for security-sensitive apps. Choose ix (inherit — child runs under parent’s profile) or write a separate child profile with Px.
Go through all the prompts. When you reach (F)inish, it writes the profile to /etc/apparmor.d/ and loads it in complain mode.
Step 2 — Read the Generated Profile and Sanitize It
aa-genprof is a blunt instrument. Open what it produced:
cat /etc/apparmor.d/opt.myapp.venv.bin.python3
You’ll see something like:
# Last Modified: Sat May 23 12:00:00 2026
abi <abi/3.0>,
include <tunables/global>
/opt/myapp/venv/bin/python3 {
include <abstractions/base>
include <abstractions/python>
# Auto-generated — review these
/opt/myapp/** r,
/opt/myapp/** w, # ← too broad
/opt/myapp/logs/** w,
/etc/passwd r,
/proc/*/net/if_inet6 r,
network inet stream,
network inet6 stream,
/usr/bin/bash ix, # ← suspicious
}
The auto-generated profile usually has two problems:
- Overly broad
rwon the entire app directory. If your app only writes tologs/andtmp/, say so explicitly. - Spurious exec entries —
bash,sh,id,hostname. If your app doesn’t need to exec a shell, remove those lines. They’re either from your testing phase accidentally triggering debug tooling, or they’re exactly the kind of privilege an attacker would abuse.
Tighten it up manually:
/opt/myapp/venv/bin/python3 {
include <abstractions/base>
include <abstractions/python>
include <abstractions/nameservice> # DNS, /etc/hosts, /etc/resolv.conf
# App root — read-only
/opt/myapp/** r,
# Writable at runtime
/opt/myapp/logs/** w,
/opt/myapp/tmp/** rw,
# Python bytecode cache
/opt/myapp/**/__pycache__/ rw,
/opt/myapp/**/*.pyc rw,
# Config files (read-only)
/etc/myapp/config.yaml r,
# Network (outbound only — no raw sockets)
network inet stream,
network inet6 stream,
# PostgreSQL socket
/var/run/postgresql/.s.PGSQL.5432 rw,
# No shell execution
deny /bin/sh x,
deny /bin/bash x,
deny /usr/bin/bash x,
}
Use deny rules explicitly for things you know the app should never do. These take precedence and serve as documentation.
Step 3 — The Abstractions Cheatsheet
AppArmor ships with reusable "abstractions" that bundle common permission sets. Using them keeps profiles readable:
| Abstraction | What it covers |
|---|---|
abstractions/base |
Basic libc, locale, proc, timezone |
abstractions/python |
Python stdlib locations across distros |
abstractions/nameservice |
/etc/hosts, /etc/resolv.conf, NSS |
abstractions/ssl_certs |
/etc/ssl/certs, /usr/share/ca-certificates |
abstractions/openssl |
OpenSSL configs and engines |
abstractions/user-tmp |
/tmp, /var/tmp |
Browse what’s available on your system:
ls /etc/apparmor.d/abstractions/
Gotcha #2: Abstractions are distro-specific. An abstraction that exists on Ubuntu 24.04 may not exist on Debian 12 or may cover different paths. If you’re shipping a profile that needs to run on multiple distros, inline the rules rather than rely on abstractions — or write a CI test that validates the profile loads cleanly on each target.
Step 4 — Iterative Refinement with aa-logprof
You’ve run the app once in a controlled environment. Now leave it in complain mode under real (or realistic) load for a day or two. Cron jobs, healthchecks, log rotation, startup after reboot — all of these exercise paths aa-genprof never saw.
After soaking, run:
sudo aa-logprof -f /var/log/audit/audit.log
It reads denials and proposes additions to the existing profile. Same interactive flow as aa-genprof. Review each suggestion critically. Blanket-accept runs the risk of enshrining the bad paths an attacker might have already probed.
If auditd isn’t running, check syslog instead:
sudo aa-logprof -f /var/log/syslog
For systemd-journal environments:
journalctl -k | grep -i apparmor | grep -i denied
Repeat the soak-and-refine cycle until log scanning produces zero new proposals on a quiet system. That’s your signal the profile is stable enough for enforce mode.
Step 5 — Testing Before Enforce (The Part Everyone Skips)
Before flipping to enforce, deliberately probe for broken paths. Run the following against your complain-mode service and check for new log entries after each:
# Trigger startup + full init path
sudo systemctl restart myapp
# Exercise every API endpoint (replace with your actual healthcheck/smoke test)
curl -sf https://cd-linux.club:8000/health
curl -sf https://cd-linux.club:8000/api/v1/status
# Trigger log rotation (if applicable)
sudo logrotate -f /etc/logrotate.d/myapp
# Simulate a scheduled job if your app has one
sudo -u myapp /opt/myapp/bin/run-scheduled-task.sh
After each batch, run:
sudo journalctl -k --since "5 minutes ago" | grep -i "apparmor.*ALLOWED\|apparmor.*DENIED"
In complain mode, the keyword is ALLOWED (not DENIED). Every ALLOWED entry means "this would be blocked in enforce mode." Resolve them all before proceeding.
Gotcha #3: Kernel version differences. If your staging kernel is 6.1 and production is 6.6, the set of syscalls AppArmor intercepts may differ slightly. Test on an identical kernel. Running uname -r on both is obvious — actually doing it before you enforce is where people fall short.
Step 6 — Flipping to Enforce Mode
Once you’re satisfied with the profile, enforce it:
sudo aa-enforce /etc/apparmor.d/opt.myapp.venv.bin.python3
Verify:
sudo aa-status | grep myapp
You should see it listed under "profiles in enforce mode."
Restart the service and immediately tail the audit log:
sudo systemctl restart myapp && sudo tail -f /var/log/audit/audit.log | grep DENIED
If nothing appears and the service starts cleanly, you’re done. If denials show up, you have a gap in the profile. Fix it:
sudo aa-complain /etc/apparmor.d/opt.myapp.venv.bin.python3
# run aa-logprof, update profile
sudo aa-enforce /etc/apparmor.d/opt.myapp.venv.bin.python3
Automating Profile Loading with systemd
Don’t rely on the global AppArmor service to load profiles in the right order. For service-specific profiles, load them in the unit file:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
ExecStartPre=+/usr/sbin/apparmor_parser -r /etc/apparmor.d/opt.myapp.venv.bin.python3
ExecStart=/opt/myapp/venv/bin/python3 /opt/myapp/main.py
Restart=on-failure
[Install]
WantedBy=multi-user.target
The + prefix on ExecStartPre runs that command as root regardless of the User= setting. The -r flag reloads (replace) the profile atomically. This means every service restart guarantees the current profile on disk is the one being enforced — no stale state.
AppArmor in Docker and Kubernetes
For containers, the mechanics shift slightly but the principles don’t.
Docker accepts a custom AppArmor profile via --security-opt:
docker run \
--security-opt apparmor=myapp-profile \
--name myapp \
myapp:latest
The profile must be loaded on the host before the container starts:
sudo apparmor_parser -r -W /etc/apparmor.d/myapp-profile
Kubernetes lets you attach profiles via annotations or the newer securityContext.appArmorProfile field (GA in 1.30):
# Kubernetes 1.30+ (GA API)
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
spec:
containers:
- name: myapp
image: myapp:latest
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: myapp-profile # must be loaded on all nodes
Gotcha #4: Node profile distribution. In Kubernetes, AppArmor profiles must be loaded on every node that might schedule the pod. Use a DaemonSet to push profiles, or better, use a dedicated project like Security Profiles Operator which handles this lifecycle problem properly.
Production-Ready Profile Checklist
Before pushing any profile to enforce in production, run through this:
Deny explicit attack vectors:
deny /bin/sh x,
deny /bin/bash x,
deny /usr/bin/python3 x, # prevent interpreter chaining
deny @{PROC}/@{pid}/mem rw,
deny @{PROC}/sysrq-trigger rw,
deny /sys/kernel/security/** rwklx,
Restrict capability acquisition:
capability net_bind_service, # only if app binds <1024
# deny everything else implicitly — AppArmor default-deny
Version-control your profiles. Store them in your application repo under deploy/apparmor/, not just on the production host. A profile that lives only on one server is a profile that gets lost during the next rebuild.
Test profile reload in CI. Add a step to your pipeline:
sudo apparmor_parser --preprocess /etc/apparmor.d/myapp-profile
This validates syntax without loading. Catches typos before they hit the host.
Monitor for denials in production. Forward AppArmor audit events to your log aggregation stack. A Loki query like:
{job="syslog"} |= "apparmor" |= "DENIED"
should trigger an alert. Denials in production after enforce is live mean either a code path you never tested, or an active probe.
When to Use hat (Sub-Profiles)
If your application has a privileged initialization phase and a lower-privilege serving phase, AppArmor hats let you model this:
/opt/myapp/venv/bin/python3 {
# Startup rules: broader permissions during init
/etc/myapp/secrets/** r,
/var/lib/myapp/migrations/** rw,
^serving {
# Runtime hat: only what the HTTP server needs
/opt/myapp/static/** r,
/opt/myapp/logs/** w,
network inet stream,
}
}
The application calls aa_change_hat("serving", magic_token) from C (or via ctypes from Python) to transition into the restricted hat at runtime. Only the kernel knows the magic token, so even a compromised process can’t escape back to the parent profile.
This is underused. It’s exactly the right tool for any daemon that does privileged setup once and then serves requests indefinitely.
The Mindset Shift That Actually Makes This Work
Most people treat AppArmor as a checkbox. "We have AppArmor profiles." They don’t ask whether the profiles are accurate.
The useful mental model: your AppArmor profile is a specification of your application’s intended behavior. If the profile allows something your app should never do, the profile is wrong. If the app does something the profile doesn’t allow, either the app is doing something unexpected (interesting) or you missed a code path (fix the profile).
Treat unexpected denials the same way you treat unexpected errors in your application logs — investigate before dismissing. The denial that looks like a false positive is occasionally the thing that caught an actual compromise.
That’s the difference between AppArmor as security theater and AppArmor as an actual control.