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.
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.
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?
To preserve a known first-failure signal and prove that the correct repair is a narrow writable runtime path rather than unrelated privilege escalation.
What is the minimum capability set retained by the final synthetic app?
None: all Linux capabilities are dropped because the workload demonstrated no need for them.
What makes /run/app acceptable as a writable exception?
It is an explicit bounded tmpfs used only for ephemeral runtime state; the rest of the root filesystem remains read-only.
What does the final dossier still not prove?
It does not prove safety against kernel vulnerabilities, hostile multi-tenancy, application authorization bugs, supply-chain compromise, or unsafe external services.
Why is Chapter 26 a natural next step?
Because it evaluates rootless Docker and user-namespace identity mapping as additional host-privilege-reduction layers on top of the least-authority container posture built here.
Official references and version notes
- Docker Engine security — daemon trust, kernel isolation, user namespaces, and host-hardening context.
- Seccomp security profiles for Docker — built-in profile behavior, blocked syscall rationale, and custom profile mechanics.
-
AppArmor security profiles for Docker
— generated
docker-defaultprofile and custom-policy workflow. - docker container run reference — capabilities, devices, read-only rootfs, security options, user, namespaces, and resource/security controls.
-
Compose service capability controls
—
cap_addandcap_drop. -
Compose service security_opt
— including
no-new-privilegesand LSM labels. - Compose read_only — read-only root filesystem semantics.
- Docker tmpfs mounts — ephemeral writable memory-backed state.
- Docker Engine 29 release notes — current release baseline and 2026 security changes.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.