AppArmor is the mandatory access control system that Ubuntu enables by default. Instead of labelling every file like SELinux, it attaches a profile to a program by its path, and the profile lists exactly which files, capabilities and network access that program may use. Anything not listed is denied, even for root. In this tutorial you will inspect AppArmor on Ubuntu 24.04, write a profile for a small backup script, refine it in complain mode, enforce it, and confirm that it blocks actions outside the profile.

Prerequisites

To follow this tutorial, you need:

  • A server running Ubuntu 24.04 LTS, such as a CubePath VPS. Debian 12 also enables AppArmor by default and uses the same commands.
  • A non-root user with sudo privileges.

Step 1 - Checking AppArmor status

AppArmor is part of the Ubuntu kernel and the apparmor package is installed by default. The command-line helpers used in this guide (aa-complain, aa-enforce, aa-logprof) are in apparmor-utils:

sudo apt update
sudo apt install apparmor-utils

Check that the module is loaded and which profiles are active:

sudo aa-status
apparmor module is loaded.
121 profiles are loaded.
27 profiles are in enforce mode.
   /usr/bin/man
   /usr/lib/snapd/snap-confine
   lsb_release
   man_filter
   nvidia_modprobe
   rsyslogd
   ...
94 profiles are in unconfined mode.
   ...
2 processes have profiles defined.
2 processes are in enforce mode.
   /usr/sbin/rsyslogd (812) rsyslogd
   ...

Profiles can be in one of these modes:

ModeBehaviour
enforceActions not allowed by the profile are blocked and logged.
complainNothing is blocked, but actions that would be denied are logged. Used while building a profile.
unconfinedThe profile is loaded but does not restrict the program; Ubuntu 24.04 uses these to grant specific permissions such as user namespaces to some applications.

Profiles live in /etc/apparmor.d/. By convention, a profile's file name is the program's path with dots instead of slashes, for example usr.sbin.rsyslogd. Local additions go in /etc/apparmor.d/local/, and reusable rule sets (abstractions) in /etc/apparmor.d/abstractions/.

Step 2 - Creating the program to confine

To have something concrete to confine, create a small script that archives a notes directory. First, the data and the destination:

sudo mkdir -p /srv/notes /var/backups/notes
echo "first note" | sudo tee /srv/notes/todo.txt

Create the script:

sudo nano /usr/local/bin/notes-backup
#!/bin/bash
set -euo pipefail

tar -czf "/var/backups/notes/notes-$(date +%F).tar.gz" -C /srv notes

Make it executable and run it once without any profile to confirm it works:

sudo chmod 755 /usr/local/bin/notes-backup
sudo /usr/local/bin/notes-backup
ls /var/backups/notes/
notes-2026-09-25.tar.gz

The script uses a fixed interpreter path (#!/bin/bash) rather than /usr/bin/env bash on purpose: AppArmor attaches profiles by executable path, and a fixed path keeps the profile simple.

Step 3 - Writing the profile

Create the profile file:

sudo nano /etc/apparmor.d/usr.local.bin.notes-backup
abi <abi/4.0>,
include <tunables/global>

profile notes-backup /usr/local/bin/notes-backup {
  include <abstractions/base>
  include <abstractions/bash>
  include <abstractions/nameservice>

  # The script itself and the programs it runs
  /usr/local/bin/notes-backup r,
  /usr/bin/bash ix,
  /usr/bin/date ix,
  /usr/bin/tar ix,
  /usr/bin/gzip ix,

  # Data it reads
  /srv/notes/ r,
  /srv/notes/** r,

  # Where it writes archives
  /var/backups/notes/ r,
  /var/backups/notes/*.tar.gz w,

  include if exists <local/usr.local.bin.notes-backup>
}

What each part does:

  • abi <abi/4.0> pins the feature set of AppArmor 4.0, the version in Ubuntu 24.04, so the profile behaves the same after kernel upgrades.
  • profile notes-backup /usr/local/bin/notes-backup names the profile and attaches it to that path.
  • The include <abstractions/...> lines pull in common rules: libraries and /dev/null (base), shell basics (bash) and user and group lookups (nameservice), which tar needs.
  • r is read, w is write, and ix means the program may execute that binary and the child runs under the same profile. A binary without an x rule cannot be executed at all.
  • /srv/notes/** matches everything below the directory; /srv/notes/ (with the trailing slash) is the directory itself, needed to list it.
  • The final include if exists line lets you add rules later in /etc/apparmor.d/local/ without editing the main profile.

Step 4 - Loading the profile in complain mode

Load the profile in complain mode first, so that any rule you forgot is logged instead of breaking the program:

sudo aa-complain /etc/apparmor.d/usr.local.bin.notes-backup
Setting /etc/apparmor.d/usr.local.bin.notes-backup to complain mode.

aa-complain adds flags=(complain) to the profile and loads it into the kernel. Confirm:

sudo aa-status | grep -A3 "complain mode"
1 profiles are in complain mode.
   notes-backup

Now run the program and exercise everything it normally does:

sudo /usr/local/bin/notes-backup

Look for anything the profile would have denied. AppArmor logs to the kernel log:

sudo journalctl -k --since "10 minutes ago" | grep 'profile="notes-backup"'

If the output is empty, the profile already covers everything the script did. If there are lines with apparmor="ALLOWED", each one is a rule the profile is missing.

Step 5 - Refining the profile with aa-logprof

You can add missing rules by hand, but aa-logprof reads the log and proposes them interactively:

sudo aa-logprof
Reading log entries from /var/log/syslog.
Updating AppArmor profiles in /etc/apparmor.d.

Profile:  notes-backup
Path:     /srv/notes/archive/old.txt
New Mode: r
Severity: 4

 [1 - /srv/notes/archive/old.txt r,]
  2 - /srv/notes/archive/* r,
(A)llow / [(D)eny] / (I)gnore / (G)lob / Glob with (E)xtension / (N)ew / Audi(t) / Abo(r)t / (F)inish

For each event, choose the narrowest rule that still makes sense: G generalises a path into a glob, A adds the selected rule and D adds an explicit deny. At the end, save the changes with S. If aa-logprof finds nothing to add, it prints nothing and exits, which is what you want.

Always read the profile after aa-logprof runs to make sure it did not add broad rules such as /** r,:

cat /etc/apparmor.d/usr.local.bin.notes-backup

Step 6 - Enforcing the profile

When the program runs in complain mode without generating new log entries, switch to enforce mode:

sudo aa-enforce /etc/apparmor.d/usr.local.bin.notes-backup
Setting /etc/apparmor.d/usr.local.bin.notes-backup to enforce mode.

Run the script again; it must still work:

sudo /usr/local/bin/notes-backup && echo "backup OK"
backup OK

Profiles in /etc/apparmor.d/ are loaded automatically at boot by apparmor.service, so the profile stays enforced after a reboot.

Step 7 - Verifying that the profile blocks other actions

To see enforcement in action, make the script do something it has no business doing. Edit it and add a line that reads the password hashes:

sudo nano /usr/local/bin/notes-backup
#!/bin/bash
set -euo pipefail

cat /etc/shadow > /dev/null
tar -czf "/var/backups/notes/notes-$(date +%F).tar.gz" -C /srv notes

Run it as root:

sudo /usr/local/bin/notes-backup
/usr/local/bin/notes-backup: line 4: /usr/bin/cat: Permission denied

Even though the script runs as root, AppArmor refused to execute cat, because the profile has no rule for it. The kernel log records the denial:

sudo journalctl -k -n 5 | grep DENIED
kernel: audit: type=1400 audit(1790332012.456:188): apparmor="DENIED" operation="exec" class="file" profile="notes-backup" name="/usr/bin/cat" pid=4412 comm="notes-backup" requested_mask="x" denied_mask="x" fsuid=0 ouid=0

The fields tell you everything: which profile (profile), what was attempted (operation, requested_mask) and on what (name). Remove the cat line from the script again.

Step 8 - Making local changes and reloading

When the program legitimately needs more access, add the rule to the local file instead of the main profile, so package updates or regenerated profiles do not overwrite it. For example, to let the script also archive /srv/journal:

sudo nano /etc/apparmor.d/local/usr.local.bin.notes-backup
/srv/journal/ r,
/srv/journal/** r,

Reload the profile after every change. apparmor_parser -r replaces the loaded version and reports syntax errors with a line number:

sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.notes-backup

If you need to turn a profile off completely, disable it. This unloads it and creates a symlink in /etc/apparmor.d/disable/ so it stays off after a reboot:

sudo aa-disable /etc/apparmor.d/usr.local.bin.notes-backup

Re-enable it later with sudo aa-enforce /etc/apparmor.d/usr.local.bin.notes-backup.

Troubleshooting

AppArmor parser error ... syntax error. Every rule must end with a comma, and the profile block with }. The parser prints the file and line number; fix it and run sudo apparmor_parser -r again.

Changes have no effect. The profile was edited but not reloaded. Run sudo apparmor_parser -r on the file. Also check that the path in the profile header matches the real executable; /bin/... and /usr/bin/... are the same file on Ubuntu 24.04, and AppArmor always sees the resolved /usr/bin/... path.

aa-logprof finds no events. It reads /var/log/syslog (or /var/log/audit/audit.log when auditd is installed). On minimal systems without rsyslog, install it with sudo apt install rsyslog or check the kernel log directly with journalctl -k.

A program fails but you see no denials. Confirm the profile is attached with sudo aa-status. A program that was already running when you loaded the profile stays unconfined until it is restarted.

Conclusion

You wrote an AppArmor profile from scratch, refined it in complain mode, enforced it on Ubuntu 24.04 and saw it block an action even for root. The same workflow (complain, exercise, aa-logprof, review, enforce) applies to confining any daemon you run. As next steps, install the apparmor-profiles package to see more example profiles, read man apparmor.d for the full rule syntax, and use aa-genprof to generate a starting profile for larger programs.