This lab builds an AppArmor profile for nginx from scratch, testing both legitimate access (must work) and malicious access (must be blocked). By the end you will have a production-style profile.
Objective
By the end of this lab, you can:
- Use aa-genprof to build a profile.
- Switch profiles between complain and enforce.
- Test that legitimate access works.
- Test that malicious access is blocked.
Architecture
You need a Debian-family host (Debian, Ubuntu) with:
- AppArmor installed and running.
- nginx installed.
- A test page in
/var/www/html/.
Tasks
Task 1: Verify AppArmor is active
sudo systemctl status apparmor
aa-status
Expected: AppArmor is loaded with several default profiles.
Task 2: Put nginx in complain mode
sudo aa-complain /usr/sbin/nginx
aa-status | grep nginx
Should show nginx (complain).
Task 3: Back up the shipped profile, then generate yours
Debian and Ubuntu ship an nginx profile. You are about to overwrite it, so take a copy first. Without this you cannot put the host back the way you found it.
sudo cp -a /etc/apparmor.d/usr.sbin.nginx \
/etc/apparmor.d/usr.sbin.nginx.orig
ls -l /etc/apparmor.d/usr.sbin.nginx*
If the file does not exist on your host, record that fact — cleanup will then mean deleting your profile rather than restoring one.
Start nginx:
sudo systemctl start nginx
Run the application and observe:
curl http://localhost/
Wait 30 seconds for aa-genprof to capture accesses (if running interactively). Or check the audit log:
sudo aa-logprof
Interactively review the captured accesses. Accept the defaults to build a minimal profile:
# /etc/apparmor.d/usr.sbin.nginx
#include <tunables/global>
/usr/sbin/nginx {
#include <abstractions/base>
#include <abstractions/nameservice>
#include <abstractions/openssl>
#include <abstractions/ssl_certs>
capability dac_override,
capability net_bind_service,
/etc/nginx/** r,
/var/log/nginx/** w,
/var/www/** r,
/run/nginx.pid w,
network inet stream,
network inet6 stream,
audit deny /etc/shadow* rw,
}
Save to /etc/apparmor.d/usr.sbin.nginx, then parse-check it
before loading. An #include of an abstraction that does not
exist on this system is a hard error, not a warning, and the
profile simply will not load:
# Which abstractions does this host actually ship?
ls /etc/apparmor.d/abstractions/
# Parse only - loads nothing, changes nothing
sudo apparmor_parser -Q /etc/apparmor.d/usr.sbin.nginx && echo "profile parses"
Guessing an abstraction name by the daemon’s name does not work:
there is no abstractions/nginx. apparmor_parser reports
Could not open 'abstractions/nginx' and refuses the whole file.
Task 4: Enforce the profile
sudo aa-enforce /usr/sbin/nginx
aa-status | grep nginx
Should show nginx (enforce).
Task 5: Test legitimate access
curl http://localhost/
Should succeed. nginx can read its config, logs, and web content per the profile.
Task 6: Test malicious access
Try to get nginx to read a file it should not:
echo "test" | sudo tee /etc/nginx/test.conf
# Restart nginx - the profile should deny reading this file
sudo systemctl restart nginx
Check the audit log:
sudo journalctl -k -n 20 | grep nginx
sudo dmesg | grep nginx
The denial should be logged but nginx should still serve content (because the file is not in the served path).
Task 7: Prove the profile blocks a write outside its allow-list
You will point nginx’s access log at a path under /etc/
that the profile denies, and watch AppArmor stop it.
Create the drop-in in the http context:
sudo tee /etc/nginx/conf.d/zz-lab-denial.conf >/dev/null <<'EOF'
# Valid http-context directive aimed at a denied path.
access_log /etc/shadow.lab-test;
EOF
Test the configuration without restarting anything:
sudo nginx -t
Expect a failure like:
nginx: [emerg] open() "/etc/shadow.lab-test" failed (13: Permission denied)
nginx: configuration file /etc/nginx/nginx.conf test failed
That is the correct, honest result. nginx does not carry
on serving with a log target it cannot open — it aborts.
An access denial on a log file is a startup failure, not a
silent no-op. This is the whole reason nginx -t exists:
you find out before you restart, while the running worker
processes are still up.
Confirm AppArmor is the reason, not file permissions:
sudo journalctl -k -n 50 | grep -i 'apparmor.*nginx'
sudo dmesg | grep -i 'apparmor.*shadow'
You should see apparmor="DENIED" operation="open" with
name="/etc/shadow.lab-test" and profile="/usr/sbin/nginx".
If you see nothing, check that you used audit deny and not
plain deny.
Now remove the drop-in and confirm the service is healthy again before moving on:
sudo rm -f /etc/nginx/conf.d/zz-lab-denial.conf
sudo nginx -t && sudo systemctl restart nginx
curl -sf http://localhost/ >/dev/null && echo 'nginx OK'
Never leave a failing config in place between tasks. The
next systemctl restart nginx from anyone else — a package
upgrade, a config-management run — would fail for reasons
that have nothing to do with them.
Task 8: Add deny rules for other sensitive paths
Edit /etc/apparmor.d/usr.sbin.nginx and extend the deny
block:
audit deny /etc/shadow* rw,
audit deny /etc/ssh/** rw,
audit deny /root/** rwx,
Reload the profile into the kernel and confirm it took:
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
aa-status | grep nginx
apparmor_parser -r replaces the profile for processes
started from now on and for already-running confined
processes. If the parser reports a syntax error the old
profile stays loaded — read the error, do not assume the
reload worked.
Task 9: Test that the profile blocks the SSH config
Same shape as Task 7, different target:
sudo tee /etc/nginx/conf.d/zz-lab-denial.conf >/dev/null <<'EOF'
access_log /etc/ssh/sshd_config.lab-test;
EOF
sudo nginx -t
sudo journalctl -k -n 50 | grep -i 'apparmor.*sshd_config'
Expect nginx -t to fail with permission denied and an
apparmor="DENIED" record naming the path.
Clean up immediately, as before:
sudo rm -f /etc/nginx/conf.d/zz-lab-denial.conf
sudo nginx -t && sudo systemctl restart nginx
curl -sf http://localhost/ >/dev/null && echo 'nginx OK'
Task 10: Document the profile
APPARMOR PROFILE
=================
Service: nginx
Profile: /etc/apparmor.d/usr.sbin.nginx
Mode: enforce
Generated: 2026-08-09
Capabilities:
- dac_override: needed to read /etc/nginx
- net_bind_service: bind to port 80
File access:
- /etc/nginx/** r: read configuration
- /var/log/nginx/** w: write logs
- /var/www/** r: read web content
- /run/nginx.pid w: write PID file
Network:
- TCP IPv4 and IPv6
Explicit denies (audit deny, so they are logged):
- /etc/shadow*
- /etc/ssh/**
- /root/**
Verification:
- curl http://localhost/ succeeds
- access_log /etc/shadow.lab-test: nginx -t fails,
apparmor="DENIED" logged
- access_log /etc/ssh/sshd_config.lab-test: nginx -t fails,
apparmor="DENIED" logged
Validation
- The profile is in
/etc/apparmor.d/usr.sbin.nginxandaa-statusshowsnginxin enforce mode. - The shipped profile is backed up at
/etc/apparmor.d/usr.sbin.nginx.orig. sudo nginx -tpasses andcurl -sf http://localhost/returns the test page.- Pointing a log at a denied path makes
nginx -tfail with(13: Permission denied)— not a silent success. - Each denial produced an
apparmor="DENIED"record in the kernel log. - No
zz-lab-denial.confremains in/etc/nginx/conf.d/.
Cleanup
Undo everything, in this order. The last two commands are the proof, not the hope.
# 1. Remove any lab drop-in and the Task 6 test file
sudo rm -f /etc/nginx/conf.d/zz-lab-denial.conf
sudo rm -f /etc/nginx/test.conf
# 2. Restore the distribution profile you backed up in Task 3
sudo cp -a /etc/apparmor.d/usr.sbin.nginx.orig \
/etc/apparmor.d/usr.sbin.nginx
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx
sudo aa-enforce /usr/sbin/nginx
# 3. Bring nginx back and verify
sudo nginx -t
sudo systemctl restart nginx
curl -sf http://localhost/ >/dev/null && echo 'nginx OK'
aa-status | grep nginx
The final aa-status must show nginx under enforce.
What you learned
- AppArmor profiles are built by observing accesses.
- complain mode captures; enforce mode blocks.
- Explicit deny rules harden the profile, but they are
audit-quiet unless written as
audit deny. - A denied file access is usually a startup failure, not a silent no-op. Confined services abort rather than continue half-configured.
- Test denials through a disposable drop-in, never by appending to a live site file — and never aim a write at a real credential file.
- Verification requires testing both legitimate and malicious access, and cleanup must restore the host to enforce mode with the service running.