Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Seccomp and AppArmor profiles for Docker containers

By updated

Improve this page

A container launched with no option receives two filters nobody asked for. The first, seccomp, sorts system calls: these are the requests a program sends to the kernel to open a file, create a process or mount a disk. The second, AppArmor, mostly sorts access to files, to the network and to mounts. Each follows a profile, a file of rules that says what is allowed. When a command fails with "Operation not permitted", you do not know which of the two profiles refused, nor whether a missing capability is the cause instead. This lesson teaches you to read the active profiles, to pin a denial on its layer, then to write your own profiles without removing the other protections. The measurements come from a Debian 13 VM with Docker Engine 29.9.2 and Compose 5.6.1. An AppArmor profile is loaded into the kernel of the host: replay the steps on a throwaway machine, never on a shared host.

What you will learn

Each step is checked in the kernel, not only in a configuration option.

  • You read the active profiles of a container in docker info, /proc/1/status and /proc/1/attr/current.
  • You pin a denial on its layer (capability, seccomp or AppArmor) by removing one layer at a time.
  • You recognize the settings that disable these protections, in order to refuse them.
  • You write a seccomp profile derived from the default profile of your engine version, and you attach it with docker run or Compose.
  • You load a dedicated AppArmor profile, you attach it in enforce mode (the kernel applies the denials) and you make its denial show up in the kernel log.
  • You give that profile what a service still requires (receiving the stop signal, surviving the reboot of the host).

Prerequisites

You must know how to run a container and understand the role of Linux capabilities. The lesson Securing Docker in production applies them to the engine, and the page Understanding Linux capabilities explains the kernel mechanism. You need sudo access on a Linux host that runs AppArmor, here Debian 13, with curl and python3 to download and modify the seccomp profile.

All the measurements of the lesson use a pinned Alpine image and cut the network (--network none). Every object created carries the c38b-m- prefix and lives in a dedicated working folder:

Terminal window
ALP="alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a"
W=/tmp/c38b-m-work
mkdir -p "$W" && cd "$W"

Run the blocks in order and in a single terminal: they reuse ALP, W and the current folder. The two labs work on other calls, other paths and other values: redo the reasoning there instead of copying the blocks.

What does each mechanism filter, and how does it differ from capabilities?

The three mechanisms each rule on the same call of the process, each with its own question. Capabilities decide whether the process has the right to perform a privileged operation, for example changing the owner of a file. seccomp decides whether the system call itself is allowed, whatever the process that issues it. AppArmor decides whether the profile attached to the process allows the targeted access: a path, a network operation, a mount, but also a capability or a signal.

Decision tree with three successive checks for a system call: seccomp first, then AppArmor, then capabilities, in the order measured for mount. A yes moves on to the next check, and the call runs after the last yes. A no at any step gives a denial: EPERM by default for seccomp, EACCES by default for AppArmor, EPERM for capabilities.System callfrom the processseccomp:call allowed?AppArmor:access allowed?yesseccomp refusalEPERM by defaultnoCapabilities:operation permitted?yesAppArmor refusalEACCES by defaultnoCall executedyesCapability refusalEPERMno
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Follow the diagram from top to bottom: a denial at any step stops the call. Above all, remember that an access must be allowed by the three layers at once.

The error message gives a hint, not a proof. By default, seccomp and the capabilities answer EPERM ("Operation not permitted"), and AppArmor answers EACCES ("Permission denied") when it refuses a file or a mount. But a seccomp profile can return EACCES, and an AppArmor profile that refuses a capability returns EPERM, as the measurements below show. Only removing one layer at a time settles it.

The order of the checks is fixed for seccomp: it rules before the kernel runs the system call, so before the checks made during that execution. Between AppArmor and the capabilities, the order depends on the call. The diagram shows the one for mount, which the table in the section "Which layer refused my call?" brings out. The table below sums up what each layer examines and where it is set.

LayerWhat it examinesDocker option that sets it
CapabilitiesThe privileged operation requested--cap-add, --cap-drop
seccompThe name of the system call and its numeric arguments--security-opt seccomp=
AppArmorPaths, network, mounts, capabilities and signals according to the profile--security-opt apparmor=

The difference with capabilities lies in granularity. A capability is a right granted to the process, whereas a seccomp profile lists system calls and an AppArmor profile lists access rules. Both profiles can therefore refuse an action that the process is nevertheless entitled to perform.

Which profiles does the engine apply without being asked?

Docker attaches to every unprivileged container a built-in seccomp profile and, on a host that runs AppArmor, a profile named docker-default, without you asking (see the default seccomp profile and the default AppArmor profile). The docker info command announces the active mechanisms, and the kernel says what a given container really applies.

Terminal window
docker info --format '{{.SecurityOptions}}'
[name=apparmor,profile=default name=seccomp,profile=builtin name=cgroupns]

This output says that Docker knows how to apply AppArmor (profile default) and seccomp (profile builtin, built into Docker). It says nothing about a given container. To examine one, read two files of its main process, PID 1:

Terminal window
docker run --rm --network none "$ALP" sh -c 'grep -E "^(Seccomp|Seccomp_filters|NoNewPrivs):" /proc/1/status; cat /proc/1/attr/current'
NoNewPrivs: 0
Seccomp: 2
Seccomp_filters: 1
docker-default (enforce)

Look at the Seccomp: 2 line. It is 0 when no filter is loaded and 2 when a filter is active, and Seccomp_filters: 1 counts the loaded filters. The NoNewPrivs line is not used in this lesson. The last line, read from /proc/1/attr/current, gives the AppArmor profile and its mode. The enforce mode means that the kernel applies the denials; the other mode, complain, only logs the accesses that the profile would refuse. These two files come from the kernel: they are authoritative, unlike an option displayed by Docker.

Finally, docker inspect is not the right source for seccomp:

Terminal window
docker run -d --network none --name c38b-m-ins "$ALP" sleep 120
docker inspect c38b-m-ins --format 'SecurityOpt={{json .HostConfig.SecurityOpt}} AppArmorProfile={{json .AppArmorProfile}} Privileged={{.HostConfig.Privileged}}'
docker rm -f c38b-m-ins
SecurityOpt=null AppArmorProfile="docker-default" Privileged=false

SecurityOpt is null: this field only lists the options you asked for. AppArmorProfile does name the profile applied, but no field names the seccomp profile. An empty option list therefore does not prove the absence of a filter: the proof remains /proc/1/status.

Which layer refused my call?

A missing capability and a seccomp filter return the same error, EPERM ("Operation not permitted"). The message therefore does not point to the guilty layer. The method is to remove one layer at a time on a throwaway container: if the call goes through when a layer is removed, that layer was refusing. This removal is for measuring, never for fixing: the fix only touches the layer at fault.

The first case is unshare -U, which asks the kernel for a separate user namespace. Under the default profile, it fails. The seccomp=unconfined option disables the seccomp profile for a container: if the call goes through again with it, seccomp alone was at fault:

Terminal window
docker run --rm --network none "$ALP" sh -c 'unshare -U true; echo unshare_rc=$?'
docker run --rm --network none --security-opt seccomp=unconfined "$ALP" sh -c 'unshare -U true; echo unshare_rc=$?'
unshare_rc=1
unshare: unshare(0x10000000): Operation not permitted
unshare_rc=0

The first container fails with EPERM and the second succeeds. No capability changed between the two: the seccomp filter is responsible. Note also that --cap-add SYS_ADMIN lets unshare through, because the default profile allows that call when the capability is present. Here, seccomp and the capability are linked.

The second case goes the other way. chown works by default, and fails when you remove the CHOWN capability. The error persists even when seccomp and AppArmor are disabled, which clears those two layers.

Terminal window
docker run --rm --network none --cap-drop CHOWN "$ALP" sh -c 'touch /tmp/f; chown 1000 /tmp/f; echo chown_rc=$?'
docker run --rm --network none --cap-drop CHOWN --security-opt seccomp=unconfined --security-opt apparmor=unconfined "$ALP" sh -c 'touch /tmp/f; chown 1000 /tmp/f; echo chown_rc=$?'
chown: /tmp/f: Operation not permitted
chown_rc=1
chown: /tmp/f: Operation not permitted
chown_rc=1

The third case, mount -t tmpfs none /mnt, crosses the three layers. The table gives what each combination produced: the message changes according to the layer that refuses, and only one combination succeeds.

Options added to the containerResult of mountLayer that refuses
nonepermission denied (are you root?), code 1seccomp, which only allows mount with SYS_ADMIN
seccomp=unconfinedmounting none on /mnt failed: Permission denied, code 255AppArmor (docker-default)
--cap-add SYS_ADMINmounting none on /mnt failed: Permission denied, code 255AppArmor (docker-default)
--cap-add SYS_ADMIN and apparmor=unconfinedsucceeded, code 0none
seccomp=unconfined and apparmor=unconfinedpermission denied (are you root?), code 1the missing SYS_ADMIN capability

Read the table from top to bottom. Granting the capability is not enough (row 3), because docker-default forbids mount. Conversely, removing both profiles is not enough without the capability (row 5). Rows 1 and 5 show the same message, because seccomp and a missing capability both return EPERM: the message alone separates nothing.

What separates them is the result when a layer is removed. As soon as seccomp is removed (row 2), the message and the code change: the default seccomp profile was therefore refusing first, since it only allows mount for a process that holds SYS_ADMIN. Then AppArmor refuses while the capability is still missing, which sets the order of the diagram for this call.

To pin down a denial, repeat the approach: note the error, remove one layer, run again, and start over until the call goes through. The fix targets only the layer found, and the other protections stay active.

What do unconfined and privileged remove?

Three settings disable these protections: seccomp=unconfined, apparmor=unconfined and --privileged. Recognize them in a docker run, a Compose file or a script in order to refuse them: none of them is justified as a way to fix a denial.

Terminal window
docker run --rm --network none --privileged "$ALP" sh -c 'grep -E "^(Seccomp|Seccomp_filters|NoNewPrivs):" /proc/1/status; cat /proc/1/attr/current'
NoNewPrivs: 0
Seccomp: 0
Seccomp_filters: 0
unconfined

With --privileged, the seccomp filter drops to Seccomp: 0 and AppArmor becomes unconfined: both layers disappear at once. The two unconfined options each act on a single layer, as the table sums up.

OptionSeccomp field of /proc/1/status/proc/1/attr/current
none (default)2docker-default (enforce)
--security-opt seccomp=unconfined0docker-default (enforce)
--security-opt apparmor=unconfined2unconfined
--privileged0unconfined

When you set both unconfined, docker inspect shows SecurityOpt=["seccomp=unconfined","apparmor=unconfined"] and AppArmorProfile="unconfined", which is easy to spot in a review. A service whose /proc/1/status check gives Seccomp: 0, or whose /proc/1/attr/current gives unconfined, runs without one of these protections.

How do you write a custom seccomp profile?

A seccomp profile is a JSON file built on three elements:

  • defaultAction says what happens to a call that is not in the list;
  • syscalls is the list of rules;
  • each rule carries names (the calls targeted), action and, if needed, args (conditions on the arguments of the call).

You give it to Docker with --security-opt seccomp=<file>. The client reads the file, not the daemon: the path is therefore resolved on the machine where you type the command.

Where should you start?

Start from the default profile of the exact version of your engine, not from a profile that allows everything. For Docker 29.9.2, the file is in the moby/moby repository, at the version tag. The path profiles/seccomp/default.json does not exist at that tag: use the vendor/ path of the block below. On the measurement VM, the file had the checksum on the first line of the output.

Terminal window
curl -fsSL "https://raw.githubusercontent.com/moby/moby/docker-v29.9.2/vendor/github.com/moby/profiles/seccomp/default.json" -o defaut.json
sha256sum defaut.json
python3 -c "import json;d=json.load(open('defaut.json'));print('defaultAction=',d['defaultAction'],' defaultErrnoRet=',d.get('defaultErrnoRet'),' groupes=',len(d['syscalls']))"
785b2429264afba4d594320337cb17f144f3c7d51585f9805eef72e28f4f9334 defaut.json
defaultAction= SCMP_ACT_ERRNO defaultErrnoRet= 1 groupes= 37

The profile refuses by default (SCMP_ACT_ERRNO, with error 1, EPERM) and lists its allowances in 37 rules. Attached unchanged, the downloaded file behaves like the built-in profile: chmod works and unshare stays refused. To block chmod, two moves are possible: add a denial, or remove the allowance. The two trials give different results.

What happens if you add a deny at the end of the list?

The first reflex is to add, at the end of the list, a rule that refuses chmod and its variants. The derive-ajout.py script runs this trial with the Alpine image:

Terminal window
cat > derive-ajout.py <<'EOF'
import json
d = json.load(open('defaut.json'))
d['syscalls'].append({"names": ["chmod", "fchmod", "fchmodat", "fchmodat2"], "action": "SCMP_ACT_ERRNO", "errnoRet": 1})
json.dump(d, open('derive.json', 'w'), indent=1)
print('règles :', len(d['syscalls']))
EOF
python3 derive-ajout.py
docker run --rm --network none --security-opt seccomp="$W/derive.json" "$ALP" sh -c 'touch /tmp/f; chmod 600 /tmp/f; echo "chmod rc=$?"; unshare -U true && echo unshare=ok || echo unshare=refuse'
règles : 38
chmod rc=0
unshare: unshare(0x10000000): Operation not permitted
unshare=refuse

Look at chmod rc=0: the profile has 38 rules and it refuses nothing new. It was nevertheless loaded, since unshare stays refused as before. A denial added at the end of the list blocked nothing here. The default profile already allows the chmod family in its first rule, a SCMP_ACT_ALLOW rule, and that is the one that won. This trial covers an unconditional denial aimed at a call that is already allowed: it does not tell you how two contradictory rules are settled in general. Do not count on an added denial, and harden by removal.

How do you deny a call that was allowed?

Since defaultAction refuses, it is enough to remove the names of the call from the rule that allows it: the call then falls under the default denial. You harden the profile by taking allowances away from it, not by adding denials. The derive-retrait.py script first looks for the rules that cite the chmod family (the first line of the output finds only one, rule 0), then removes those names from every SCMP_ACT_ALLOW rule.

Terminal window
cat > derive-retrait.py <<'EOF'
import json
VISES = {"chmod", "fchmod", "fchmodat", "fchmodat2"}
d = json.load(open('defaut.json'))
ou = [(i, sorted(VISES & set(r.get('names', [])))) for i, r in enumerate(d['syscalls']) if VISES & set(r.get('names', []))]
print('règles qui autorisent la famille chmod :', [(i, d['syscalls'][i]['action'], n) for i, n in ou])
for r in d['syscalls']:
if r.get('action') == 'SCMP_ACT_ALLOW' and 'names' in r:
r['names'] = [n for n in r['names'] if n not in VISES]
json.dump(d, open('derive.json', 'w'), indent=1)
EOF
python3 derive-retrait.py
docker run --rm --network none --security-opt seccomp="$W/derive.json" "$ALP" sh -c 'touch /tmp/f; chmod 600 /tmp/f; echo "chmod rc=$?"; unshare -U true && echo unshare=ok || echo unshare=refuse; ls /tmp >/dev/null && echo ls=ok'
docker run -d --name c38b-m-d2 --network none --security-opt seccomp="$W/derive.json" "$ALP" sleep 30
docker exec c38b-m-d2 grep -E '^Seccomp' /proc/1/status
docker rm -f c38b-m-d2
règles qui autorisent la famille chmod : [(0, 'SCMP_ACT_ALLOW', ['chmod', 'fchmod', 'fchmodat', 'fchmodat2'])]
chmod rc=1
chmod: /tmp/f: Operation not permitted
unshare=refuse
unshare: unshare(0x10000000): Operation not permitted
ls=ok
Seccomp: 2
Seccomp_filters: 1

Three lines of the output establish that the profile is correct. chmod rc=1 proves that the targeted call is refused. unshare=refuse and ls=ok show that the rest of the default profile stays in place and that ordinary calls go through. Seccomp: 2 confirms that the kernel applies a filter. You have therefore removed one allowance without touching the rest.

What else can you set in a rule?

The variants that follow isolate each field of a rule. They were measured on a minimal profile whose defaultAction is SCMP_ACT_ALLOW, in place of the default profile. Such a profile loses the protections of the default: unshare -U true goes through, whereas the default profile refuses it. Keep it only to understand the fields, never for a service.

Terminal window
cat > c38b-m-chmod-eperm.json <<'EOF'
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["chmod", "fchmod", "fchmodat", "fchmodat2"],
"action": "SCMP_ACT_ERRNO"
}
]
}
EOF
docker run --rm --network none --security-opt seccomp="$W/c38b-m-chmod-eperm.json" "$ALP" sh -c 'touch /tmp/f; chmod 600 /tmp/f; echo chmod600_rc=$?; mkdir /tmp/d; echo mkdir_rc=$?; ls -l /tmp/f; unshare -U true; echo unshare_rc=$?; grep -E "^Seccomp" /proc/1/status'
chmod: /tmp/f: Operation not permitted
chmod600_rc=1
mkdir_rc=0
-rw-r--r-- 1 root root 0 Nov 4 18:39 /tmp/f
unshare_rc=0
Seccomp: 2
Seccomp_filters: 1

Without errnoRet, the error returned is EPERM. The file keeps its original permissions (-rw-r--r--), mkdir works, and unshare_rc=0 confirms the announced loss of protection. Three other fields are set by changing the rule:

  • "errnoRet": 13 returns EACCES, and chmod prints chmod: /tmp/f: Permission denied.
  • "action": "SCMP_ACT_KILL_PROCESS" kills the offending process instead of returning an error to it. The parent shell survived, because chmod is a separate process, and the output showed Bad system call (core dumped).
  • An args key makes the rule conditional on the value of an argument, as the example below shows.

The args rule refuses chmod only for the mode 0777, written 511 in decimal. For chmod and fchmod, the mode is the second argument (index 1). For fchmodat and fchmodat2, it is the third (index 2).

Terminal window
cat > c38b-m-chmod-args.json <<'EOF'
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["chmod", "fchmod"],
"action": "SCMP_ACT_ERRNO",
"args": [{"index": 1, "value": 511, "op": "SCMP_CMP_EQ"}]
},
{
"names": ["fchmodat", "fchmodat2"],
"action": "SCMP_ACT_ERRNO",
"args": [{"index": 2, "value": 511, "op": "SCMP_CMP_EQ"}]
}
]
}
EOF
docker run --rm --network none --security-opt seccomp="$W/c38b-m-chmod-args.json" "$ALP" sh -c 'touch /tmp/f; chmod 600 /tmp/f; echo chmod600_rc=$?; chmod 777 /tmp/f; echo chmod777_rc=$?; ls -l /tmp/f'
chmod600_rc=0
chmod: /tmp/f: Operation not permitted
chmod777_rc=1
-rw------- 1 root root 0 Nov 4 18:39 /tmp/f

chmod 600 goes through and chmod 777 is refused: the rule only targets the chosen value.

A badly written profile does not always announce itself. On Docker 29.9.2, two errors stop docker run before the container starts, with return code 125: a truncated JSON file, then a missing file. Here are the two messages, in that order:

docker: compacting json for seccomp profile (/tmp/c38b-m-work/c38b-m-broken.json) failed: unexpected end of JSON input
docker: opening seccomp profile (/tmp/c38b-m-work/c38b-m-absent.json) failed: open /tmp/c38b-m-work/c38b-m-absent.json: no such file or directory

On the other hand, a nonexistent system call name is silently accepted: the container started normally with a profile targeting c38b_m_inexistant. A typo in names therefore produces no error, and the rule applies to nothing. Always check the effect with a trial that must fail.

How do you attach it in a Compose file?

Compose takes the option over under security_opt. A relative path is read from the folder of the Compose file, not from the folder where you run the command. Both the seccomp= and seccomp: forms worked with Compose 5.6.1.

To measure it, docker compose -f "$W/compose/compose.yaml" up -d was run from another folder, which contained no profile: the profile was applied (Seccomp: 2, chmod refused). With a fake permissive profile of the same name in that launch folder, chmod was still refused, so the file next to compose.yaml is indeed the one that is read. The block below runs Compose from the folder of the file, which comes to the same thing.

Terminal window
mkdir -p "$W/compose" && cd "$W/compose"
cp "$W/c38b-m-chmod-eperm.json" .
cat > compose.yaml <<EOF
name: c38b-m-proj
services:
app:
image: $ALP
command: ["sleep", "120"]
network_mode: none
security_opt:
- seccomp=./c38b-m-chmod-eperm.json
EOF
docker compose up -d
docker compose exec -T app sh -c 'touch /tmp/f; chmod 600 /tmp/f; echo rc=$?; grep -E "^Seccomp" /proc/1/status'
docker compose down
cd "$W"
Container c38b-m-proj-app-1 Creating
Container c38b-m-proj-app-1 Created
Container c38b-m-proj-app-1 Starting
Container c38b-m-proj-app-1 Started
chmod: /tmp/f: Operation not permitted
rc=1
Seccomp: 2
Seccomp_filters: 1

The Seccomp: 2 line and the refusal of chmod prove that the profile is applied to the service. With seccomp=unconfined in its place, the same container showed Seccomp: 0: an automated check of this field therefore detects that setting.

How do you write and load an AppArmor profile?

An AppArmor profile is a file of rules that apparmor_parser loads into the kernel of the host. It has a name, and that name is what you give to --security-opt apparmor=. An access that the profile allows nowhere is refused without any particular rule: that is an implicit denial. The deny rule creates an explicit denial, which holds even when a broader rule allows the access. The profile below broadly allows the network (network), the capabilities that the container already holds (capability), files (file) and unmounting (umount), then only refuses writing under /srv/vault/. It does not take over the other rules of docker-default: the section "What does a dedicated profile lose compared to docker-default?" shows what that gap costs a service. The two flags are not part of the exercise: copy them as they are.

Terminal window
mkdir -p "$W/vault"
echo "contenu-vault" > "$W/vault/secret.txt"
chmod 666 "$W/vault/secret.txt"
ls -l "$W/vault/secret.txt"
cat > c38b-m-nowrite <<'EOF'
#include <tunables/global>
profile c38b-m-nowrite flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network,
capability,
file,
umount,
deny /srv/vault/** w,
}
EOF
sudo aa-status | grep -E 'c38b|docker-default|profiles are loaded'
sudo apparmor_parser -a c38b-m-nowrite; echo "parser_rc=$?"
sed -e 's/profile c38b-m-nowrite flags=(/profile c38b-m-nowrite-cplx flags=(complain,/' c38b-m-nowrite > c38b-m-nowrite-cplx
sudo apparmor_parser -a c38b-m-nowrite-cplx
sudo aa-status | grep -E 'c38b|docker-default|profiles are loaded'
-rw-rw-rw- 1 ansible ansible 14 Nov 4 18:40 /tmp/c38b-m-work/vault/secret.txt
107 profiles are loaded.
docker-default
parser_rc=0
109 profiles are loaded.
c38b-m-nowrite
docker-default
c38b-m-nowrite-cplx

The test file has Unix mode 666: the permissions therefore allow writing, and only the AppArmor policy can refuse it. Loading with -a adds the profile without touching docker-default or the other profiles. The sed command also builds a copy of the profile in complain mode, to compare the two modes afterwards. In the output, aa-status goes from 107 to 109 profiles: the profile of the lesson and that copy, listed next to docker-default.

If you replay the lesson while the profile is still loaded, -a refuses to add it a second time. The -r option replaces the loaded profile with the content of the file:

Terminal window
sudo apparmor_parser -a c38b-m-nowrite; echo "parser_a_rc=$?"
sudo apparmor_parser -r c38b-m-nowrite; echo "parser_r_rc=$?"
apparmor_parser: Unable to add "c38b-m-nowrite". Profile already exists
parser_a_rc=239
parser_r_rc=0

A syntax error is reported at load time, with the line number and a return code of 1:

Terminal window
printf 'profile c38b-m-bad {\n file,\n deny /x/** wz,\n}\n' > c38b-m-bad
sudo apparmor_parser -a c38b-m-bad; echo "bad_rc=$?"
AppArmor parser error for c38b-m-bad in profile c38b-m-bad at line 3: syntax error, unexpected TOK_ID, expecting TOK_MODE
bad_rc=1

Next, attach the profile to a container, with the test folder mounted on /srv/vault and a tmpfs on /srv/open for comparison.

Terminal window
S='cat /proc/1/attr/current; echo --- ; cat /srv/vault/secret.txt; echo "cat_rc=$?"; echo nouveau > /srv/vault/secret.txt; echo "write_rc=$?"; touch /srv/vault/new; echo "touch_rc=$?"; mkdir /srv/vault/d; echo "mkdir_rc=$?"; echo ok > /srv/open/f; echo "open_rc=$?"; ls -l /srv/vault/secret.txt; id'
docker run --rm --network none --security-opt apparmor=c38b-m-nowrite -v "$W/vault:/srv/vault" --tmpfs /srv/open "$ALP" sh -c "$S"
c38b-m-nowrite (enforce)
---
contenu-vault
cat_rc=0
sh: can't create /srv/vault/secret.txt: Permission denied
write_rc=1
touch: /srv/vault/new: Permission denied
touch_rc=1
mkdir: can't create directory '/srv/vault/d': Permission denied
mkdir_rc=1
open_rc=0
-rw-rw-rw- 1 1001 1001 14 Nov 4 18:40 /srv/vault/secret.txt
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

Read the output in this order. The first line confirms the enforce mode. Reading succeeds; writing, touch and mkdir in the protected folder are refused; writing in /srv/open goes through. The profile therefore only stops the targeted access. The file keeps mode 666 and the user of the container is root: only the policy refuses, not the Unix permissions. With docker-default or with apparmor=unconfined, the same writes all succeeded.

AppArmor also filters capabilities. The capability rule of the profile leaves the container the ones it holds; a deny capability chown rule takes one away, even though Docker granted it. The denial then looks like that of a missing capability:

Terminal window
sed -e 's/c38b-m-nowrite/c38b-m-nochown/' -e 's# deny /srv/vault/\*\* w,# deny capability chown,#' c38b-m-nowrite > c38b-m-nochown
sudo apparmor_parser -a c38b-m-nochown
docker run --rm --network none --security-opt apparmor=c38b-m-nochown "$ALP" sh -c 'cat /proc/1/attr/current; touch /tmp/f; chown 1000 /tmp/f 2>&1; echo chown_rc=$?'
docker run --rm --network none --security-opt apparmor=c38b-m-nochown --security-opt seccomp=unconfined "$ALP" sh -c 'touch /tmp/f; chown 1000 /tmp/f 2>&1; echo chown_sans_seccomp_rc=$?'
c38b-m-nochown (enforce)
chown: /tmp/f: Operation not permitted
chown_rc=1
chown: /tmp/f: Operation not permitted
chown_sans_seccomp_rc=1

The message is Operation not permitted, so EPERM, exactly the one from --cap-drop CHOWN: an AppArmor denial does not always return EACCES. The denial persists without seccomp, even though Docker grants the CHOWN capability by default and the same chown works under docker-default: it is therefore indeed the AppArmor profile that refuses.

The complain mode logs the accesses that the profile would implicitly refuse, without blocking them, but it still applies a deny rule. Run again the docker run that mounts vault, with the copy loaded above under flags=(complain,...), that is, with --security-opt apparmor=c38b-m-nowrite-cplx. The container prints c38b-m-nowrite-cplx (complain), and writing to the file is still refused:

c38b-m-nowrite-cplx (complain)
[...]
sh: can't create /srv/vault/secret.txt: Permission denied
write_rc=1

A deny rule therefore applies even in complain, which the manual page aa-complain(8) confirms. Do not count on this mode to safely test a deny rule, and do not infer the mode from an observed denial: a deny denial shows up in both modes. Only /proc/1/attr/current says whether the profile is in enforce.

If you name a profile that is not loaded, the container does not start, and docker run returns code 127 with an error from the runc runtime:

docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: unable to apply apparmor profile: apparmor failed to apply profile: write fsmount:fscontext:proc/thread-self/attr/apparmor/exec: no such file or directory

The message does not say "profile missing" in so many words, but unable to apply apparmor profile followed by no such file or directory hints at it. Load the profile before starting the container. This is also the error a service meets after a host reboot, when its profile was only loaded with -a: the section "How does an AppArmor profile survive a reboot?" shows how to install it.

Why does an AppArmor denial not show up in the kernel log?

The denial of the write is indeed applied, and yet the journalctl -k command shows no apparmor="DENIED" line for /srv/vault. On the measurement VM, the /sys/module/apparmor/parameters/audit parameter was normal (readable with sudo cat) and auditd was inactive. A simple deny rule refuses without logging: the manual page apparmor.d(5) says that you have to combine it with audit to get a trace.

The audit deny rule does both. Load a copy of the profile where only that rule changes, then run the write again:

Terminal window
sed -e 's/c38b-m-nowrite/c38b-m-nowrite-aud/' -e 's#deny /srv/vault#audit deny /srv/vault#' c38b-m-nowrite > c38b-m-nowrite-aud
sudo apparmor_parser -a c38b-m-nowrite-aud
docker run --rm --network none --security-opt apparmor=c38b-m-nowrite-aud -v "$W/vault:/srv/vault" --tmpfs /srv/open "$ALP" sh -c 'cat /proc/1/attr/current; cat /srv/vault/secret.txt; echo x > /srv/vault/secret.txt; echo write_rc=$?'
sudo journalctl -k --no-pager -o short-iso --since "-5min" | grep 'apparmor='
c38b-m-nowrite-aud (enforce)
contenu-vault
sh: can't create /srv/vault/secret.txt: Permission denied
write_rc=1
[...]
2026-11-04T18:40:19+00:00 c45-debian.lab kernel: audit: type=1400 audit(1793817619.758:118): apparmor="STATUS" operation="profile_load" profile="unconfined" name="c38b-m-nowrite-aud" pid=29909 comm="apparmor_parser"
2026-11-04T18:40:19+00:00 c45-debian.lab kernel: audit: type=1400 audit(1793817619.966:119): apparmor="DENIED" operation="open" class="file" profile="c38b-m-nowrite-aud" name="/srv/vault/secret.txt" pid=29951 comm="sh" requested_mask="wc" denied_mask="wc" fsuid=0 ouid=1001

The DENIED line is enough to pin the denial on AppArmor: profile= names the profile, name= the path, operation="open" the operation, and requested_mask="wc" the write and creation requested. An implicit denial, on the other hand, logs itself: a profile that does not allow writing on this path, with no deny or audit rule, produced the same line.

The grep 'apparmor=' filter also brings back noise unrelated to your denial: operation="profile_load" lines when you load a profile, and, if you name a profile that is not loaded, an operation="change_onexec" line emitted by runc with info="label not found". Look for the profile= and the name= of your case. On this VM, the refusal of mount by docker-default did not leave a trace either without an audit rule.

What does a dedicated profile lose compared to docker-default?

A profile passed through --security-opt apparmor= replaces docker-default entirely: it inherits none of its rules. Yet docker-default does not just allow. The profile template embedded in Docker 29.9.2 also refuses mount, writing to most files of /proc and /sys, reading /proc/kcore, and the alg and vsock network sockets. Finally, it allows specific signals: those that runc and the daemon send to the container, and those that the container's processes exchange. The c38b-m-nowrite profile has none of these rules.

The most visible gap concerns signals. The service below waits for SIGTERM to stop cleanly: that is the signal that docker stop has runc send to it. Run it under the profile of the lesson, then stop it:

Terminal window
APP='trap "echo arret-propre; exit 0" TERM; sleep 300 & wait'
docker run -d --name c38b-m-svc --network none --security-opt apparmor=c38b-m-nowrite "$ALP" sh -c "$APP" >/dev/null
sleep 1
docker exec c38b-m-svc cat /proc/1/attr/current
t0=$(date +%s); docker stop c38b-m-svc >/dev/null; t1=$(date +%s)
echo "duree_stop=$((t1-t0))s code=$(docker inspect -f '{{.State.ExitCode}}' c38b-m-svc) journal=[$(docker logs c38b-m-svc 2>&1)]"
docker rm c38b-m-svc >/dev/null
sudo journalctl -k --no-pager -o short-iso --since "-1min" | grep 'apparmor="DENIED"' | grep 'c38b-m-nowrite'
c38b-m-nowrite (enforce)
duree_stop=12s code=137 journal=[]
2026-11-04T19:09:44+00:00 c45-debian.lab kernel: audit: type=1400 audit(1793819384.532:124): apparmor="DENIED" operation="signal" class="signal" profile="c38b-m-nowrite" pid=2327 comm="runc" requested_mask="receive" denied_mask="receive" signal=term peer="runc"
2026-11-04T19:09:46+00:00 c45-debian.lab kernel: audit: type=1400 audit(1793819386.560:125): apparmor="DENIED" operation="signal" class="signal" profile="c38b-m-nowrite" pid=2334 comm="runc" requested_mask="receive" denied_mask="receive" signal=kill peer="runc"

The stop took 12 seconds and ended with code 137, without the arret-propre line. The log says why: AppArmor refused the container the right to receive (requested_mask="receive") the SIGTERM then the SIGKILL sent by runc (peer="runc"). The container was nevertheless killed, as code 137 shows, by a path that these two lines do not show: the profile does not prevent the stop, it takes away the service's only chance to stop itself. The service never knew it was being asked to stop: this is the abrupt stop that a service with data being written must avoid. The lesson Container lifecycle, PID 1 and clean shutdown in Docker explains the delay of docker stop and code 137.

The profile of a service therefore allows at least the reception of the signals from runc. The c38b-m-service copy differs from c38b-m-nowrite only by that rule:

Terminal window
sed -e 's/c38b-m-nowrite/c38b-m-service/' -e 's# deny /srv/vault/\*\* w,# signal (receive) peer=runc,\n deny /srv/vault/** w,#' c38b-m-nowrite > c38b-m-service
cat c38b-m-service
sudo apparmor_parser -a c38b-m-service; echo "parser_service_rc=$?"
docker run -d --name c38b-m-svc --network none --security-opt apparmor=c38b-m-service "$ALP" sh -c "$APP" >/dev/null
sleep 1
docker exec c38b-m-svc cat /proc/1/attr/current
t0=$(date +%s); docker stop c38b-m-svc >/dev/null; t1=$(date +%s)
echo "duree_stop=$((t1-t0))s code=$(docker inspect -f '{{.State.ExitCode}}' c38b-m-svc) journal=[$(docker logs c38b-m-svc 2>&1)]"
docker rm c38b-m-svc >/dev/null
#include <tunables/global>
profile c38b-m-service flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network,
capability,
file,
umount,
signal (receive) peer=runc,
deny /srv/vault/** w,
}
parser_service_rc=0
c38b-m-service (enforce)
duree_stop=0s code=0 journal=[arret-propre]

With this rule, the service receives SIGTERM, writes arret-propre and exits with code 0 without waiting. That single rule only covers the signals from runc: a service whose processes send signals to each other, or receive some from a process of the host, needs the other signal rules of the template. For a real service, do not start from an empty profile: start from the template of your engine version, keep its denials and its signal rules, then add your restriction. Then check the three operating gestures: the targeted access is refused, docker stop ends with code 0, and the log shows no unexpected denial.

How does an AppArmor profile survive a reboot?

apparmor_parser -a loads the profile into the kernel and writes nothing to disk: when the host reboots, the profile disappears, and the container that names it no longer starts. For a profile to be reloaded at boot, install its file under /etc/apparmor.d/, which the apparmor service reads at every start, then load it from that location with -r:

Terminal window
sudo install -m 0644 -o root -g root c38b-m-service /etc/apparmor.d/c38b-m-service
sudo apparmor_parser -r /etc/apparmor.d/c38b-m-service; echo "parser_install_rc=$?"
ls -l /etc/apparmor.d/c38b-m-service
sudo aa-status | grep -E 'c38b|profiles are loaded'
parser_install_rc=0
-rw-r--r-- 1 root root 229 Nov 4 19:09 /etc/apparmor.d/c38b-m-service
110 profiles are loaded.
c38b-m-nochown
c38b-m-nowrite
c38b-m-service

This output comes from a run limited to the three profiles it lists. If you ran all the blocks of the lesson, aa-status also lists the -cplx and -aud copies (112 profiles on the measurement VM). The measurement VM was then rebooted. A reboot opens a new terminal and empties /tmp: the checks below therefore redefine ALP, and the working folder is gone. This block describes the state after the reboot: run without rebooting, it gives nowrite_rc=0, since c38b-m-nowrite is still loaded.

Terminal window
ALP="alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a"
sudo aa-status | grep -E 'c38b|profiles are loaded'
docker run --rm --network none --security-opt apparmor=c38b-m-service "$ALP" cat /proc/1/attr/current; echo "service_rc=$?"
docker run --rm --network none --security-opt apparmor=c38b-m-nowrite "$ALP" cat /proc/1/attr/current; echo "nowrite_rc=$?"
108 profiles are loaded.
c38b-m-service
c38b-m-service (enforce)
service_rc=0
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: unable to apply apparmor profile: apparmor failed to apply profile: write fsmount:fscontext:proc/thread-self/attr/apparmor/exec: no such file or directory
Run 'docker run --help' for more information
nowrite_rc=127

Only the installed profile came back: the container that names it starts, whereas the one that names c38b-m-nowrite, loaded with -a, fails with code 127. You do not need to reboot to anticipate it: the presence of the file under /etc/apparmor.d/ and an enabled apparmor service (systemctl is-enabled apparmor) indicate that the profile will be reloaded. If you reboot a throwaway machine anyway, only c38b-m-service is left to clean up, by its path, as the first two lines of the cleanup block do. The page Understand and use AppArmor details profile management on the host side.

What are the limits of these profiles?

Each mechanism leaves some cases out of its reach, and it is better to know them than to discover them in production.

  • seccomp does not read the content of paths passed by pointer: it compares the name of the call and its numeric arguments, so it cannot refuse "open this file". The kernel documentation on seccomp explains it: BPF filters do not dereference pointers. Refusing a path is AppArmor's job.
  • AppArmor depends on the host. Distributions based on SELinux, instead of AppArmor, do not have this mechanism: their containers are confined by SELinux labels, which follow other rules.
  • A custom profile is tied to the machine. A seccomp profile is a file that the client reads, an AppArmor profile is a state of the host's kernel: every host that runs the service must carry the profile installed under /etc/apparmor.d/.
  • A badly written seccomp rule stays silent: an unknown call name is accepted without error. Only a trial that must fail proves that a rule applies.

Never disable a protection to fix a denial: an unconfined granted "to test quickly" risks staying in place. Write the minimal probe that reproduces the denial, find the layer by removing one layer at a time, then modify only the profile of that layer. For Kubernetes, the same profiles are attached through the securityContext: seccomp in Kubernetes (in French) and AppArmor in Kubernetes (in French).

How do you clean up what the lesson created?

A loaded AppArmor profile stays in the kernel until you unload it: deleting the file is not enough. First unload the installed profile by its path under /etc/apparmor.d/, then remove that file so that it does not come back at the next boot. Then unload the other profiles of the lesson with -R, which reads their file in the working folder, check that only the original profiles remain, then delete the folder.

Terminal window
sudo apparmor_parser -R /etc/apparmor.d/c38b-m-service; echo "unload c38b-m-service rc=$?"
sudo rm /etc/apparmor.d/c38b-m-service
for f in c38b-m-nowrite c38b-m-nowrite-cplx c38b-m-nowrite-aud c38b-m-nochown; do sudo apparmor_parser -R "$f"; echo "unload $f rc=$?"; done
sudo aa-status | grep -c c38b
sudo aa-status | grep -E 'profiles are loaded|docker-default'
cd / && find /tmp/c38b-m-work -delete
docker ps -a --filter name=c38b-m- --format '{{.Names}}'
unload c38b-m-service rc=0
unload c38b-m-nowrite rc=0
unload c38b-m-nowrite-cplx rc=0
unload c38b-m-nowrite-aud rc=0
unload c38b-m-nochown rc=0
0
107 profiles are loaded.
docker-default

The count 0 after grep -c c38b proves that no profile of the lesson remains, and the total falls back to the number from before the lesson (107 on the measurement VM), docker-default included. The last command must print nothing. Never unload docker-default or any profile other than those of the lesson.

Put it into practice

The first lab starts from a service whose seccomp filtering is disabled. You derive the default profile, supplied as a pinned copy, so that it refuses one specific call with one specific argument value while still letting the same call through with other values. It combines the two moves of this lesson: remove the call from the rule that allows it, then allow it again with a condition on its argument. Its four tests check that the explicit profile is declared with no unconfined, that the kernel announces an active filter in /proc/1/status, that a provided probe sees the targeted call refused and the others allowed, and that the profile still refuses what the default refused, which rules out a permissive profile.

The second lab has you write, load and attach a dedicated AppArmor profile yourself, in enforce mode. It expects the opposite of this lesson's demonstration profile: instead of allowing broadly and then refusing a write with deny, the profile names what the service needs and refuses the rest by omission. That is the implicit denial seen earlier, the only one that complain mode lets through, and so the only one that proves which mode applies. Its tests read the profile declared on the container, check enforce in /proc/1/attr/current, try two reads, one of which must be refused by the policy and not by Unix permissions, then try to read a system file the service has no need for, to rule out a profile with a bare file, rule. Keep in mind that the method "load in complain, collect the denials, add rules" only shows what the service attempted: it does not reveal the signal (receive) peer=runc rule for a service that ignores SIGTERM.

Key points

A denial is judged by its layer before it is fixed, and a fix never removes the other protections.

  • You read the active profiles in /proc/1/status (Seccomp: 2) and /proc/1/attr/current (docker-default (enforce)), because SecurityOpt is empty by default.
  • You pin a denial by removing one layer at a time, because the message does not separate them: seccomp, capabilities and a capability refused by AppArmor all return EPERM.
  • You spot seccomp=unconfined, apparmor=unconfined and --privileged as settings to refuse.
  • You harden a seccomp profile by removing the call from the SCMP_ACT_ALLOW rule: a denial added at the end of the list blocked nothing in the measurement.
  • You attach an AppArmor profile loaded with apparmor_parser, you check enforce, and to log a deny denial you add audit.
  • You know that a dedicated profile replaces docker-default entirely: without signal (receive) peer=runc, docker stop no longer stops the service cleanly.
  • You install the profile of a service under /etc/apparmor.d/, because a profile loaded with -a disappears at reboot and the container no longer starts.
  • You keep in mind that complain does not stop a deny rule, and that a truncated or missing seccomp profile stops docker run (code 125) whereas an unknown call name goes through without error.
  • You unload your profiles with apparmor_parser -R at the end.

Knowledge check

Check that the essentials of this lesson are mastered. The questions cover reading the active profiles, pinning a denial on its layer, writing the two profiles and how they hold up in operation (stopping the service, rebooting).

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn