SELinux asks what a process is allowed to do with a label; AppArmor asks what a program is allowed to touch by path. That single difference explains why Ubuntu, Debian and SUSE administrators sleep better with AppArmor: a profile is a readable list of files, directories and capabilities attached to a program, and writing one takes an evening instead of a career. This article builds a real profile for a homegrown service from scratch: preparing the application, generating the first draft with aa-genprof, letting complain mode collect the truth about runtime behavior, shaping the result with abstractions, and flipping the profile to enforcing without breaking production.
How AppArmor thinks and where profiles live
AppArmor mediates access by executable path. When a program runs under a profile, the kernel checks every file operation, network call and capability use against the rules of that profile, and a violation in enforcing mode means an immediate Operation not permitted error. Denials are logged with the profile name and the exact path involved, which makes the feedback loop unusually short: the log tells you the profile name, the file path and the permission that failed, in one line.
Profiles are plain text files in /etc/apparmor.d/. The file name follows a dot-separated convention, for example usr.local.bin.myapp for a binary installed at /usr/local/bin/myapp, but the profile name inside the file is what matters. The overall status of the subsystem is visible through aa-status or apparmor_status, which prints every loaded profile with its mode, loaded into enforcing or complain, and counts of profiles altogether:
aa-status profiles are loaded. 30 profiles are in enforce mode. 0 profiles are in complain mode.
Two quick diagnostic commands complete the picture. aa-unconfined lists running processes that have no profile at all, which is the honest inventory of your attack surface before hardening starts. And /proc/sys/kernel/apparmor/display_id or, on older releases, the details inside aa-status show which profiles are active on the running kernel, because a profile on disk does nothing until the apparmor service loads it.
Preparing the application and generating the first draft
The workflow starts with the binary, a script or a service unit that can be restarted. The canonical tool for a first profile is aa-genprof, which creates a skeleton and then watches the audit log while you exercise the application:
aa-genprof /usr/local/bin/myapp
The tool opens a scan loop: it prompts you to start the application in another terminal, use it the way production uses it, and return to press S for scan. Every access the application makes while unconfined generates an audit event, and each scan surfaces those events as questions: the tool shows a path, a permission and a suggested rule, and offers choices like inherit for an executed child, or a named transition profile. Answering the questions is a conversation with the application about its own behavior, and the answers land directly in the profile file.
A realistic session for a small web service runs like this. First pass: start the service, hit its endpoints, stop it. Scan. The questions cover the binary itself, its libraries, configuration under /etc/myapp, the log directory, and the listen socket. Second pass: run the nightly cleanup task that the service also performs, because cron-triggered behavior is part of the same profile and always forgotten on the first attempt. Third pass: deliberately trigger the rare paths, such as a config reload on signal, since a profile that misses a once-a-week access works fine for six days and fails on the seventh.
The result of aa-genprof is a file in /etc/apparmor.d/ named after the binary, containing a profile in complain mode by default, with a header comment saying so. The profile is a draft at this point: it covers what happened during the session, and it is missing whatever did not happen yet. The next section is where the draft becomes honest.
Reading and editing the generated profile
A generated profile is worth reading before trusting, and the language is small enough to read fluently in one sitting. The skeleton looks like this:
#include <tunables/global>
/usr/local/bin/myapp {
#include <abstractions/base>
/etc/myapp/config r,
/var/log/myapp/ rw,
/var/log/myapp/*.log rw,
/usr/local/bin/myapp mr,
capability setuid,
capability setgid,
network inet stream,
/usr/local/bin/helper ix,
}
The permission letters compress a whole language: r is read, w is write with create and delete, m allows memory mapping, k file locking, l linking, and combinations like rw read them literally. The execute modes deserve attention because they define what happens when the program starts another program: ix inherits the current profile, px and Px switch to a named profile for the target, ux unconditionally drops all confinement, and the last one in any form is the rule to avoid, because it is a hole in the policy by definition. The mr pair on the binary itself is standard: the kernel needs to map the executable to run it.
Rules support variables and wildcards, and a star covers a directory in one line: /var/log/myapp/ rw gives recursive write access under the log tree, while a single star stays one level deep. Owner conditions tighten a rule to files belonging to the running user, owner /home/ rw, which matters on shared systems. Every edit lands after a reload: systemctl reload apparmor or systemctl restart apparmor parses all profiles and rejects syntax errors with the offending line printed, so the loop from edit to test is seconds long.
Complain mode as a policy incubator
Enforcing a fresh profile on the first day is how administrators learn to hate AppArmor: every missing rule becomes an outage. Complain mode exists exactly for this stage. In complain mode the kernel logs violations instead of blocking them, the application runs as if unconfined, and every access it makes is recorded as a would-deny event. The profile accumulates truth about the application while the application keeps working, which is the whole idea:
aa-complain /etc/apparmor.d/usr.local.bin.myapp Setting /usr/local/bin/myapp to complain mode.
The operational pattern is a calendar, not a trick. Deploy the profile in complain mode alongside the application. Let it run for the full business cycle of the service, a week for most, a month-end for anything accounting touches, because the goal is to observe every code path that exists in real life. Then collect the log entries, and this is where the second tool of the workflow takes over: aa-logprof reads the audit log, groups the events per profile and asks the same question set as aa-genprof, turning accumulated runtime behavior into candidate rules for an existing profile instead of a new one.
aa-logprof -f /var/log/audit/audit.log
On Ubuntu systems where auditd is not the primary log sink, the same events arrive through the standard syslog; aa-logprof finds them either way, and the rsyslog integration section of the AppArmor documentation covers the plumbing when logs go elsewhere. The discipline that separates a good profile from a lucky one is simple: complain for the full cycle, logprof once, review the proposed rules by hand, and only then flip to enforcing. The review matters because the log contains what the application did, not what it should do, and the rare event of a compromised script touching a path it never legitimately needs is exactly what a hand-edited deny line is for.
Abstractions, reusable blocks and a house profile template
Nothing erodes discipline faster than copying the same forty lines into every profile, and AppArmor solved this years ago with abstractions. The files under /etc/apparmor.d/abstractions/ contain curated rule blocks for common needs: base covers the shared runtime essentials every dynamically linked program needs, nameservice covers user and DNS resolution, networking covers the common socket patterns, and user-tmp handles scratch files in home directories. Including them looks like this:
#include <abstractions/base>
#include <abstractions/nameservice>
#include <abstractions/networking>
A house template builds on this. A service that reads its config from /etc, writes logs under /var/log, binds one port and runs one helper binary has a profile of roughly ten lines, because the abstractions carry the bulk. The maintenance benefit compounds over years: when a distribution update changes where name resolution caches live, the abstraction absorbs the change and every profile using it survives the upgrade without an incident.
Abstractions are also the honest boundary of what to write by hand. A rule that no abstraction covers, say a service touching a vendor-specific device under /dev, belongs in the profile itself with a comment explaining the why. A rule that three profiles share belongs in a local abstraction, for example /etc/apparmor.d/abstractions/myhouse-common, which turns tribal knowledge into an artifact instead of a legend.
Flipping to enforcing and keeping the profile alive
The switch is one command and should feel ceremonial anyway:
aa-enforce /etc/apparmor.d/usr.local.bin.myapp Setting /usr/local/bin/myapp to enforce mode.
Before flipping, run the deployment checklist that experienced administrators keep. Confirm the profile survived the last reload without parse warnings, because a profile that fails to load is a profile that does not exist. Exercise the application once more in a staging copy with the profile already in enforcing mode, since the complain stage can hide a rule that only fails under a specific workload. Confirm log monitoring catches AppArmor denials, by grepping the audit log for the profile name during the test, because after the flip the log is the only signal that something is being blocked.
Keeping the profile alive is where most of the lifespan effort goes. Application updates change behavior, and a strict profile turns each update into a small negotiation: deploy in complain mode again, watch the audit trail for new would-deny entries, logprof, review, enforce. The aa-logprof cycle is the same as the incubation stage, just shorter, because the delta is usually a few paths. Profiles under version control make the negotiation visible: the diff between profile versions reads as a changelog of what the application started doing, which turns security maintenance from a tax into documentation nobody else in the industry gets for free.
The closing checklist fits on one card. Inventory with aa-unconfined. Generate with aa-genprof and a realistic exercise session. Complain for the full business cycle. Absorb the log with aa-logprof. Read every rule, include abstractions, add hand-written rules with comments. Enforce, test the fallback path, watch the logs. Version the profile with the application, and treat every deploy as a micro-iteration of the same loop. Done this way, AppArmor stops being an Ubuntu curiosity and becomes the quiet reason a compromised helper script finds nothing to read.
## Debugging denials when enforcing still bites
Even a well-aged profile misses something eventually, and the denial format is designed to be read under pressure. An audit entry names the profile, the operation, the exact path or socket involved and the requested permission, in one structured line. On systems with auditd the entries live in /var/log/audit/audit.log; on default Ubuntu installs the journal carries them, and journalctl -k | grep apparmor surfaces the recent ones in seconds. The anatomy of every entry is the same: apparmor="DENIED" followed by the operation, the profile in effect, the full path and the capability or permission class, which means the fix is usually visible in the log line itself before any tool is invoked.
A denial in production follows a strict order of operations. First, decide whether the access is legitimate by reading the denial: an application failing to reopen its own log after rotation is legitimate, an attempt to write to /etc/passwd is not. Second, if the access is legitimate, add the narrowest rule that satisfies it, reload with systemctl reload apparmor, and confirm the error disappears. Third, if the access is suspicious, leave the profile exactly as it is, because the denial is the system working correctly, and document the event in the runbook. The one move that is never right is flipping the whole profile back to complain mode to silence a single missing path, since that trades one small outage risk for the entire attack surface of an unconfined service.
Long-running services add one nuance worth respecting: a profile reload changes rules for future operations, but a process that already holds an open file handle keeps it until the handle closes. A rule added after a denial therefore proves itself only after the service restarts or the log rotates, and chasing a fix that seems to do nothing is usually just chasing an open descriptor. Restart the service as part of the verification step and the second test tells the truth.