Docker & ContainersXI Β· Container & Host SecurityLSMs
Linux security modules β AppArmor and SELinux for containers
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
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.
$ cat /sys/kernel/security/lsmlockdown,capability,landlock,yama,apparmor,bpfIllustrative 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:
$ 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 family | Major LSM | Enabled by default | Check with |
|---|---|---|---|
| Ubuntu, Debian | AppArmor | yes | aa-status, /sys/module/apparmor/parameters/enabled |
| RHEL, CentOS Stream, Rocky, Alma, Fedora | SELinux | yes, enforcing | getenforce, sestatus |
| SUSE / openSUSE | AppArmor | yes | aa-status |
| Arch | neither | no | nothing is enforcing unless you set it up |
| Alpine | neither | no | docker-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 intmpfsat 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 anapparmorreload that unloaded it.
sudo aa-status | grep -E 'docker|profiles are'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,
}
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-alpineProfiles 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
}
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.
docker run -d --name db -v /srv/pgdata:/var/lib/postgresql/data:Z postgres:16Diagnosing an LSM denial
The classic presentation:
$ docker run --rm -v /srv/data:/data alpine:3.20 touch /data/testtouch: /data/test: Permission deniedIllustrative 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
Q1. Which single command most directly answers "which major LSM is enforcing on this host"?
Q2. What does the `:Z` suffix on a bind mount do?
Q3. A container shows `unconfined` in /proc/PID/attr/current on an Ubuntu host. Which causes are plausible? Select all that apply.
Q4. The `docker-default` AppArmor profile is a file in /etc/apparmor.d that you can edit.
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.