AppArmor & SELinux
Mandatory access control on files and capabilities.
Seccomp (secure computing mode) decides which system calls a program is allowed to make. Capabilities chop root's power into roughly forty separate keys, so you can hand over one without handing over the whole ring. Both are worth having. Neither one has any opinion about which files a program may open. A web server an attacker has taken over can still read /etc/shadow, the file holding every password hash on the box, as long as the ordinary file permissions allow it. Inside a container running as root, they usually do. Closing that gap is the job of a Linux Security Module.
A Linux Security Module (LSM, a second checkpoint built into the kernel) is a hook the kernel calls before it grants access to a file, a capability, or an operation. Ordinary Unix permissions work like an office building: whoever owns a room controls its lock, and the caretaker carries a master key that opens everything. That is discretionary access control (DAC), where the owner decides who gets in and root overrides everyone. Mandatory access control (MAC) posts a guard at the door with a fixed list of who may enter which room. The master key does not get you past the guard. Two modules dominate. AppArmor ships on Debian and Ubuntu. SELinux (Security-Enhanced Linux) ships on Red Hat Enterprise Linux and Fedora.
You are almost certainly running one already and have not noticed. On a host with AppArmor loaded, Docker writes a profile called docker-default and straps it onto every container you start, unless you go out of your way to tell it not to. You can read the name of that profile from inside the container.
# which profile is confining this container?$ docker run --rm alpine cat /proc/self/attr/currentdocker-default (enforce)# --privileged (or apparmor=unconfined) throws the guard out entirely:$ docker run --rm --privileged alpine cat /proc/self/attr/currentunconfined
What docker-default actually stops
The profile is a hand-picked deny list of things a container almost never needs and an attacker very much wants. Loading kernel modules. Writing to the corners of /proc and /sys that talk straight to the kernel. Mounting filesystems. That last one deserves its own sentence, because mount is a classic escape move. With it you can remount a host path as writable, or abuse an old cgroup (control group, the kernel feature that caps how much CPU and memory a container may use) release_agent trick to run your code on the node itself. Watch docker-default refuse a mount even after you hand the container the exact capability that is supposed to authorize it.
# give the container CAP_SYS_ADMIN, the capability that mount(2) requires...$ docker run --rm --cap-add SYS_ADMIN alpine \sh -c 'mkdir /mnt/x && mount -t tmpfs none /mnt/x'mount: mounting none on /mnt/x failed: Permission denied# the capability check passed. AppArmor is what said no. Read it on the HOST:$ sudo dmesg | tail -1audit: type=1400 audit(1752690411.882:73): apparmor="DENIED"operation="mount" class="mount" profile="docker-default"name="/mnt/x/" pid=4127 comm="mount" srcname="none" flags="rw"
That is what mandatory access control buys you. The capability check said yes, the profile still said no, because the guard's list beats the master key. docker-default is deliberately broad, wide enough to fit any workload without breaking it. Pinning one app down to its own files takes a profile you write yourself.
A profile is an allowlist of paths and caps
A custom profile flips the question around. Rather than listing what to forbid, you write down what the process may do, and everything you left out falls through to deny. A tight profile for a web server reads its content directory, writes to one cache path, opens a TCP socket, and touches nothing else. Someone who lands code execution inside it is boxed into the behavior you declared. Load a new profile in complain mode first, which logs what it would have blocked instead of blocking it, watch which paths the app honestly needs, then switch it to enforce.
#include <tunables/global>profile docker-myapp flags=(attach_disconnected,mediate_deleted) {#include <abstractions/base>network inet tcp, # bind/connect TCP, nothing else/app/** r, # read app files, read-only/var/cache/app/** rw, # the one path it may writedeny /etc/shadow rwklx, # never touch credentialsdeny /bin/** wl, # no writing into binary dirsdeny mount, # no mounting, no escape primitive}
# compile the profile into the kernel, then run the app under it:$ sudo apparmor_parser -r -W /etc/apparmor.d/docker-myapp$ docker run --rm --security-opt apparmor=docker-myapp myapp:1.0 \sh -c 'echo pwned > /app/index.html'sh: can't create /app/index.html: Permission denied# /app is r, not rw, so the write is denied and logged with the access mask:$ sudo dmesg | tail -1audit: type=1400 audit(1752690533.204:81): apparmor="DENIED"operation="open" profile="docker-myapp" name="/app/index.html"pid=5210 comm="sh" requested_mask="wc" denied_mask="wc" fsuid=0 ouid=0
SELinux does the same job with labels
Red Hat, Fedora, and everything downstream of them run SELinux instead, and it reaches the same place by a different route. Every process and every file wears a label, and part of that label is a type. Container processes run as the type container_t. Content meant for containers is labeled container_file_t. The kernel's rule is blunt: container_t may touch container_file_t and very little else. So a container cannot read a host file carrying some other type, whatever the Unix permissions on that file happen to say. AppArmor decides by path. SELinux decides by label. Either way it is the kernel holding a line underneath your app.
This bites the first time you bind-mount a host directory into a container on an SELinux box. The directory keeps the label it had on the host, container_t cannot touch it, and you get a Permission denied that looks exactly like a bug in your own code. The fix is a suffix on the volume. Lowercase :z relabels the directory to a shared container type that several containers can use. Uppercase :Z relabels it private to one container, with a unique category of its own. Docker runs the relabel for you the moment you add the suffix.
# Fedora host, SELinux enforcing. Bind a host dir in with no label suffix:$ docker run --rm -v /srv/appdata:/data fedora cat /data/config.ymlcat: /data/config.yml: Permission denied# the kernel logged an AVC (access vector cache) denial, not a plain Unix error:$ sudo ausearch -m avc -ts recent | tail -4type=AVC msg=audit(1752690644.1:92): avc: denied { read } forpid=8842 comm="cat" name="config.yml"scontext=system_u:system_r:container_t:s0:c12,c34tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=0# add :Z and Docker relabels the volume to a private container type:$ docker run --rm -v /srv/appdata:/data:Z fedora cat /data/config.ymldb_host: 10.0.0.5$ ls -Z /srv/appdata/config.ymlsystem_u:object_r:container_file_t:s0:c12,c34 /srv/appdata/config.yml
Both modules land you in the same place. Even root inside the container answers to a policy the host wrote, and that policy has the last word on which files, capabilities, and network moves are allowed, on top of whatever the ordinary DAC permissions would have waved through.
Which one you get depends on the distribution underneath you. Ubuntu and Debian hosts pick up the AppArmor profiles that ship with Docker. On the Red Hat family, SELinux labels are what matter, and the :z and :Z suffixes on volumes are how you set them. Switching the module off to "make the volume work" turns up in incident write-ups often enough to count as a pattern. Fix the labels instead.
Custom profiles are strong and easy to get wrong. Start from the vendor default, run your version in complain mode, read the denials it collects, and tighten one rule at a time.
Run the same check after every change window. Confirm the control is still on, paste the command and its output into the ticket, and refuse to close the change if the reading drifted from what you expected. Pick the tightest scope the workload can still live with. That habit compounds across every host and every pipeline you own.
Try this
Find out whether your host is enforcing AppArmor or SELinux, then look at the security options and labels on a container that is actually running.
$ docker info --format 'SecurityOptions={{.SecurityOptions}}'SecurityOptions=[name=apparmor name=seccomp,profile=builtin name=cgroupns]$ docker run -d --name lsmtest alpine sleep 120 >/dev/null$ docker inspect -f 'AppArmorProfile={{.AppArmorProfile}} SecurityOpt={{.HostConfig.SecurityOpt}}' lsmtestAppArmorProfile=docker-default SecurityOpt=[]$ docker rm -f lsmtest >/dev/null$ # On SELinux hosts also check: getenforce; ls -Z on volume mounts
Takeaway
Leave the module enforcing. When a container hits a wall, fix the volume label or the profile rather than switching AppArmor or SELinux off, and count docker-default and confined types as part of your baseline rather than optional decoration.
/srv/shared. Which bind-mount suffix do you use, and why not the other one?