Reading AppArmor denial traces in syslog and journald, understanding the apparmor DENIED format, running the observe-adjust-reload-verify debug loop, and using aa-notify for real-time denial notifications.

In episode 6, you tightened profiles with network and capability rules — and you most likely also harvested denials in the logs right away. That's not failure; that's a signal at work. The problem is that many engineers respond to a denial by removing rules until the error goes away, without ever knowing what was actually blocked.
Episode 7 changes how you face denials: not as an enemy to silence, but as information to read. AppArmor records nearly every refusal with detail, and reading those records correctly is the skill that separates operators who guess from operators who understand.
By default, AppArmor denials are written to the kernel log, which then flows into the system logs. On Ubuntu and Debian with rsyslog, the traces live in a single file:
grep apparmor /var/log/syslogOn modern systems with journald, the faster way is through journalctl — especially for kernel messages:
journalctl -k --grep=apparmorsudo journalctl -k --grep=apparmor -n 50The -n 50 flag limits to the last 50 lines, while -f follows the log like tail -f — very useful while you reproduce a problem.
Note
If your system has auditd installed, AppArmor denials are also recorded in /var/log/audit/audit.log. Both contain the same information — pick whichever you're comfortable with, as long as you're consistent. For this episode we'll focus on syslog and journald.
Here's a typical denial:
audit: type=1400 audit(1710000000.123:456): apparmor="DENIED"
operation="open" profile="/usr/sbin/nginx"
name="/etc/nginx/secret.conf" pid=1234 comm="nginx"
requested_mask="r" denied_mask="r" fsuid=0Five elements you must read:
apparmor="DENIED" — the marker that this is an AppArmor refusal, not an application error.operation — the blocked operation: open, exec, connect, mkdir, and so on.profile — the profile doing the refusing; here, the Nginx profile.name — the target object, usually a file path or socket address.requested_mask and denied_mask — the rights requested and the rights denied. r means read, w write, x execute, m memory map, k lock, a append.A network connect denial looks a bit different:
audit: type=1400 audit(1710000000.789:457): apparmor="DENIED"
operation="connect" profile="/usr/sbin/nginx"
name="/run/mysqld/mysqld.sock" pid=1234 comm="nginx"
requested_mask="rw" denied_mask="rw" fsuid=0Notice name here is a Unix socket path, not an IP address. This is a direct consequence of episode 6: the Nginx profile has no network unix stream rule, so the connection to MySQL over the socket is denied.
Debugging an AppArmor denial follows a four-step cycle. Skip a single step and you'll be fixing the wrong thing:
aa-logprof if you want step-by-step guidance.sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginxThe -r (replace) flag updates an already-loaded profile. If you created a brand-new profile file that was never loaded, use -a (add), or let aa-enforce/aa-complain handle it.
Important
Watch what changes between cycles. If a denial keeps appearing with the exact same profile and operation after a reload, the rule probably isn't actually matching the path — for example the path points to a symlink, or a @{HOME} variable isn't resolving. An unchanged denial means the information you fixed isn't what's being blocked.
Running the manual loop is fine for debugging, but for continuous monitoring there's a more convenient tool: aa-notify. On Ubuntu it's available through the apparmor-notify package. For a summary of the last few days of denials:
aa-notify -s 1-s takes the number of days — replace 1 with 7 for a week. The output looks roughly like this (simplified):
Profiles: 3 Denials: 12 Since: 1 day ago
/usr/sbin/nginx 8
/usr/sbin/mysqld 3
/usr/bin/python3.12 1For real-time desktop notifications, use poll mode:
aa-notify -p -u arman --display $DISPLAYThis mode keeps reading the logs and pops up a notification every time there's a new denial — so changes in application behavior are visible without opening a terminal. On servers without a GUI, the main scenario is scheduling aa-notify -s 1 via cron and checking the summary periodically. Global configuration (including who may run it and notification filtering) lives in /etc/apparmor/notify.conf.
In episode 7 you now hold the key to AppArmor debugging: knowing where denials are written (syslog, journald, and audit.log), being able to read the apparmor="DENIED" format down to the operation and mask level, running the observe-adjust-reload-verify cycle with apparmor_parser -r, and using aa-notify for automated notifications.
Keys to take home:
apparmor_parser -r, don't reboot.Up to this point, all your profiles are written with hardcoded paths — and hardcoded paths make profiles non-portable across systems. In episode 8, we'll meet Variables, Tunables & Includes: @{HOME}, @{PROC}, the tunables directory, and abstractions that let a single profile run on many machines without being reworked.