Skip to main content
RunBook Academy

Docker & ContainersXI Β· Container & Host SecurityLSMs

Linux security modules β€” AppArmor and SELinux for containers

Advanced⏱ ~32 min

What you'll learn

  • Determine which LSM is enforcing on a given host
  • Apply the right Docker-default profile
  • Diagnose LSM-related failures without disabling the LSM
  • Use SELinux volume relabelling safely

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-09

Not yet marked complete on this device.

The kernel’s Mandatory Access Control layer applies policy that even root cannot bypass. That is the property that distinguishes it from everything else in this part: capabilities, seccomp and the device cgroup all restrict a process, but a process that regains privilege gets them back. An LSM denial is enforced against the label, not against the UID.

Which LSM you get is decided by your distribution, and you do not get to run both.

Which LSM is enforcing on this host?

Start here, always. Every troubleshooting session on this topic that goes badly starts with someone assuming.

Read-only / Safethe authoritative answer
$ cat /sys/kernel/security/lsm
lockdown,capability,landlock,yama,apparmor,bpf

Illustrative output

The list always contains capability and reflects the order checks run in. The β€œminor” modules (yama, landlock, lockdown, bpf) stack freely; the major module β€” apparmor, selinux, smack or tomoyo β€” appears at most once. On the capture above the host is an AppArmor host. A host showing ...,selinux,bpf is a SELinux host.

Then ask Docker what it thinks:

Read-only / Safewhat the daemon believes it has
$ docker info --format '{{json .SecurityOptions}}'
["name=apparmor","name=seccomp,profile=builtin","name=cgroupns"]

Illustrative output

The two must agree. A host where /sys/kernel/security/lsm names apparmor and docker info does not is a host where containers are running unconfined while the compliance spreadsheet says otherwise. That mismatch is the finding, and it is common after a kernel upgrade that a daemon restart did not follow.

Distribution familyMajor LSMEnabled by defaultCheck with
Ubuntu, DebianAppArmoryesaa-status, /sys/module/apparmor/parameters/enabled
RHEL, CentOS Stream, Rocky, Alma, FedoraSELinuxyes, enforcinggetenforce, sestatus
SUSE / openSUSEAppArmoryesaa-status
Archneithernonothing is enforcing unless you set it up
Alpineneithernodocker-default will not apply

That last pair of rows matters more than it looks. On a host with no major LSM, --security-opt apparmor=docker-default is silently a no-op, docker inspect shows an empty AppArmorProfile, and every container runs with one fewer boundary than the runbook claims.

AppArmor

Docker applies a profile called docker-default to every container unless told otherwise. Two details about it are commonly got wrong:

  • It confines containers, not the daemon. Some distributions ship a separate profile for /usr/sbin/dockerd; that is a different thing with a different name.
  • It is not a file in /etc/apparmor.d. The Docker binary generates it in tmpfs at daemon start and loads it into the kernel directly. That is why you will not find it on disk, and why it comes back after an apparmor reload that unloaded it.
Read-only / Safeis docker-default loaded
sudo aa-status | grep -E 'docker|profiles are'
Read-only / Safewhat a specific container is confined by
CID=web
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
cat "/proc/$PID/attr/current"
docker inspect "$CID" --format 'requested={{.AppArmorProfile}}'

docker-default (enforce) is what you want. unconfined means either --privileged, an explicit --security-opt apparmor=unconfined, or a host where AppArmor is not active at all β€” three very different causes with the same symptom, which is why you check /sys/kernel/security/lsm first.

A custom profile

A custom profile must exist in /etc/apparmor.d and be loaded before the container starts, or docker run fails outright with apparmor failed to apply profile.

#include <tunables/global>

profile docker-nginx flags=(attach_disconnected,mediate_deleted) {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  network inet tcp,
  network inet udp,

  deny @{PROC}/* w,
  deny @{PROC}/sys/[^k]** w,
  deny mount,
  deny /sys/[^f]*/** wklx,
  deny ptrace (trace, tracedby),

  file,
  capability net_bind_service,
}
Configuration changeload, then use
sudo apparmor_parser -r -W /etc/apparmor.d/docker-nginx
sudo aa-status | grep docker-nginx
docker run -d --name web --security-opt apparmor=docker-nginx nginx:1.27-alpine

Profiles are not persisted by Docker. If the host reboots and nothing reloads /etc/apparmor.d/docker-nginx, the container will fail to start. On a systemd host the apparmor.service unit loads everything in that directory at boot β€” verify that it is enabled rather than assuming, because a profile that only exists in the kernel of a running machine is a profile that disappears at the worst moment.

SELinux

SELinux labels every process and every file, and rules are label-to-label. Docker’s container processes run as container_t (with container_init_t for the init process), and content a container may access is labelled container_file_t.

Docker does not enable SELinux support by itself. daemon.json defaults to "selinux-enabled": false, and the RHEL-family packages turn it on for you:

{
  "selinux-enabled": true
}
Read-only / Safehost and container SELinux state
getenforce
sestatus | head -6
CID=web
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
cat "/proc/$PID/attr/current"
ls -lZ /srv/data | head -3

:z and :Z on bind mounts

A host directory bind-mounted into a container carries the host’s label β€” typically something like var_t or home_root_t β€” which container_t is not permitted to read. The symptom is Permission denied on a file whose Unix permissions are obviously correct.

The two suffixes relabel the source:

  • :z β€” a shared label. The content becomes accessible to every container on the host. Correct for something genuinely shared, such as a config directory read by three services.
  • :Z β€” a private label with an MCS category unique to this container. No other container can read it. Correct for a database’s data directory.
Configuration changeprivate relabel
docker run -d --name db -v /srv/pgdata:/var/lib/postgresql/data:Z postgres:16

Diagnosing an LSM denial

The classic presentation:

Read-only / Safethe symptom
$ docker run --rm -v /srv/data:/data alpine:3.20 touch /data/test
touch: /data/test: Permission denied

Illustrative output

AppArmor:

sudo dmesg -T | grep -i 'apparmor="DENIED"' | tail -20
sudo journalctl -k --since '10 min ago' | grep -i apparmor

A denial line names the profile, the operation, and the path: apparmor="DENIED" operation="open" profile="docker-nginx" name="/proc/sys/kernel/...". That triple is the whole diagnosis.

SELinux:

sudo ausearch -m AVC -ts recent
sudo ausearch -m AVC -ts recent | audit2why

For SELinux, audit2allow can generate a policy module from denial records:

sudo ausearch -m AVC -ts recent | audit2allow -M my-container-policy
# Read my-container-policy.te BEFORE installing it.
sudo semodule -i my-container-policy.pp

Read the generated .te file first, every time. audit2allow faithfully permits whatever was denied, including the thing an attacker was attempting. A module that grants container_t write access to shadow_t is a valid answer to the denial and a disaster as a policy.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. Which single command most directly answers "which major LSM is enforcing on this host"?

  2. Q2. What does the `:Z` suffix on a bind mount do?

  3. Q3. A container shows `unconfined` in /proc/PID/attr/current on an Ubuntu host. Which causes are plausible? Select all that apply.

  4. Q4. The `docker-default` AppArmor profile is a file in /etc/apparmor.d that you can edit.

  5. Q5. Which SELinux mechanism can suppress the audit record for a denial you are trying to diagnose, and which command temporarily disables it?

Passing score: 75%. Answers are checked in this browser.