Applying AppArmor to real services: the built-in profiles for Nginx, Apache, and OpenSSH, customizing web roots and SSL certificates, MySQL and PostgreSQL database profiles, Node and Python runtimes, and the log rotation traps.

In episode 8, you gained the tools to write portable profiles: variables, tunables, and abstractions. Now it's time to step out of the practice sessions and lock down the services actually running on your servers. Web servers and databases are the first targets almost always attacked when an instance is internet-facing — and yet they're exactly the easiest to secure, because the community has already prepared built-in profiles.
Ubuntu and Debian ship AppArmor profiles for popular services inside their packages. Some are active right after installation; others are available but waiting to be activated. See what's on your system:
ls /etc/apparmor.d/ | grep -E "nginx|apache|sshd|mysqld|postgres"Example filenames that appear:
usr.sbin.apache2
usr.sbin.mysqld
usr.sbin.nginx
usr.sbin.sshd
usr.lib.postgresql.14.bin.postgresNotice the pattern: the filename is the executable path with / replaced by dots. When Nginx runs from /usr/sbin/nginx, the kernel loads the profile usr.sbin.nginx. This relationship determines the filename — not the service name.
Note
Profile status can be seen with aa-status: a (enforce) line means actively denying, (complain) means only logging, and (attach_disconnected) is a special mode often used by container profiles. A built-in profile not listed in aa-status isn't active — you need to activate it explicitly.
A built-in profile is usually enough for a standard installation. Problems appear when you deviate from the defaults: the web root moves, certificates go to Let's Encrypt, or logs are redirected to a custom directory. A common customization example:
#include <tunables/global>
/usr/sbin/nginx flags=(enforce) {
#include <abstractions/base>
#include <abstractions/nameservice>
#include <abstractions/ssl_certs>
network inet tcp,
network inet6 tcp,
/usr/sbin/nginx mr,
/etc/nginx/** r,
/var/www/app/** r,
/var/log/nginx/*.log w,
# Let's Encrypt certificates for custom domains
/etc/letsencrypt/live/**.pem r,
/etc/letsencrypt/archive/** r,
}Three customizations you must pay attention to:
/var/www/app/** r — your web root isn't the default /var/www/html./etc/letsencrypt/** — certbot certificates live outside /etc/ssl./var/log/nginx/*.log w — the profile must be able to write the logs that logrotate newly opens.Don't forget to reload after editing, following the pattern from episode 7:
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.nginxusr.sbin.sshd is the simplest profile worth activating: SSH is a server's main entry door, and shrinking what the sshd process can do after being compromised is extremely valuable. The built-in profile allows authentication, session opening, and basic operations — while most other system functions stay closed. If you use key-based authentication with a custom key location, make sure a rule for @{HOME}/.ssh/** r is present in the profile.
Databases store your most valuable data, so their profiles must be genuinely locked down. usr.sbin.mysqld allows access to the data directory and local sockets:
/var/lib/mysql/** rw,
/var/run/mysqld/** rw,
/var/log/mysql/*.log rw,Pay special attention to log rotation. Logrotate closes the old log file and creates a new one. If the profile only allows a specific file path (for example /var/log/mysql/error.log), the freshly rotated file will trigger a denial. Use a wildcard for the whole directory, and make sure there's a rule for the directory itself:
/var/log/mysql/ rw,
/var/log/mysql/** rw,PostgreSQL has a similar pattern through the usr.lib.postgresql.*.bin.postgres profile. If you change the data directory or port, update the profile — a connect denial to a socket at a custom path is the symptom that appears most often.
Runtimes like Node and Python are their own challenge: they load modules dynamically, do JIT compilation, and open many small files at runtime. A profile that's too tight produces denial after denial; a profile that's too loose is useless.
A realistic approach for an application on a server:
#include <tunables/global>
/usr/bin/node mr,
/opt/myapp/** r,
/opt/myapp/node_modules/** r,
/opt/myapp/.env r,
/var/log/myapp/*.log rw,
network inet tcp,
capability setgid,
capability setuid,Notice what's included and what isn't. The project paths are watched fully, while Node's internals are handled by abstractions/base or explicit rules. For applications with native extensions, JIT may need the m (memory map) right for loaded libraries — an operation="mmap" denial in the logs is the clue.
Tip
The staged strategy works best here: run the runtime in complain mode (aa-complain /etc/apparmor.d/...), let the application work for a few days, collect the denials that genuinely repeat with aa-logprof, then move to enforce. Complain mode turns the logs into a list of needs without blocking anything.
In episode 9 you've applied AppArmor to production services: recognizing the distribution built-in profiles for Nginx, Apache, and OpenSSH, customizing web roots and SSL certificates at custom locations, understanding the core MySQL and PostgreSQL rules, handling the dynamics of Node and Python runtimes, and avoiding the log rotation traps with the right wildcards.
Keys to take home:
All the profiles we've discussed so far lock down processes running directly on the host. But most modern workloads no longer run directly on the host — they run in containers. In episode 10, we move into Containers & Kubernetes: AppArmor integration with Docker and containerd, the docker-default profile, and securing pods in Kubernetes with annotations and native fields.