Chapter 25Lesson 05~120 minutes

Checkpoint Lab — Container Security Model: Namespaces, Capabilities, Seccomp, AppArmor, SELinux, Devices, and Privilege Boundaries

Incrementally harden a synthetic Docker workload and produce an evidence-backed minimum-authority security posture dossier.

Container securityCapabilitiesSeccompAppArmor / SELinuxLeast privilege

Learning objectives

  • Harden one synthetic application incrementally from a permissive-but-unprivileged baseline to a minimum-authority configuration.
  • Capture process identity, capabilities, security options, mounts, filesystem mutability, namespace mode, and denied-operation evidence at each step.
  • Predict which operations will fail before applying each hardening control and compare the prediction with observed evidence.
  • Produce a concise security posture dossier and bridge the result to Chapter 26 rootless/user-namespace threat reduction.
Checkpoint objective. Start from a functioning local application, remove authority in controlled increments, preserve every denied operation and final security configuration, then justify the minimum authority retained. The lab never weakens host policy or uses broad privilege escalation.

1. Preflight and evidence packet

rm -rf dkr25-checkpoint dkr25-app
mkdir -p dkr25-checkpoint dkr25-app

docker context show | tee dkr25-checkpoint/context.txt
docker version | tee dkr25-checkpoint/docker-version.txt
docker info | tee dkr25-checkpoint/docker-info.txt
docker info --format '{{json .SecurityOptions}}' | tee dkr25-checkpoint/security-options.json
docker compose version | tee dkr25-checkpoint/compose-version.txt 2>&1 || true
docker buildx version | tee dkr25-checkpoint/buildx-version.txt 2>&1 || true

docker pull alpine:3.22
docker image inspect alpine:3.22 --format '{{json .RepoDigests}}' | tee dkr25-checkpoint/base-digests.json

2. Create one deterministic synthetic application

The application needs to read a declared config, write ephemeral state, and emit stdout. It does not need devices, host namespaces, persistent host writes, daemon access, or Linux capabilities.

cat > dkr25-app/run.sh <<'SH'
#!/bin/sh
set -eu
echo "uid=$(id -u) gid=$(id -g)"
cat /app/config.txt
echo "started=$(date -u +%FT%TZ)" >/run/app/state.txt
cat /run/app/state.txt
exec sleep 120
SH
chmod 755 dkr25-app/run.sh
printf 'mode=checkpoint
' > dkr25-app/config.txt

3. Baseline: functioning unprivileged container

The baseline is already unprivileged in Docker terms, but it still uses the image’s default root user and default writable rootfs/capability set. Capture it so every later reduction has a comparison point.

docker rm -f dkr25-cp-base 2>/dev/null || true

docker run -d --name dkr25-cp-base   --label academy=docker-ch25-checkpoint   -v "$PWD/dkr25-app:/app:ro"   alpine:3.22 sh /app/run.sh

sleep 2
docker logs dkr25-cp-base | tee dkr25-checkpoint/baseline-logs.txt
docker inspect dkr25-cp-base > dkr25-checkpoint/baseline-inspect.json
docker inspect dkr25-cp-base \
  --format 'user={{json .Config.User}} privileged={{.HostConfig.Privileged}} readonly={{.HostConfig.ReadonlyRootfs}} cap_add={{json .HostConfig.CapAdd}} cap_drop={{json .HostConfig.CapDrop}} security_opt={{json .HostConfig.SecurityOpt}}'   | tee dkr25-checkpoint/baseline-summary.txt

4. Write predictions before hardening

Record at least these predictions in your own words: (1) changing to UID/GID 65534 should still allow reading the read-only app directory but may expose ownership assumptions; (2) dropping all capabilities should not affect this workload; (3) making rootfs read-only will break the write to /run/app until an explicit writable tmpfs is supplied; (4) no-new-privileges should not change normal execution.

cat > dkr25-checkpoint/predictions.txt <<'EOF'
P1 non-root: app config remains readable; no host authority is implied.
P2 cap-drop ALL: expected to keep this simple app functional.
P3 read-only rootfs without writable /run/app: expected to fail on state write.
P4 read-only rootfs plus bounded tmpfs /run/app: expected to restore function.
P5 no-new-privileges: expected to preserve normal app behavior while preventing privilege gain paths.
EOF

5. Remove root identity and all capabilities

docker rm -f dkr25-cp-min 2>/dev/null || true

docker run -d \
  --name dkr25-cp-min \
  --label academy=docker-ch25-checkpoint \
  --user 65534:65534 \
  --cap-drop ALL   -v "$PWD/dkr25-app:/app:ro"   alpine:3.22 sh /app/run.sh

sleep 2
docker logs dkr25-cp-min | tee dkr25-checkpoint/nonroot-dropall-logs.txt
docker inspect dkr25-cp-min \
  --format 'user={{json .Config.User}} cap_drop={{json .HostConfig.CapDrop}} cap_add={{json .HostConfig.CapAdd}}'   | tee dkr25-checkpoint/nonroot-dropall-summary.txt

6. Intentionally create one denied operation and preserve it

Now add read-only rootfs without providing the application’s writable runtime path. This is the expected controlled failure. Preserve stdout/stderr, exit code, and inspect configuration before fixing it.

docker rm -f dkr25-cp-denied 2>/dev/null || true

docker run \
  --name dkr25-cp-denied \
  --label academy=docker-ch25-checkpoint \
  --user 65534:65534 \
  --cap-drop ALL \
  --read-only   -v "$PWD/dkr25-app:/app:ro"   alpine:3.22 sh /app/run.sh   >dkr25-checkpoint/denied.stdout 2>dkr25-checkpoint/denied.stderr || true

docker inspect dkr25-cp-denied \
  --format 'exit={{.State.ExitCode}} readonly={{.HostConfig.ReadonlyRootfs}} user={{json .Config.User}} cap_drop={{json .HostConfig.CapDrop}}'   | tee dkr25-checkpoint/denied-summary.txt
cat dkr25-checkpoint/denied.stderr

7. Final minimum-authority container

Repair only the required writable runtime path with an 8 MiB tmpfs owned by the application UID/GID, and add no-new-privileges. Keep rootfs read-only and capabilities empty.

docker rm -f dkr25-cp-final 2>/dev/null || true

docker run -d \
  --name dkr25-cp-final \
  --label academy=docker-ch25-checkpoint \
  --user 65534:65534 \
  --cap-drop ALL \
  --security-opt no-new-privileges=true \
  --read-only \
  --tmpfs /run/app:rw,nosuid,nodev,size=8m,uid=65534,gid=65534   -v "$PWD/dkr25-app:/app:ro"   alpine:3.22 sh /app/run.sh

sleep 2
docker logs dkr25-cp-final | tee dkr25-checkpoint/final-logs.txt
docker inspect dkr25-cp-final > dkr25-checkpoint/final-inspect.json
docker inspect dkr25-cp-final \
  --format 'user={{json .Config.User}} privileged={{.HostConfig.Privileged}} readonly={{.HostConfig.ReadonlyRootfs}} cap_drop={{json .HostConfig.CapDrop}} cap_add={{json .HostConfig.CapAdd}} security_opt={{json .HostConfig.SecurityOpt}} tmpfs={{json .HostConfig.Tmpfs}} pid_mode={{.HostConfig.PidMode}} network_mode={{.HostConfig.NetworkMode}} devices={{json .HostConfig.Devices}}'   | tee dkr25-checkpoint/final-security-summary.txt

8. Capture runtime identity and filesystem evidence

docker exec dkr25-cp-final sh -c '
  id
  echo "--- proc status ---"
  grep -E "^(Uid|Gid|CapInh|CapPrm|CapEff|CapBnd|NoNewPrivs|Seccomp):" /proc/1/status || true
  echo "--- writable runtime state ---"
  cat /run/app/state.txt
  echo "--- rootfs write should fail ---"
  (echo denied >/etc/dkr25-write-test) 2>&1 || true
' | tee dkr25-checkpoint/final-runtime-evidence.txt

docker inspect dkr25-cp-final --format '{{json .Mounts}}'   | tee dkr25-checkpoint/final-mounts.json

9. Record host LSM/security assumptions without mutation

docker info --format '{{json .SecurityOptions}}'   | tee dkr25-checkpoint/final-daemon-security-options.json

if command -v aa-status >/dev/null 2>&1; then
  aa-status > dkr25-checkpoint/apparmor-status.txt
fi
if command -v getenforce >/dev/null 2>&1; then
  getenforce > dkr25-checkpoint/selinux-enforce.txt
fi

10. Security posture dossier

Boundary Final checkpoint posture Evidence
Image identity Versioned Alpine base with recorded RepoDigest base-digests.json
Runtime identity UID/GID 65534 final inspect + id
Capabilities All dropped; none added final inspect + /proc/1/status capability masks
Privilege gain no-new-privileges enabled security option + NoNewPrivs runtime field where supported
Root filesystem Read-only inspect + failed write evidence
Writable state Only bounded /run/app tmpfs plus Docker-internal runtime files tmpfs config + runtime state
Host source Application directory mounted read-only mount evidence
Devices No explicit device mapping inspect devices list
Namespaces No host PID namespace requested; ordinary Docker network mode used inspect namespace modes
Seccomp/LSM Defaults preserved; host capabilities recorded Docker info/security-option evidence

11. Assumptions and limitations

Add a note stating that this checkpoint validates a local synthetic application only. It does not prove resistance to kernel vulnerabilities, malicious multi-tenancy, application-layer authorization flaws, image supply-chain compromise, or unsafe external services. Rootless Docker and user-namespace mapping are intentionally deferred to Chapter 26, where their networking/storage/device limitations can be evaluated directly.

cat > dkr25-checkpoint/assumptions.txt <<'EOF'
Local disposable lab; no production secrets/endpoints/devices.
Engine 29.8.1 is the documentation baseline; actual local versions are recorded.
Default seccomp/LSM policy is preserved; host support is recorded, not assumed.
This lab proves declared/runtime least-authority properties for one synthetic workload only.
It does not constitute hostile multi-tenant isolation proof or kernel-vulnerability immunity.
Rootless Docker and user-namespace mapping are evaluated in Chapter 26.
EOF

12. Exact cleanup and evidence archive

docker rm -f dkr25-cp-base dkr25-cp-min dkr25-cp-denied dkr25-cp-final 2>/dev/null || true
rm -rf dkr25-app

tar -czf dkr25-checkpoint.tgz dkr25-checkpoint/

13. What Chapter 25 adds to the production Docker model

You can now describe a container security boundary as evidence: process identity, namespace scope, capabilities, syscall filter, LSM policy, filesystem mutability, mounts, devices, and daemon/host context. You can remove authority incrementally without hiding failures behind blanket escalation. Chapter 26 builds on that model by moving the daemon/container execution boundary itself toward non-root operation with Rootless Docker, RootlessKit, and user namespaces.

Knowledge check

Why does the checkpoint deliberately create a read-only-rootfs failure?

What is the minimum capability set retained by the final synthetic app?

What makes /run/app acceptable as a writable exception?

What does the final dossier still not prove?

Why is Chapter 26 a natural next step?

Next lesson

Next: Rootless Docker, RootlessKit, User Namespaces, Networking, Storage, Limitations, and Threat Reduction: Concepts, Architecture, and Mental Model

Continue with the next lesson in the course sequence and carry forward the evidence-first Docker operating model.

Official references and version notes

Version/security baseline, verified 2026-09-21.

Docker Engine 29.8.1 is the current Engine baseline. The built-in seccomp policy remains a moderately protective allowlist-style default and currently blocks roughly 44 syscalls out of 300+. Docker’s AppArmor integration uses a generated docker-default profile where AppArmor is active; Engine 29.8 adds support for generating that profile from a custom daemon template. The 2026 CVE-2026-31431 hardening changed default seccomp/LSM handling for AF_ALG/socketcall; Docker explicitly warns against disabling seccomp as a workaround. Actual host LSM, rootless/userns, kernel, daemon, and Desktop/VM state must therefore be inspected rather than assumed.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.