Ubuntu 26.10, codenamed Stonking Stingray, was released as an interim release, as Canonical’s release announcement states and OMG! Ubuntu’s coverage of the launch also covers. Canonical supports it until July 2027, so most server teams stay on the 26.04 LTS release. Both versions ship the same security layer that decides what a hijacked web server can do next: AppArmor.
This AppArmor tutorial takes 13 steps and about 55 minutes. You confine nginx on Ubuntu with a custom profile, start in complain mode, collect real access logs, tighten the rules by hand, switch to enforce mode and prove that the profile blocks a path traversal leak. You finish with a deployable project, a verification script and a systemd timer that alerts you when confinement slips.
A fresh server rarely has this layer configured for the services you installed yourself. A Lynis Linux security audit will tell you AppArmor is loaded, but it cannot tell you that nginx runs with no profile at all. That gap is what you close here.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
AppArmor on Ubuntu in 2026: What Changed and Why It Still Matters
As of October 28, 2026, AppArmor remains the default mandatory access control system on Ubuntu. It is not a new tool, and that is the point. The workflow you learn here works the same on 26.04 LTS and 26.10, and the commands have barely moved in years. What moves is the packaging around them.
Ubuntu 26.04 LTS documents nginx 1.28.2 as its packaged web server, and Ubuntu’s package-change mailing list reportedly shows an AppArmor 5.0.2 build accepted for 26.04. The upstream project’s release page lists a 6.0 alpha as its newest entry, but that is a development snapshot and not something to deploy on a production server. Stick with the userspace version your Ubuntu release ships. Profiles written against the packaged version keep working after routine updates.
According to coverage of the 26.10 launch, Ubuntu also keeps using AppArmor to mediate D-Bus access for snaps and critical packages. That tells you where Canonical’s investment sits. The kernel module and the profile language are not going away, and a profile you write today will still be readable in three years.
Why bother with a profile for nginx at all? Consider what a web server process can normally do. It can read most of the filesystem, open outbound network connections, and write wherever its user owns files. A single bug in a module, a misconfigured alias directive or a vulnerable upstream library hands an attacker all of that. A profile shrinks the blast radius to the paths and capabilities nginx genuinely needs.
Picture a realistic failure. A developer adds a download endpoint with an alias directive and forgets a trailing slash. A scanner finds it within hours and starts walking up the directory tree. Without a profile, nginx happily serves any file the www-data user can read, including application configs, backups and deployment keys left in a home directory. With a profile, the kernel refuses every path outside the rule set, and the attempt shows up in your logs as a pattern of DENIED lines instead of a quiet data leak.
Mandatory access control sits underneath your other defenses, not in place of them. Key-only logins from an ssh-keygen Ed25519 setup shut the front door, and Fail2ban against SSH brute force throttles the people rattling the handle. AppArmor assumes something got in anyway and limits what it can touch. Layering the three costs an afternoon and removes a lot of bad weekends.
How AppArmor Works: Profiles, Modes and Rules
AppArmor is a Linux Security Module. The kernel checks every file open, network call and capability request against a profile attached to the running program. If no rule allows the action, the kernel refuses it, regardless of Unix file permissions. Even root inside a confined process obeys the profile.
Ubuntu’s server documentation states that AppArmor profiles are simple text files located in /etc/apparmor.d/. The same page explains that AppArmor uses profiles of an application to determine what files and permissions the application requires. That plain-text design is why you can review, diff and version-control a profile like any other config file.
Profile Attachment and Naming
A profile attaches to an executable path. When the kernel runs /usr/sbin/nginx, it looks for a profile whose attachment matches that path and switches the process into that profile. The profile also has a name, which shows up in logs and in /proc/PID/attr/current. In this guide the attachment is /usr/sbin/nginx and the name is nginx.
Worker processes inherit the profile because nginx forks them without executing a new binary. One profile therefore covers the master and every worker. That keeps the rule set small compared with, say, a PHP application server that spawns helper programs.
The Four Rule Types You Will Write
- File rules grant read (
r), write (w), memory-map executable (m), lock (k) and link (l) access to paths and globs. - Capability rules allow specific root powers such as
net_bind_servicefor ports below 1024. - Network rules allow socket families and types, for example
network inet streamfor IPv4 TCP. - Signal rules control who may send signals to the process, which matters for reloads and log rotation.
Anything not listed is denied in enforce mode. An explicit deny rule beats any allow rule, and the Ubuntu documentation notes that explicit deny rules still apply in complain mode. Remember that detail, because it explains a few surprises later.
| Mode | What the kernel does | What you see in logs | Command |
|---|---|---|---|
| Enforce | Blocks anything the profile does not allow and logs it | apparmor=”DENIED” | aa-enforce |
| Complain | Allows the action and logs what enforce mode would have blocked | apparmor=”ALLOWED” | aa-complain |
| Unconfined | No profile is attached, so nothing is checked | Nothing | aa-disable |
| Audit flag | Also logs the accesses that are allowed | apparmor=”AUDIT” | audit keyword in the rule |
Complain mode is the learning phase. Enforce mode is the finish line. Skipping the first to reach the second is the fastest way to take your own site offline.
Prerequisites and Versions
You need a disposable Ubuntu machine. A small cloud VM or a local virtual machine works well, and a container does not, because AppArmor profiles load into the host kernel and most container runtimes restrict that. Do not run this first on a production host. Profiles that are slightly wrong cause outages that look like random permission errors.
| Component | Version or requirement | Notes |
|---|---|---|
| Ubuntu Server | 26.04 LTS (Resolute Raccoon) | 26.10 works the same way but is supported only until July 2027 |
| nginx | 1.28.2 from the Ubuntu repository | The packaged build matches the paths in the profile |
| apparmor-utils | Package from the Ubuntu repository | Provides aa-genprof, aa-logprof, aa-complain and aa-enforce |
| auditd | Package from the Ubuntu repository | Writes AppArmor events to /var/log/audit/audit.log |
| Python 3 | The version Ubuntu ships | Runs a tiny demo backend |
| Access | A user with sudo | Needed for every profile command |
| Time | About 55 minutes | Most of it is the logging phase |
The profile targets the packaged nginx. If you build nginx from source or install the upstream repository build, binary paths and module locations change, and you should regenerate the profile instead of copying this one. Apache admins can follow the same method with different paths, and our Nginx vs Apache comparison helps if you are still choosing a web server.
The terminal output in this guide is representative. Process IDs, timestamps, the number of loaded profiles and some paths will differ on your machine. Trust your own logs over any sample.
Steps 1 to 3: Check AppArmor, Install the Tools and Build a Test Site
Step 1. Confirm AppArmor is active. Start by asking the kernel and the service manager. The first command should print Y, and the second lists loaded profiles and the processes they confine.
cat /sys/module/apparmor/parameters/enabled
sudo aa-status
sudo systemctl status apparmor --no-pager
Y
apparmor module is loaded.
N profiles are loaded.
N profiles are in enforce mode.
0 profiles are in complain mode.
...
Active: active (exited) since ...
If the first command prints N or the file does not exist, AppArmor is disabled in the kernel command line or you are inside a container. Fix that before going further, because nothing below works without it. The troubleshooting section covers the usual causes.
Step 2. Install the toolchain. The apparmor package that provides aa-status is already present on Ubuntu Server. You add the profile-building utilities, auditd for clean event logs, nginx itself and Python for the demo backend.
sudo apt update
sudo apt install -y apparmor-utils auditd nginx python3
nginx -v
sudo systemctl is-active auditd nginx
nginx version: nginx/1.28.2 (Ubuntu)
active
active
Why install auditd on a server that already has the systemd journal? The profile tools parse audit-format lines, and auditd writes them to a predictable file. Without it, kernel messages land in the journal or syslog depending on your setup, and aa-logprof may report that it found nothing to review.
Step 3. Build a site worth protecting. You need static content, a reverse-proxied API and a systemd service behind it. That mix touches the file, network and signal rules you care about. Create the directories and a landing page first.
sudo mkdir -p /var/www/demo /opt/demo-api
echo '<h1>AppArmor demo</h1>' | sudo tee /var/www/demo/index.html
Create the backend at /opt/demo-api/server.py. It answers every GET request with a small JSON body on the loopback interface.
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({"status": "ok", "path": self.path}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(("127.0.0.1", 3000), Handler).serve_forever()
Wrap it in a service at /etc/systemd/system/demo-api.service. DynamicUser gives it a throwaway unprivileged account so the backend does not run as root.
[Unit]
Description=Demo API for the AppArmor tutorial
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/demo-api/server.py
DynamicUser=yes
Restart=on-failure
[Install]
WantedBy=multi-user.target
Now the nginx site. Save this as /etc/nginx/sites-available/demo. The nginx documentation covers every directive if you want to extend it.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /var/www/demo;
index index.html;
location / {
try_files $uri $uri/ =404;
}
location /api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
sudo systemctl daemon-reload
sudo systemctl enable --now demo-api
sudo ln -s /etc/nginx/sites-available/demo /etc/nginx/sites-enabled/demo
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
curl -i http://127.0.0.1/api/hello
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
HTTP/1.1 200 OK
Server: nginx/1.28.2 (Ubuntu)
Content-Type: application/json
Content-Length: 34
{"status": "ok", "path": "/hello"}
The trailing slash on proxy_pass strips the /api prefix, which is why the backend sees /hello. You now have a working site and no profile. Time to prove that.
Steps 4 and 5: Baseline the Unconfined Server and Generate a First Profile
Step 4. Record the baseline. Every process exposes its AppArmor label in /proc. The oldest nginx process is the master, so ask for that one.
sudo cat /proc/$(pgrep -o -x nginx)/attr/current
ps -eZ | grep nginx
unconfined
unconfined 1423 ? 00:00:00 nginx
unconfined 1424 ? 00:00:00 nginx
The word unconfined is the finding. Ubuntu ships profiles for a long list of packages and tools, but none for the nginx you just installed. The web server that faces the internet has the full power of its user and root-owned master process, and that is a common state for third-party daemons on any distribution.
Step 5. Generate the first draft with aa-genprof. Ubuntu’s server documentation points admins to aa-genprof for new profiles. It creates a stub profile, puts it in complain mode and then waits while you exercise the program.
sudo aa-genprof /usr/sbin/nginx
Writing updated profile for /usr/sbin/nginx.
Setting /usr/sbin/nginx to complain mode.
Profiling: /usr/sbin/nginx
Please start the application to be profiled in
another window and exercise its functionality now.
Once completed, select the "Scan" option below in
order to scan the system logs for AppArmor events.
[(S)can system log for AppArmor events] / (F)inish
Leave this terminal waiting. Open a second terminal for the next phase. If you only want the stub and plan to do the log review separately, press F now and run sudo aa-autodep /usr/sbin/nginx instead. Both routes write /etc/apparmor.d/usr.sbin.nginx.
Look at the generated file. It is tiny, and the exact header lines vary with your AppArmor version.
abi <abi/4.0>,
include <tunables/global>
/usr/sbin/nginx flags=(complain) {
include <abstractions/base>
/usr/sbin/nginx mr,
}
Rename the profile so logs show a readable name. Change the opening line to profile nginx /usr/sbin/nginx flags=(complain) {. The attachment path stays the same, and from now on every log line says profile="nginx" instead of a path.
Steps 6 and 7: Load the Profile in Complain Mode and Exercise Every Code Path
Step 6. Validate and load the profile. The parser can check syntax without touching the kernel. Run that first, then load for real. The -r flag replaces a profile that is already loaded.
sudo apparmor_parser -QT /etc/apparmor.d/usr.sbin.nginx
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
sudo systemctl restart nginx
sudo cat /proc/$(pgrep -o -x nginx)/attr/current
nginx (complain)
The restart matters. Loading a profile does not change processes that are already running. Only a process that starts after the load picks up the attachment. Skipping the restart is the number one reason people believe a profile is broken when it is simply not applied.
Step 7. Exercise everything nginx does. Complain mode only records what actually happens. If you never trigger log rotation, TLS renewal or a reload, those accesses never appear, and enforce mode will break them later. Save this as exercise-nginx.sh and run it.
#!/usr/bin/env bash
set -u
base="http://127.0.0.1"
curl -s -o /dev/null -w '%{http_code} /\n' "$base/"
curl -s -o /dev/null -w '%{http_code} /api/hello\n' "$base/api/hello"
curl -s -o /dev/null -w '%{http_code} /missing\n' "$base/missing"
curl -s -o /dev/null -w '%{http_code} HEAD /\n' -I "$base/"
sudo nginx -t
sudo systemctl reload nginx
sudo systemctl restart nginx
sudo logrotate -f /etc/logrotate.d/nginx
sudo systemctl stop nginx
sudo systemctl start nginx
curl -s -o /dev/null -w '%{http_code} after restart\n' "$base/"
bash exercise-nginx.sh
200 /
200 /api/hello
404 /missing
200 HEAD /
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
200 after restart
If your real server also renews TLS certificates, runs a cron job that touches nginx files or loads dynamic modules, trigger those too. A good rule is to leave complain mode running for at least a full day of real traffic before you consider enforcing. A tutorial VM can compress that to minutes, but production cannot.
Now read what the kernel logged. Each line names the operation, the path and the permission mask nginx wanted.
sudo grep 'profile="nginx"' /var/log/audit/audit.log | tail -n 3
sudo grep 'profile="nginx"' /var/log/audit/audit.log | grep -o 'name="[^"]*"' | sort | uniq -c | sort -rn | head -n 12
type=AVC msg=audit(1793210000.123:456): apparmor="ALLOWED" operation="open" class="file" profile="nginx" name="/etc/nginx/mime.types" pid=1424 comm="nginx" requested_mask="r" denied_mask="r" fsuid=0 ouid=0
type=AVC msg=audit(1793210000.456:457): apparmor="ALLOWED" operation="open" class="file" profile="nginx" name="/var/log/nginx/access.log" pid=1424 comm="nginx" requested_mask="wc" denied_mask="wc" fsuid=0 ouid=0
type=AVC msg=audit(1793210001.001:458): apparmor="ALLOWED" operation="capable" class="cap" profile="nginx" pid=1424 comm="nginx" capability=6 capname="setgid"
38 name="/etc/nginx/nginx.conf"
21 name="/var/www/demo/index.html"
12 name="/var/log/nginx/access.log"
9 name="/run/nginx.pid"
6 name="/var/lib/nginx/proxy"
Read the fields left to right. operation says what nginx tried, name is the path, requested_mask is the access it wanted and fsuid shows which user it acted as. Worker processes show fsuid=33, the www-data account on Ubuntu, while the master shows fsuid=0.
Treat the log as a to-do list. Every distinct name value is either a rule you need or a surprise worth investigating. If you see nginx touching a path you did not expect, such as a home directory or a temp file outside /var/lib/nginx, find out why before you allow it. Profiling often surfaces odd behavior in modules and third-party configs that nobody remembered adding.
Steps 8 and 9: Turn Logs Into Rules and Tighten Them by Hand
Step 8. Review events with aa-logprof. This tool reads the log, groups events by profile and proposes rules one at a time. Run it in the second terminal, or return to the waiting aa-genprof session and press S. Both use the same engine.
sudo aa-logprof
Reading log entries from /var/log/audit/audit.log.
Updating AppArmor profiles in /etc/apparmor.d.
Complain-mode changes:
Profile: /usr/sbin/nginx
Path: /var/lib/nginx/proxy
New Mode: owner rw
Severity: unknown
[1 - owner /var/lib/nginx/proxy/** rw,]
2 - /var/lib/nginx/proxy/** rw,
(A)llow / [(D)eny] / (I)gnore / (G)lob / Glob with (E)xtension / (N)ew / Abo(r)t / (F)inish / (O)pen
The prompt wording shifts a little between versions, but the choices stay familiar. Allow accepts the proposed rule. Deny writes a deny rule. Glob widens the path, which you should do sparingly. Ignore skips the event. Finish writes the profile and exits.
Accept the narrow suggestions for log files, the pid file, nginx’s temp directories and the configuration tree. Be suspicious of any proposal that ends in /** at a high-level directory. Most of the time you can ignore a suggestion for something nginx does not need and let it fail later in a controlled way. Answer S to save, then F to finish.
Step 9. Rewrite the profile so a human can audit it. Generated profiles work, but they carry noise and broad globs. Replace the contents of /etc/apparmor.d/usr.sbin.nginx with the tightened version below. It keeps the profile in complain mode for now, and it adds explicit denies for the files an attacker wants first.
abi <abi/4.0>,
include <tunables/global>
profile nginx /usr/sbin/nginx flags=(complain) {
include <abstractions/base>
include <abstractions/nameservice>
include <abstractions/openssl>
capability chown,
capability dac_override,
capability net_bind_service,
capability setgid,
capability setuid,
network inet stream,
network inet6 stream,
network inet dgram,
network inet6 dgram,
signal (receive) peer=unconfined,
signal (send, receive) peer=nginx,
/usr/sbin/nginx mr,
/etc/nginx/** r,
/usr/share/nginx/** r,
/usr/lib/nginx/modules/*.so mr,
/var/www/demo/** r,
/var/log/nginx/ r,
/var/log/nginx/*.log w,
/run/nginx.pid rw,
/var/lib/nginx/{body,proxy,fastcgi,scgi,uwsgi}/ r,
/var/lib/nginx/{body,proxy,fastcgi,scgi,uwsgi}/** rw,
@{PROC}/sys/net/core/somaxconn r,
deny /home/** rwklx,
deny /root/** rwklx,
deny /boot/** rwklx,
deny /etc/shadow* rwklx,
deny /etc/ssh/** rwklx,
deny @{PROC}/*/mem rwklx,
deny network raw,
include if exists <local/usr.sbin.nginx>
}
Go through it once. The capabilities cover what nginx needs to bind port 80, drop privileges to www-data and create its temp directories. The network rules cover TCP and the UDP lookups a hostname in proxy_pass would trigger. The signal rules let systemd and logrotate talk to the master, and let nginx talk to its own workers. File rules name the web root precisely instead of granting /var/www/**.
The deny block deserves its own explanation, because each line targets something attackers reach for first:
deny /home/** rwklxanddeny /root/** rwklxprotect shell histories, SSH keys and cloud credentials that often sit in home directories.deny /etc/shadow* rwklxanddeny /etc/ssh/** rwklxkeep password hashes and host keys out of reach even if a bug gives nginx an arbitrary file read.deny @{PROC}/*/mem rwklxblocks reading or writing other processes’ memory through the proc filesystem.deny network rawstops raw sockets, which a compromised process would need for packet crafting or sniffing.
None of these denies is strictly required, since the allow list already omits those paths. They are belt and braces. If a future edit adds a broad glob by mistake, the explicit deny still wins, because deny rules take precedence over allow rules.
The last line pulls in an optional local override file. If the file does not exist, nothing happens. Use it for per-host additions, so you never edit the main profile for a one-off path. That habit lines up with the checklist style of the CIS Benchmarks server hardening guide, where repeatable, reviewable configuration beats hand edits on live servers.
sudo mkdir -p /etc/apparmor.d/local
sudo touch /etc/apparmor.d/local/usr.sbin.nginx
sudo apparmor_parser -QT /etc/apparmor.d/usr.sbin.nginx && echo "syntax ok"
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
sudo systemctl restart nginx
bash exercise-nginx.sh
Run the exercise script again with the hand-written profile loaded in complain mode, then check the log for any fresh ALLOWED lines you did not cover. Every new path means a missing rule. When a full run produces no new entries, the profile is ready to enforce.
Steps 10 and 11: Switch to Enforce Mode and Prove the Block
Step 10. Enforce the profile. One command flips the mode. It removes the complain flag from the profile and reloads it, and then you restart nginx so every process starts under the enforced label.
sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx
sudo systemctl restart nginx
sudo cat /proc/$(pgrep -o -x nginx)/attr/current
sudo aa-status | grep -i nginx
bash exercise-nginx.sh
Setting /etc/apparmor.d/usr.sbin.nginx to enforce mode.
nginx (enforce)
nginx
/usr/sbin/nginx (1423) nginx
/usr/sbin/nginx (1424) nginx
200 /
200 /api/hello
404 /missing
200 HEAD /
200 after restart
The label now reads nginx (enforce) and the exercise script passes, which means your profile is complete for ordinary traffic. A passing run is necessary, but it proves nothing about security yet. A profile that allows everything would pass too. You need to watch it refuse something.
Step 11. Simulate a path traversal leak. Misconfigured alias and root directives are a classic way to expose files outside the web root. Create a file that sits outside every allowed path but remains world-readable, which is exactly the kind of file Unix permissions alone will not protect.
sudo mkdir -p /srv/secret
echo 'internal-only' | sudo tee /srv/secret/notes.txt
ls -l /srv/secret/notes.txt
Then add a deliberately careless location to the server block in /etc/nginx/sites-available/demo. Do this on a lab VM only, and remove it afterwards.
location /leak/ {
alias /srv/secret/;
}
sudo nginx -t && sudo systemctl reload nginx
curl -i http://127.0.0.1/leak/notes.txt
HTTP/1.1 403 Forbidden
Server: nginx/1.28.2 (Ubuntu)
Content-Type: text/html
The file is world-readable and nginx’s configuration points straight at it, yet the response is 403. Check why in the two logs.
sudo tail -n 1 /var/log/nginx/error.log
sudo grep 'apparmor="DENIED"' /var/log/audit/audit.log | grep notes.txt | tail -n 1
2026/10/28 19:12:40 [crit] 1431#1431: *7 open() "/srv/secret/notes.txt" failed (13: Permission denied), client: 127.0.0.1, server: _, request: "GET /leak/notes.txt HTTP/1.1", host: "127.0.0.1"
type=AVC msg=audit(1793213560.210:512): apparmor="DENIED" operation="open" class="file" profile="nginx" name="/srv/secret/notes.txt" pid=1431 comm="nginx" requested_mask="r" denied_mask="r" fsuid=33 ouid=0
The kernel logged DENIED against profile nginx for a path that nginx has no rule for. For contrast, flip the profile back to complain mode and repeat the request. The same URL now returns the file, and the log shows ALLOWED.
sudo aa-complain /etc/apparmor.d/usr.sbin.nginx
curl -s http://127.0.0.1/leak/notes.txt
sudo aa-enforce /etc/apparmor.d/usr.sbin.nginx
Setting /etc/apparmor.d/usr.sbin.nginx to complain mode.
internal-only
Setting /etc/apparmor.d/usr.sbin.nginx to enforce mode.
That before-and-after is the whole value of the exercise. The vulnerable configuration did not change between the two requests, and only the profile did. Clean up now: delete the /leak/ block from the site file, reload nginx and remove /srv/secret.
sudo nano /etc/nginx/sites-available/demo
sudo nginx -t && sudo systemctl reload nginx
sudo rm -r /srv/secret
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/leak/notes.txt
Steps 12 and 13: Survive Reboots and Updates, Then Automate Verification
Step 12. Make the profile permanent and test log rotation. The apparmor service loads every profile in /etc/apparmor.d at boot, so your file persists as long as the service is enabled. Confirm that, reboot and check the label again. A profile that works until the next reboot is a trap.
sudo systemctl is-enabled apparmor
sudo reboot
enabled
sudo cat /proc/$(pgrep -o -x nginx)/attr/current
sudo logrotate -f /etc/logrotate.d/nginx
ls -l /var/log/nginx/
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/
tail -n 2 /var/log/nginx/access.log
nginx (enforce)
-rw-r----- 1 www-data adm 0 Oct 28 19:20 access.log
-rw-r----- 1 www-data adm 412 Oct 28 19:19 access.log.1
200
127.0.0.1 - - [28/Oct/2026:19:20:31 +0000] "GET / HTTP/1.1" 200 25 "-" "curl/8.x"
Log rotation is the part people forget. Logrotate runs unconfined, sends a signal to the confined nginx master and expects it to reopen its log files. If the signal rule were missing, nginx would keep writing to the rotated file and your new log would stay empty. The new access.log filling with fresh requests confirms the rule works.
Ubuntu’s documentation warns that editing profiles it ships under /etc can trigger package-update conffile prompts, where an upgrade may ask about or overwrite your custom rules. Your nginx profile is not a shipped file, so the risk is low here. The override file still gives you a clean place for host-specific additions, so edit that instead of the main profile.
echo '/var/www/other-site/** r,' | sudo tee -a /etc/apparmor.d/local/usr.sbin.nginx
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
sudo systemctl reload nginx
Step 13. Automate a confinement check. Profiles rot. Someone disables one during an outage, a new nginx build changes paths or a deploy script restarts the service from a different binary. A short script and a timer catch all three. Save this as /usr/local/sbin/check-nginx-confinement.sh and make it executable.
#!/usr/bin/env bash
set -euo pipefail
fail=0
master="$(pgrep -o -x nginx || true)"
if [[ -z "$master" ]]; then
echo "FAIL: nginx is not running"
exit 2
fi
for pid in $(pgrep -x nginx); do
label="$(cat "/proc/${pid}/attr/current")"
if [[ "$label" != "nginx (enforce)" ]]; then
echo "FAIL: pid ${pid} has label '${label}', expected 'nginx (enforce)'"
fail=1
fi
done
if [[ -r /var/log/audit/audit.log ]]; then
denied="$(grep -c 'apparmor="DENIED".*profile="nginx"' /var/log/audit/audit.log || true)"
else
denied="$(journalctl -k --since '-1 hour' --no-pager -q -g 'apparmor="DENIED".*profile="nginx"' | wc -l)"
fi
echo "INFO: DENIED events for nginx in the scanned log: ${denied}"
exit "$fail"
sudo chmod 750 /usr/local/sbin/check-nginx-confinement.sh
sudo /usr/local/sbin/check-nginx-confinement.sh; echo "exit=$?"
INFO: DENIED events for nginx in the scanned log: 3
exit=0
The script exits non-zero when any nginx process is not in enforce mode. A nonzero exit is what your scheduler or monitoring agent reacts to. The denied count is informational, since the three events from the leak test are expected, and a sudden jump is the signal worth alerting on in a real setup.
Common AppArmor Pitfalls That Break Profiles
Most failed AppArmor rollouts trace back to the same handful of mistakes. Each one is cheap to avoid once you know it exists.
- Loading a profile without restarting the service. Running processes keep the label they started with. After any load or mode change, restart the daemon and read
/proc/PID/attr/currentto confirm the new label. - Writing rules for symlink paths. AppArmor checks the resolved path, not the link. Use
/run/nginx.pidrather than/var/run/nginx.pid. Certbot-style layouts bite harder, because/etc/letsencrypt/live/holds links into/etc/letsencrypt/archive/, and you need read access to the archive files. - Assuming complain mode blocks nothing. Explicit
denyrules apply in complain mode too, per Ubuntu’s documentation. A deny rule you added early can break a service while you think you are only observing. - Accepting wide globs during aa-logprof. Pressing Allow on
/var/**or/etc/** rwturns the profile into decoration. Narrow every suggestion to the directory that matters. - Profiling with incomplete traffic. Only exercised code paths reach the log. Renewals, cron jobs, reloads, plugin loads and error pages all need a trigger before you enforce.
- Editing shipped profiles in place. Ubuntu warns that local edits to packaged profiles can collide with updates. Use files under
/etc/apparmor.d/local/where the shipped profile includes them. - Forgetting IPv6 and DNS. A profile with only
network inet streamworks until a hostname lookup or an IPv6 client shows up. Add the datagram and inet6 variants you actually need. - Expecting host profiles to follow containers. A containerized nginx gets the container runtime’s profile, not yours. Treat container confinement as a separate task.
A ninth, quieter mistake deserves a mention: treating the profile as finished. Software updates add files and modules. Re-run the complain-mode loop on a staging copy after each nginx upgrade and compare the generated rules against what you already have.
AppArmor Troubleshooting: 10 Problems and Fixes
When something breaks, read the audit log first. The rule you need is almost always written in the denied line. This table maps common symptoms to the log field that points at the fix.
| Symptom | Log field to read | Likely fix |
|---|---|---|
| nginx will not start | operation=”open” or operation=”capable” | Add the file path or capability rule |
| 502 Bad Gateway on a proxied path | operation=”connect” | Add the network rule or the socket path |
| Reload or rotation does nothing | operation=”signal” | Add a signal receive rule for the sender |
| TLS certificate cannot load | name=”/etc/letsencrypt/archive/…” | Allow read on the archive path |
| No events at all | auditctl -s lost counter | Check auditd and the audit backlog |
- The parser reports a syntax error. Almost always a missing trailing comma on a rule or a missing closing brace. Run
sudo apparmor_parser -QT fileand fix the line it names. Every rule except include lines ends with a comma. - nginx fails with bind() to 0.0.0.0:80 failed (13: Permission denied). The profile lacks
capability net_bind_service, or the network rules are missing. Check the log foroperation="capable"and add the named capability. - nginx fails with open() “/var/log/nginx/error.log” failed (13). Add the log path with write access. Remember the directory itself needs a read rule, which is why the profile has
/var/log/nginx/ r. - aa-logprof says there is nothing to review. The tool reads the default log location. If events go to the journal or to another file, pass it explicitly with
sudo aa-logprof -f /var/log/audit/audit.log, and confirm auditd is running. - aa-status complains that the apparmor filesystem is not mounted. The module is off or unavailable. Check
cat /proc/cmdlineforapparmor=0, runsudo systemctl enable --now apparmorand remember that many containers and WSL setups cannot use it. - The process still shows unconfined after loading. Either it started before the load or the attachment path does not match the real binary. Restart the service, then compare
readlink -f $(which nginx)to the attachment line. - Proxied requests return 502 after enforcing. Look for a denied
connectoperation. The profile needs the rightnetworkrule, or for a unix socket backend a rule for the socket path. - Reload works but logrotate leaves nginx writing to the old file. The signal rule for unconfined senders is missing or misspelled. The denied line shows
class="signal"and the signal name. - TLS certificates fail to load after switching to Let’s Encrypt. Allow read on
/etc/letsencrypt/live/**and on/etc/letsencrypt/archive/**, since the live files are links. - Events seem to be missing. Run
sudo auditctl -sand look at thelostcounter. A nonzero value means the audit queue overflowed, so raise the backlog limit withsudo auditctl -b 8192and retry.
If you still cannot find the cause, put the profile back in complain mode for ten minutes. Complain mode never blocks traffic, except through explicit deny rules, so it is the safe place to gather evidence while the site stays up.
Advanced AppArmor Tips for Real Deployments
Once the basic profile holds, a few techniques make it fit production work better.
Pair AppArmor With systemd Sandboxing
A systemd unit can request a specific profile with AppArmorProfile=nginx and add its own limits such as NoNewPrivileges=yes and ProtectSystem=strict. Put these in a drop-in under /etc/systemd/system/nginx.service.d/. The two mechanisms overlap on purpose. If one is misconfigured, the other still limits damage.
[Service]
AppArmorProfile=nginx
NoNewPrivileges=yes
ProtectHome=yes
PrivateTmp=yes
Test the drop-in on staging first. ProtectSystem=strict makes most of the filesystem read-only for the service, so you must list writable paths in ReadWritePaths= or nginx fails to start.
Other ideas worth trying once the basics are stable:
- Test a profile on one command.
aa-exec -p nginx -- /usr/sbin/nginx -truns a single command under a named profile without touching the service. - Use audit rules for sensitive paths. Prefix a rule with
audit, for exampleaudit /etc/nginx/** w,, to log allowed writes to configuration. That makes silent changes visible. - Split risky helpers into child profiles. PHP-FPM or CGI helpers deserve their own profile, using hats or separate attachments, so a compromised script does not inherit the web server’s reach.
- Pin the ABI line. The
abistatement keeps rule semantics stable when the userspace tools update. - Back up the policy. Keep
/etc/apparmor.d/usr.sbin.nginxand the local override in version control and deploy them with your configuration management tool.
Send the denials somewhere a human will see them. A single DENIED line from a profile in enforce mode is usually a missing rule after an upgrade, but a burst of them against paths like /etc/shadow or /root/.ssh looks like an attack in progress. Forward /var/log/audit/audit.log to your log pipeline or SIEM, and write one alert for the burst pattern and another for any process that suddenly loses its enforce label.
Scale changes the picture. In Kubernetes, recent releases accept an AppArmor profile in the pod security context, and the same complain-then-enforce habit applies on every node. Our Kubernetes security hardening walkthrough shows where node-level controls like this sit among the other layers.
Finally, remember what a profile does not do. AppArmor limits what a process can reach, but it does not inspect content or detect malware that lives inside allowed paths. Combine it with scanning, such as the on-access approach in a ClamAV setup on Linux, so uploaded files get checked as well as boxed in.
AppArmor vs SELinux: Which Model Fits Your Servers
Readers often search for AppArmor vs SELinux before they choose a distribution. The short version: both enforce mandatory access control in the kernel, but they describe policy differently. AppArmor ties rules to file paths. SELinux ties rules to labels stored on files and processes.
| Aspect | AppArmor | SELinux |
|---|---|---|
| Policy model | Path-based profiles per program | Label-based policy across the whole system |
| Typical home | Ubuntu and SUSE-family systems | Fedora and Red Hat-family systems |
| Learning curve | Readable text rules, quick for one service | Steeper, more expressive for complex systems |
| Profile builders | aa-genprof, aa-logprof | audit2allow and related tools |
| Renaming a file | Can change which rule applies | Label stays with the file |
| Best fit | Confining a handful of exposed services fast | Uniform policy across large fleets |
Neither is stronger in the abstract. The better system is the one your team will actually keep enabled. On Ubuntu that is AppArmor, because the distribution ships and updates its profiles, tooling and documentation around it. If you are weighing distributions for a mixed fleet, the differences in tooling and defaults show up in our Ubuntu vs Fedora comparison.
Path-based rules do have one real trade-off. If an attacker can create a hard link or bind mount that presents a protected file under an allowed path, careful profiles must account for it. AppArmor handles links with the l permission and with its own mediation, which is why the deny rules in this profile include l. SELinux sidesteps the issue because the label does not change when the path does.
Avoid stacking both on one host. Mixing two policy languages adds confusion faster than it adds depth. Pick the one that matches your distribution and invest in learning its tooling well.
The Complete Working Project: Files, Layout and Deployment
Everything above condenses into a small repository you can version and redeploy. It lets a new server reach the same confined state in under a minute, which is how a security control survives staff changes and rebuilds. For related hardening topics, the cybersecurity category collects the rest of the series.
nginx-apparmor/
apparmor/
usr.sbin.nginx # the profile from Step 9
local/usr.sbin.nginx # per-host additions, empty by default
nginx/
demo # the site from Step 3
scripts/
exercise-nginx.sh # Step 7
check-nginx-confinement.sh # Step 13
deploy.sh
systemd/
nginx-confinement-check.service
nginx-confinement-check.timer
The deploy script copies the files into place, validates syntax before touching the kernel, loads the profile in whatever mode it carries and restarts nginx. It stops on the first error, so a typo never reaches a live process.
#!/usr/bin/env bash
set -euo pipefail
repo="$(cd "$(dirname "$0")/.." && pwd)"
sudo install -m 0644 "$repo/apparmor/usr.sbin.nginx" /etc/apparmor.d/usr.sbin.nginx
sudo mkdir -p /etc/apparmor.d/local
sudo install -m 0644 "$repo/apparmor/local/usr.sbin.nginx" /etc/apparmor.d/local/usr.sbin.nginx
sudo apparmor_parser -QT /etc/apparmor.d/usr.sbin.nginx
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
sudo install -m 0644 "$repo/nginx/demo" /etc/nginx/sites-available/demo
sudo ln -sf /etc/nginx/sites-available/demo /etc/nginx/sites-enabled/demo
sudo nginx -t
sudo systemctl restart nginx
sudo install -m 0750 "$repo/scripts/check-nginx-confinement.sh" /usr/local/sbin/check-nginx-confinement.sh
sudo install -m 0644 "$repo/systemd/nginx-confinement-check."* /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nginx-confinement-check.timer
sudo /usr/local/sbin/check-nginx-confinement.sh
The two systemd units run the check every hour. A failing run marks the unit failed, which any monitoring that watches systemd state will report.
# nginx-confinement-check.service
[Unit]
Description=Verify nginx runs under its enforced AppArmor profile
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/check-nginx-confinement.sh
# nginx-confinement-check.timer
[Unit]
Description=Hourly nginx confinement check
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Persistent=true
[Install]
WantedBy=timers.target
Split the two units into separate files when you copy them. Check the timer with systemctl list-timers and trigger a manual run with sudo systemctl start nginx-confinement-check.service. To see the alert path work, put the profile in complain mode, run the service and watch it fail with the label message from Step 13.
Ship the profile mode as part of your release notes. A repository where flags=(complain) sneaks into the main branch quietly turns enforcement off, and a one-line review check catches it. Grep for that string in your CI pipeline and fail the build when it appears on the production branch.
if grep -rn 'flags=(complain)' apparmor/usr.sbin.nginx; then
echo "profile is still in complain mode"
exit 1
fi
Plan your next profiles in order of exposure. Anything that parses untrusted input from the internet comes first: the web server, the reverse proxy, mail transfer agents and application runtimes. Internal batch jobs can wait. Repeat the same loop of generate, exercise, review, tighten and enforce, and keep each profile in its own file so a mistake in one never takes down another service.
Budget real calendar time for production. The 55 minutes in this guide covers a lab with synthetic traffic. On a live server, leave complain mode running through at least one full business cycle, including the monthly jobs and the certificate renewal, then enforce during a quiet window with the previous profile mode one command away.
Frequently Asked Questions About AppArmor on Ubuntu
Is AppArmor enabled by default on Ubuntu?
Yes. AppArmor is Ubuntu’s default mandatory access control system, and the kernel module and service are active on a standard install. Ubuntu ships profiles for many packages, but not for every program you install yourself, which is why a service like nginx can run unconfined until you write its profile.
What is the difference between complain and enforce mode?
Enforce mode blocks any access the profile does not allow and logs a DENIED event. Complain mode allows the access and logs it as ALLOWED so you can learn what the program needs. Explicit deny rules still apply in complain mode.
Can AppArmor stop a process that runs as root?
Yes, for the confined process. The kernel checks the profile in addition to normal file permissions, so root inside a confined program still needs a rule for each file, capability and network action. A root attacker who escapes the confined process, or who changes the profile itself, is a different threat and needs separate controls.
Does AppArmor replace a firewall or Fail2ban?
No. A firewall controls which packets reach the machine, Fail2ban reacts to repeated failed logins, and AppArmor limits what a running program may do after it is reachable. They defend different stages of an attack, and the strongest setups run all three.
How do I disable one profile without turning AppArmor off?
Use sudo aa-disable /etc/apparmor.d/usr.sbin.nginx for that single profile, then restart the service. For a temporary test, aa-complain is the gentler choice because it keeps logging. Avoid disabling the AppArmor service itself to fix one application.
Do I need Ubuntu 26.10 for this tutorial?
No. Ubuntu 26.04 LTS is the better base for a long-lived server, and every command here works on it. Ubuntu 26.10 is an interim release supported until July 2027, so choose it only when you need its newer features and accept the shorter support window.
Will a profile slow nginx down?
Profiles are compiled and checked inside the kernel, and for a typical web workload the cost is small. Measure rather than guess, though. Run your normal load test with the profile in complain mode and again in enforce mode, and compare latency and throughput on your own hardware.
Can I use the same profile for Apache or a container?
The method transfers, but the profile does not. Apache has different binary paths, modules and log locations, so regenerate the profile with aa-genprof. A containerized web server runs under the container runtime’s profile, so you would write or attach a profile through the runtime instead.





