Hardening Containers With Capabilities, seccomp, AppArmor, and User Namespaces
A default container shares the host kernel and starts with more privilege than most workloads need. A layer-by-layer guide: dropping capabilities, no-new-privileges, seccomp, AppArmor and SELinux, read-only filesystems, and user namespaces — and how to verify each.
Containers isolate processes using kernel features — namespaces for visibility, cgroups for resources — but every container on a host shares one kernel. A container escape is typically a kernel bug or a misconfiguration reached through the kernel's attack surface. Hardening, then, is about two things: reducing the privilege a containerised process holds, and reducing the kernel surface it can touch.
Docker's defaults are a reasonable baseline for trusted workloads. For multi-tenant platforms and anything running untrusted or agent-generated code, they should be the starting point, not the end state. This guide walks the layers from cheapest to most involved, with a verification step for each.
For when a hardened container isn't enough and you need a stronger boundary altogether, see Firecracker vs gVisor vs Containers.
Layer 0: don't undo the defaults
Before adding anything, remove the configurations that negate isolation outright:
--privilegedgrants all capabilities, disables seccomp and LSM confinement, and exposes host devices. It is effectively root on the host.- Mounting the Docker socket (
/var/run/docker.sock) lets the container start a privileged container of its own: one step to host root. - Host namespaces —
--pid=host,--net=host,--ipc=host— remove the corresponding isolation. - Sensitive host mounts —
/,/proc,/sys,/etc, home directories. - Added capabilities like
SYS_ADMIN"to make something work."
Audit for these first. Every other layer is moot if one of them is present.
Layer 1: run as a non-root user
Root inside a default container is UID 0 on the host kernel, constrained by capabilities, seccomp, and LSMs. A non-root user removes a whole class of escalation paths and makes the remaining layers stronger.
USER 10001:10001
or at runtime with --user. Verify: docker exec <c> id should not report uid=0. (Writing a Production Dockerfile for a Node.js App covers doing this in the image.)
Layer 2: drop capabilities
Linux splits root's power into capabilities. Docker grants a default subset — including CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID, NET_RAW, NET_BIND_SERVICE, KILL, MKNOD, SYS_CHROOT, and a few others. Most application workloads need none of them.
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE ...
Drop everything and add back only what a specific workload demonstrably needs. NET_RAW in particular enables raw sockets (packet crafting, some spoofing attacks) and is rarely needed.
Verify from inside the container:
grep Cap /proc/self/status
# CapEff: 0000000000000000 ← no effective capabilities
(capsh --decode=<hex> translates the masks, if available.)
Layer 3: no new privileges
setuid binaries can raise privileges at exec time. The no_new_privs flag prevents any process in the container from gaining privileges it didn't start with:
docker run --security-opt no-new-privileges ...
Verify: grep NoNewPrivs /proc/self/status → NoNewPrivs: 1.
Layer 4: seccomp — restrict system calls
seccomp filters which system calls a process may make. The kernel exposes hundreds; a typical web app uses a fraction. Every syscall that's blocked is kernel code an attacker can't reach.
Docker applies a default seccomp profile that blocks dozens of syscalls considered dangerous or unnecessary for containers — for example mount, reboot, kexec_load, keyctl/add_key, and module loading — while allowing enough for general workloads. Some calls are conditionally allowed only when a corresponding capability is present.
Two important points:
- Make sure it's on.
--privilegedor--security-opt seccomp=unconfineddisables it. In Kubernetes, the default historically was unconfined unless you setseccompProfile: RuntimeDefault(or enable the kubelet's default-seccomp option). - Tighter is possible. A custom profile allowing only the syscalls your application uses shrinks the surface much further. Profiles can be generated by tracing the workload under representative load (tools exist for this for both Docker and Kubernetes), then applied with
--security-opt seccomp=profile.json. The risk is breakage from rarely used code paths — generate from thorough test runs and keep a fallback.
Verify: grep Seccomp /proc/self/status → Seccomp: 2 means a filter is active (0 means none).
Layer 5: Linux Security Modules — AppArmor or SELinux
LSMs add mandatory access control: policy about which files, capabilities, and operations a process may use, enforced regardless of its UID.
- AppArmor (Debian/Ubuntu family): Docker loads a
docker-defaultprofile that denies writes to sensitive/procand/syspaths, mounting, and similar. Custom profiles can restrict file paths per workload:--security-opt apparmor=my-profile. - SELinux (RHEL/Fedora family): containers run in a confined type (commonly
container_t) with per-container MCS labels, so one container's processes can't access another's files even when UIDs match. Keep SELinux enforcing; the common "fix" of disabling it removes a genuinely strong layer.
Verify: cat /proc/self/attr/current inside the container shows the AppArmor profile or SELinux context in effect.
Layer 6: read-only root filesystem
Most application containers never need to write to their own filesystem. Making it read-only blocks dropping tools, modifying binaries, and many persistence techniques:
docker run --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m ...
Provide writable paths explicitly: tmpfs for scratch (counts against the memory limit — see How Container CPU and Memory Limits Actually Work), and named volumes for data that must persist (Docker Volumes vs Bind Mounts). noexec on scratch mounts blocks executing dropped payloads from them.
For development workspaces, where package managers must write, apply read-only to system paths and give the workspace a dedicated writable volume instead.
Layer 7: user namespaces
Even with everything above, UID 0 in a container is UID 0 in the kernel's eyes unless user namespaces remap it. With remapping, container root maps to an unprivileged, high-numbered UID on the host. An escape then lands as a nobody, not as root.
Options:
- Docker
userns-remap— a daemon-level setting mapping container UIDs to a subordinate range. - Rootless Docker or Podman — the entire runtime runs as an unprivileged user. Podman is rootless-first.
- Kubernetes user namespaces — supported per pod (
hostUsers: false) in recent versions, subject to runtime and kernel support.
Trade-offs: file ownership on bind mounts gets more complicated (idmapped mounts help on recent kernels), and some workloads that legitimately need host-level operations won't work. For multi-tenant hosts, the gain — escapes don't yield host root — is substantial.
Layer 8: resources and devices
- Limits: CPU, memory, and especially
pids-limitso a fork bomb stays contained. - No device access beyond defaults; avoid
--deviceunless required. - Network policy: default-deny egress where possible; block the cloud metadata endpoint. (Egress Control for AI Agents.)
Putting it together
A hardened docker run for a typical web workload:
docker run -d \
--user 10001:10001 \
--cap-drop=ALL \
--security-opt no-new-privileges \
--security-opt seccomp=/etc/docker/seccomp/app.json \
--security-opt apparmor=app-profile \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
--memory 1g --cpus 1 --pids-limit 256 \
--network app-internal \
app:1.4.2
The Kubernetes equivalent lives in securityContext:
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
seccompProfile: { type: RuntimeDefault }
— and the Pod Security Standards' restricted profile enforces most of this cluster-wide.
Verify, continuously
Configuration drifts; verification shouldn't. From inside a running container, a short script can assert:
id -u≠ 0CapEffis zero (or the expected minimal set)NoNewPrivs: 1Seccomp: 2- the expected LSM profile in
/proc/self/attr/current - writes to
/fail /var/run/docker.sockdoesn't exist- the metadata endpoint is unreachable
Run it as a startup self-check or a periodic job, and fail loudly on regression. Tools such as amicontained report much of this in one go.
The summary
| Layer | Control | Verify |
|---|---|---|
| 0 | No privileged, no socket, no host namespaces | Inspect config |
| 1 | Non-root user | id -u |
| 2 | --cap-drop=ALL |
CapEff |
| 3 | no-new-privileges |
NoNewPrivs |
| 4 | seccomp (default or custom) | Seccomp: 2 |
| 5 | AppArmor / SELinux | /proc/self/attr/current |
| 6 | Read-only rootfs | write to / fails |
| 7 | User namespaces | host UID of container root |
| 8 | Limits, devices, egress | cgroup files, network tests |
EasySpawn runs every workspace in its own container as a non-root user, with enforced CPU, memory, and PID limits and no access to the host filesystem, the Docker socket, or other tenants. See how it works or join the waitlist.
Related: Docker vs Linux Users for Multi-Tenant Isolation · Prompt Injection in Coding Agents · Rootless Containers and User Namespaces · Container Networking Internals
Keep reading
Rootless Containers and User Namespaces: What They Actually Protect
Root in a container is root on the host unless something remaps it. How user namespaces work, subuid/subgid ranges, Docker userns-remap vs rootless mode vs Podman, Kubernetes hostUsers: false, the file-ownership and networking costs, and where rootless fits.
Prompt Injection in Coding Agents: A Threat Model
A coding agent with a shell, credentials, and network access reads text written by strangers all day. A threat model — sources, capabilities, sinks — why detection-based defences fail, and the architectural controls that actually bound the damage.