Skip to content
Go back

AppArmor Profiles for Docker, Step by Step

By KingPin 15 min read
AppArmor Profiles for Docker, Step by Step
Contents

Your Containers Are Already Wearing a Jacket

If you run Docker on Ubuntu or Debian, every container you start is already confined by an AppArmor profile called docker-default. You never wrote it. You never loaded it. Docker generates it when the daemon starts and attaches it to each container. Most people never look at it, which means most people have no idea what it blocks or how to make it stricter.

My take: docker-default is a decent seatbelt with one big hole, which is that it lets a compromised container write almost anywhere in its own filesystem. A short custom profile, a dozen added deny lines on top of the defaults, closes that hole for an internet-facing container. There is one trap on the way that the usual guides skip: a plain deny rule ignores complain mode and fails silently, so you need audit deny. I ran every step below on an Ubuntu 22.04 host with Docker 29.8.0 and the official nginx:stable image, and the outputs shown are from that run. For the AppArmor versus SELinux debate, see AppArmor vs SELinux. This one is Docker only.

A scope note: AppArmor has to exist on the host. Ubuntu and Debian ship it. Fedora and RHEL use SELinux instead, so skip to the Common Questions if that is you.

Step 1: Check That It’s Actually On

Start with the daemon. Docker lists its security options in docker info:

Terminal window
docker info --format '{{json .SecurityOptions}}'

You want to see name=apparmor in that list. If it is missing, the host kernel has no AppArmor or it is disabled, and none of this post applies.

Next, ask AppArmor what it has loaded:

Terminal window
sudo aa-status

Docker’s docs show docker-default in the enforce list, with a line per container process like docker-default (6044). If you have containers running, you should see those entries.

Last, ask a single container which profile it got:

Terminal window
docker run -d --name web nginx:stable
docker inspect --format '{{.AppArmorProfile}}' web

That prints docker-default. Now try a privileged one:

Terminal window
docker run -d --name web-priv --privileged nginx:stable
docker inspect --format '{{.AppArmorProfile}}' web-priv

That prints unconfined. In the Docker source, a container’s explicit profile wins, then --privileged means unconfined, then everything else gets docker-default. Docker’s own docs list “Disables the default AppArmor profile” under what --privileged does. Keep that in mind: one lazy flag from a copy-pasted compose file strips the jacket off.

What docker-default Actually Blocks

The profile comes from a Go template in the moby/profiles repository (apparmor/template.go). Here are the parts worth knowing, copied from the current source:

network,
capability,
file,
umount,

Read those four lines carefully. file, means all file access is allowed by default. network, and capability, allow all networking and all capabilities the container already has. So docker-default is not an allow-list. It is a short allow-everything preamble followed by a list of specific denials. It works like a forklift with a few cones around the dangerous zones, with no fence around the warehouse.

AppArmor deny rules beat allow rules. That is why the cones work. Here are the cones:

deny @{PROC}/sysrq-trigger rwklx,
deny @{PROC}/kcore rwklx,
deny mount,
deny /sys/[^f]*/** wklx,
deny /sys/f[^s]*/** wklx,
deny /sys/fs/[^c]*/** wklx,
deny /sys/fs/c[^g]*/** wklx,
deny /sys/fs/cg[^r]*/** wklx,
deny /sys/firmware/** rwklx,
deny /sys/devices/virtual/powercap/** rwklx,
deny /sys/kernel/security/** rwklx,

In plain terms:

  1. No writing to /proc/sysrq-trigger, which is how you reboot a host with a file write. No touching /proc/kcore, a view of kernel memory.
  2. deny mount blocks the mount operation inside the container.
  3. The /sys block is a character-class trick. Those five patterns deny write, link, lock, and execute on everything under /sys except /sys/fs/cgroup. Reads stay allowed in most of /sys.
  4. /sys/firmware, /sys/devices/virtual/powercap, and /sys/kernel/security are denied for reads too.

The profile also writes out network denials for old socket families (deny network ax25, deny network appletalk, and friends), plus deny network alg and deny network vsock. It also restricts writes under /proc and /proc/sys, and limits signals and ptrace to peers in the same profile:

ptrace (trace,tracedby,read,readby) peer="docker-default",

So a container can debug its own processes but cannot trace the host or a neighbor container. The template changes between Docker releases, so compare against the source for your Docker version before you copy anything from a blog post. This one included.

Notice what is missing: nothing stops a container from writing to /etc, /usr, or /bin. If your web server gets popped, the attacker can drop a file anywhere the container’s user is allowed to write. That gap is the reason to write your own profile.

Step 2: Write a Profile for nginx

The plan is simple. Start from the docker-default rules, then add denials for places nginx has no business writing. Docker’s docs do the same with an nginx example, and it is a good first target because nginx writes to very few places.

Create the file at /etc/apparmor.d/docker-nginx. The top-level directory matters later, in the gotchas section.

#include <tunables/global>
profile docker-nginx flags=(attach_disconnected,mediate_deleted,complain) {
network,
capability,
file,
umount,
# signals: host and runtime may signal the container (docker stop/kill)
signal (receive) peer=unconfined,
signal (receive) peer=runc,
signal (receive) peer=crun,
signal (send,receive) peer="docker-nginx",
ptrace (trace,tracedby,read,readby) peer="docker-nginx",
# kept from docker-default
deny @{PROC}/sysrq-trigger rwklx,
deny @{PROC}/kcore rwklx,
deny mount,
deny /sys/[^f]*/** wklx,
deny /sys/f[^s]*/** wklx,
deny /sys/fs/[^c]*/** wklx,
deny /sys/fs/c[^g]*/** wklx,
deny /sys/fs/cg[^r]*/** wklx,
deny /sys/firmware/** rwklx,
deny /sys/devices/virtual/powercap/** rwklx,
deny /sys/kernel/security/** rwklx,
# new: nginx has no reason to write to the system directories
audit deny /etc/** wl,
audit deny /usr/** wl,
audit deny /bin/** wl,
audit deny /sbin/** wl,
audit deny /lib/** wl,
audit deny /root/** wl,
}

This is a trimmed copy of the baseline. It leaves out the /proc write rules and every deny network line (the obsolete socket families plus alg and vsock) to keep the post readable. For a profile you plan to run for real, take all the rules from the current baseTemplate in template.go. That file is a Go template, not a loadable profile, so fill in the fields by hand: use your profile name where it says {{.Name}} and {{.PeerName}}, use unconfined for the daemon peer on a stock host, and drop the {{range}} and {{if}} lines. Then add your denies at the bottom. Starting from less than docker-default leaves the container weaker than the default you started with.

Three details. The profile name in quotes on the peer= lines must match the name on the profile line. I left complain in the flags on purpose, which is Step 4. And the new rules say audit deny, not deny. That one word decides whether complain mode works at all, and Step 4 shows why.

The official nginx image needs one more change. Its entrypoint runs 10-listen-on-ipv6-by-default.sh, which uses sed -i to edit /etc/nginx/conf.d/default.conf at startup. sed -i creates a temp file in that directory. Once the /etc/** deny is enforced, that write fails, and on my test host the container exited before nginx started:

10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
sed: couldn't open temporary file /etc/nginx/conf.d/sedicgYCv: Permission denied

The fix is the setup you probably want anyway: mount your own config read-only. The script checks whether it can modify the file first, sees a read-only mount, logs can not modify /etc/nginx/conf.d/default.conf (read-only file system?), and skips the edit. Grab the stock file as a starting point:

Terminal window
docker run --rm nginx:stable cat /etc/nginx/conf.d/default.conf > default.conf

Step 3: Load It and Run With It

Load the profile into the kernel. This is the command from Docker’s docs:

Terminal window
sudo apparmor_parser -r -W /etc/apparmor.d/docker-nginx

-r replaces an already loaded profile with the same name. -W writes the compiled result to the parser cache. Check that it landed:

Terminal window
sudo aa-status | grep docker-nginx

Now run a container under it with --security-opt:

Terminal window
docker run -d --name web-nginx -p 8080:80 \
-v "$PWD/default.conf:/etc/nginx/conf.d/default.conf:ro" \
--security-opt apparmor=docker-nginx \
nginx:stable
docker inspect --format '{{.AppArmorProfile}}' web-nginx

The inspect output prints docker-nginx. In Compose, the same setting lives under security_opt:

compose.yaml
services:
web:
image: nginx:stable
ports:
- "8080:80"
volumes:
- ./default.conf:/etc/nginx/conf.d/default.conf:ro
security_opt:
- apparmor=docker-nginx

The Compose docs say options accept either option=value or option:value syntax. Use the = form so it matches the docker run command and you only have one thing to remember.

Step 4: Complain Mode, Then Enforce

A new profile in enforce mode is a good way to break production. Complain mode logs what the profile would block and lets it through. That is why the profile above carries complain in its flags.

The trap: plain deny ignores complain mode

An explicit deny rule stays enforced in complain mode, and by default AppArmor does not log it. I loaded the profile with plain deny lines and the complain flag, then ran this:

Terminal window
docker exec web-nginx touch /etc/foo
touch: cannot touch '/etc/foo': Permission denied

The kernel log had nothing for it. No ALLOWED line, no DENIED line. So complain mode gave me no warning, and enforcement gave me no record. That is the worst pair you can get from a security tool. It also explains the nginx startup failure from Step 2: that one happened in “complain” mode too.

Prefix the rule with audit and both problems go away. With audit deny and the complain flag, the same touch succeeds and the kernel logs it:

audit: type=1400 audit(1791577434.217:129): apparmor="ALLOWED" operation="open" class="file" profile="docker-nginx" name="/etc/foo" pid=112298 comm="touch" requested_mask="wc" denied_mask="wc" fsuid=0 ouid=0

Keep audit on your deny rules after you switch to enforce mode too. Logging a blocked write from your web server is the goal.

Read the log

Run your app normally for a while: hit the pages, restart the container, let the scheduled jobs fire. Then filter the kernel log for your profile:

Terminal window
sudo journalctl -k | grep 'profile="docker-nginx"'

You can also use dmesg, which is what Docker’s docs use, or /var/log/audit/audit.log if you run auditd. The fields that matter are apparmor (ALLOWED or DENIED), operation, profile, name (the path), and requested_mask.

For each ALLOWED line, ask one question: should the container be allowed to do this? If the answer is no and the app survived anyway, you have a rule to keep. If the answer is yes because the app breaks without it, you need to loosen the profile. Since file, already allows everything and deny rules always win, loosening means narrowing or removing a deny line. You cannot punch an allow hole through a deny.

The aa-logprof tool reads the same log lines and offers to write rules for you. It was built for host programs, so treat each suggestion with suspicion. For a container profile that starts with file,, I would rather edit the denies by hand.

When the log runs quiet, flip the profile to enforce. Remove complain from the flags and reload:

profile docker-nginx flags=(attach_disconnected,mediate_deleted) {
Terminal window
sudo apparmor_parser -r -W /etc/apparmor.d/docker-nginx

Recreate the container afterward so the test runs against the enforcing profile.

Step 5: Prove It Works

A security control you have not tested is a rumor. Try two writes to protected directories in the container that runs the profile:

Terminal window
docker exec web-nginx touch /etc/foo
docker exec web-nginx sh -c 'echo x > /usr/bin/x'
touch: cannot touch '/etc/foo': Permission denied
sh: 1: cannot create /usr/bin/x: Permission denied

Compare with the container from Step 1 on docker-default, where docker exec web touch /etc/foo succeeds for root. Then read the matching kernel log lines:

Terminal window
sudo journalctl -k | grep 'apparmor="DENIED"' | grep 'profile="docker-nginx"' | tail -n 2
audit: type=1400 audit(1791577437.913:134): apparmor="DENIED" operation="mknod" class="file" profile="docker-nginx" name="/etc/foo" pid=112637 comm="touch" requested_mask="c" denied_mask="c" fsuid=0 ouid=0
audit: type=1400 audit(1791577437.970:135): apparmor="DENIED" operation="mknod" class="file" profile="docker-nginx" name="/usr/bin/x" pid=112668 comm="sh" requested_mask="c" denied_mask="c" fsuid=0 ouid=0

Creating a new file logs mknod with mask c. Then check that nginx still serves traffic and that normal scratch space still works:

Terminal window
curl -sI http://localhost:8080 | head -n 1
docker exec web-nginx sh -c 'echo x > /tmp/ok && echo tmp-ok'

On my run that printed HTTP/1.1 200 OK and tmp-ok. Denied writes in /etc and /usr, a working site, and a log line per blocked write: that is a working profile. If the container fails to start instead, run docker logs on it, find the denial, and go back to complain mode.

The Gotchas

The profile must exist before the container starts. Docker names the profile, but it does not load yours. If the profile is not in the kernel, docker run fails with an error that ends in unable to apply apparmor profile: apparmor failed to apply profile: write fsmount:fscontext:proc/thread-self/attr/apparmor/exec: no such file or directory. That is Docker 29 with runc on my test host. Grep your logs for unable to apply apparmor profile. Docker does reload docker-default for you if something unloaded it. Your custom profile gets no such rescue.

Reboots wipe loaded profiles. The kernel forgets them. The AppArmor service reloads profiles from /etc/apparmor.d/ at boot, which is why the file lives at the top level of that directory. Docker’s docs put the nginx example in a containers/ subdirectory and note the path is not a requirement. I would not rely on a subdirectory being scanned at boot. Verify with a reboot and aa-status, not with hope.

Profile names are host-global. Two Compose projects that both define docker-nginx fight over one policy. Name profiles per app.

--privileged plus an explicit profile. Privileged alone gives you unconfined. But the source checks for an explicit profile first, so --privileged --security-opt apparmor=docker-nginx applies your profile. Do not lean on this. A privileged container has other problems a path profile will not fix.

apparmor=unconfined is the off switch. People paste it from forum threads when a denial annoys them. Read the log line instead. The name= field tells you what the profile blocked.

Other hosts. Docker’s docs say Docker expects to find an AppArmor policy loaded and enforced. On hosts without AppArmor, the profile settings do nothing. Fedora and RHEL use SELinux, so use label= options there. Podman has its own default, named containers-default- plus its version, and it takes the same --security-opt apparmor=PROFILE flag.

The SumGuy Take

Writing a profile for every container in your lab is over-engineering. Writing one for the two or three containers that face the internet is cheap insurance. nginx, a reverse proxy, or anything that parses untrusted input earns the effort.

The workflow is the part to keep: copy docker-default, add denies, run in complain mode for a few days, read the log, flip to enforce, and test a denied write on purpose. Total time is an evening. Your 2 AM self will thank you when a compromised container tries to write a backdoor into /usr/bin and gets Permission denied plus a log line instead.

Common Questions

Does Docker apply an AppArmor profile by default?

Yes, when the host has AppArmor enabled. Docker generates a profile named docker-default when the daemon starts and attaches it to every container that has no other profile. Run docker inspect --format '{{.AppArmorProfile}}' <container> to confirm what a given container received.

Does --privileged turn off AppArmor?

Yes. A privileged container without an explicit profile runs unconfined, and Docker’s documentation lists disabling the default AppArmor profile as an effect of --privileged. The one exception is an explicit --security-opt apparmor=<name>, which Docker applies even to a privileged container.

Does AppArmor work on Fedora or RHEL?

No, not by default. Fedora and RHEL ship SELinux as their mandatory access control system, and Docker only applies AppArmor profiles on hosts where the AppArmor kernel module is enabled. Check docker info --format '{{json .SecurityOptions}}' for name=apparmor. On SELinux hosts, use the label= options under --security-opt instead.

What is the difference between AppArmor and seccomp in Docker?

AppArmor restricts what a process can access: file paths, network families, mounts, signals. Seccomp restricts which system calls a process can make at all. Docker applies a default profile for each, so a container normally runs under both, and they cover different gaps.

Does Podman use the same docker-default profile?

No. Podman uses its own default AppArmor profile, named containers-default- followed by the Podman version, so a Docker profile name means nothing to Podman unless you load and select it yourself. Podman accepts the same --security-opt apparmor=<profile> option to select a custom profile or apparmor=unconfined to turn confinement off for that container.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Catch Your Self-Hosted Apps Phoning Home

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts