Reduce the blast radius of every container to the absolute minimum.

1. The Threat Model

Before applying controls, understand what you're defending against:

  • Container escape — attacker breaks out of container namespaces/cgroups into the host.
  • Privilege escalation — process inside the container gains root or additional capabilities.
  • Data exfiltration — sensitive data (secrets, databases) leaves the container via network or volume.
  • Supply chain attack — compromised base image or dependency delivers malware at build time.
  • Crypto mining / resource abuse — attacker hijacks compute for their own workload.
Containers share a kernel. A kernel exploit inside any container breaks ALL isolation on that host. This is why defense in depth is not optional — every layer you add independently reduces risk.

Defense in depth means: even if one layer fails (e.g., a zero-day kernel bug bypasses seccomp), other layers (non-root, read-only filesystem, dropped capabilities) still limit the damage.

2. Running as Non-Root

By default, a container's main process runs as root (UID 0). If an attacker escapes the container while running as root, they may be root on the host (unless user namespaces remap UIDs).

The Fix: USER Instruction

# Dockerfile
FROM node:20-slim

# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser

WORKDIR /app
COPY --chown=appuser:appuser . .
RUN npm ci --omit=dev

# Switch to non-root BEFORE CMD
USER 1000

CMD ["node", "server.js"]

Best Practices

  • Use numeric UIDsUSER 1000 not USER appuser. Numeric IDs are unambiguous across images and registries.
  • Fix file permissions — use --chown on COPY/ADD, or RUN chown before switching user.
  • Don't bind to port < 1024 — non-root can't bind privileged ports. Use port 8080+ and remap externally.
  • Verify at runtime: docker run myimage id should print uid=1000.
# Override at runtime if image doesn't set USER
docker run --user 1000:1000 myimage

3. Read-Only Filesystem

A read-only root filesystem prevents an attacker from writing malware, modifying configs, or tampering with binaries inside the container.

# Run with read-only root filesystem
docker run --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /var/run:rw,noexec,nosuid \
  myimage

What It Prevents

  • Installing additional tools (wget, curl payloads)
  • Modifying application configs or credentials files
  • Writing cron jobs or startup scripts for persistence
  • Dropping backdoor binaries

Handling Apps That Need Writes

Add --tmpfs mounts for directories your app legitimately writes to (temp files, pid files, caches). These are in-memory and vanish when the container stops. For persistent data, use explicit named volumes mounted at specific paths.

4. Dropping Capabilities

Linux capabilities split the monolithic "root" power into ~40 fine-grained privileges. Docker drops many by default but keeps several that most containers don't need.

The Minimal Approach

# Drop ALL, then add back only what you need
docker run \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  myimage

Common Capabilities Reference

Capability What It Allows Recommendation
NET_BIND_SERVICE Bind to ports < 1024 Keep only if needed
CHOWN Change file ownership Drop — set ownership at build time
DAC_OVERRIDE Bypass file permission checks Drop — fix permissions instead
SETUID / SETGID Change process UID/GID Drop — no reason in single-user container
NET_RAW Raw sockets (ping, packet crafting) Drop — used for ARP spoofing attacks
SYS_ADMIN Mount filesystems, configure namespaces Always drop — almost equivalent to full root
SYS_PTRACE Trace/debug other processes Drop — enables process injection
NET_ADMIN Network configuration, iptables Drop unless building a network tool
SYS_TIME Set system clock Drop — can break logging/TLS
MKNOD Create device files Drop — container shouldn't create devices

5. Seccomp Profiles

Seccomp (Secure Computing Mode) restricts which system calls a process can make. Docker applies a default seccomp profile that blocks approximately 44 dangerous syscalls.

What the Default Blocks

  • mount / umount2 — prevent filesystem manipulation
  • reboot — can't restart the host
  • kexec_load — can't load a new kernel
  • ptrace — can't debug/inject into other processes
  • unshare / clone (with new namespaces) — can't create nested containers easily

Custom Seccomp Profiles

# Use a custom profile (JSON allowlist)
docker run --security-opt seccomp=my-profile.json myimage

# Verify default is active
docker run --rm alpine grep Seccomp /proc/1/status
# Output: Seccomp:  2  (means filter mode active)

What Happens When a Syscall Is Blocked

The kernel returns EPERM (Operation not permitted) or kills the process with SIGSYS, depending on the profile's default action. The container sees a permission error — it does not crash silently.

Debugging tip: Run with --security-opt seccomp=unconfined temporarily to confirm seccomp is causing an issue, then add only the needed syscalls to your custom profile.

6. AppArmor & SELinux

These are Mandatory Access Control (MAC) systems enforced by the kernel — they restrict what a process can do even if it's running as root.

Docker's Default AppArmor Profile

  • Denies writing to /proc and /sys (prevents kernel parameter changes)
  • Denies mounting filesystems
  • Restricts signal sending to other containers
  • Limits access to sensitive /proc entries
# Check active profile
docker inspect --format='{{.AppArmorProfile}}' my_container

# Use a custom AppArmor profile
docker run --security-opt apparmor=my-custom-profile myimage

# On SELinux systems
docker run --security-opt label=type:my_container_t myimage

When to Write Custom Profiles

  • Your app needs to access specific paths outside the norm
  • You want to deny network access entirely for a batch-processing container
  • Compliance requires documented access control policies

7. No New Privileges

The no-new-privileges flag prevents any process inside the container from gaining additional privileges via setuid/setgid binaries, capability inheritance, or other mechanisms.

docker run --security-opt=no-new-privileges myimage

Why It Matters

Even if you run as non-root, a setuid binary (like su, sudo, or ping) inside the container could allow privilege escalation. With no-new-privileges:

  • Setuid bits are ignored — su can't switch to root
  • Ambient capabilities can't be gained
  • Execve won't grant new privileges even if the binary has capabilities set
Always enable this. There's rarely a legitimate reason for a container process to escalate privileges at runtime. If your app needs capabilities, grant them explicitly at container start.

8. Resource Limits as Security

Resource limits aren't just for performance — they're a security boundary that prevents denial-of-service attacks against the host.

# Comprehensive resource limits
docker run \
  --memory=256m \
  --memory-swap=256m \
  --cpus=0.5 \
  --pids-limit=64 \
  --ulimit nofile=1024:1024 \
  --ulimit nproc=64:64 \
  myimage
Limit Attack Prevented Flag
Memory OOM-killing other containers/host --memory=256m
CPU Crypto mining consuming all cores --cpus=0.5
PIDs Fork bombs (exponential process creation) --pids-limit=64
Open files File descriptor exhaustion --ulimit nofile=1024:1024
Swap disabled Thrashing the host disk --memory-swap=256m (same as memory)

Defense-in-Depth Layers

Resource Limits & PID Limits AppArmor / SELinux (MAC) Seccomp Profile (syscall filter) Dropped Capabilities (--cap-drop=ALL) Read-Only Filesystem Non-Root User (USER 1000) Minimal Base Image (distroless / scratch / alpine) Your Application Code

Each layer is independent — even if one layer is bypassed, the others still restrict the attacker.

Knowledge Check

Quiz 1: Why Non-Root?

Why is running as non-root critical even though containers have namespace isolation?

  • Non-root containers are faster because they skip permission checks
  • Docker requires non-root for network access
  • If an attacker escapes the container (kernel exploit), they'd be root on the host
  • Non-root prevents the container from using any Linux capabilities

Quiz 2: Capabilities

What does --cap-drop=ALL --cap-add=NET_BIND_SERVICE achieve?

  • Removes all network access except DNS
  • Removes all Linux capabilities except the ability to bind to ports below 1024
  • Drops all file permissions and adds network service discovery
  • Disables all security and re-enables only basic service functionality

Quiz 3: Seccomp Behavior

What happens when a container process attempts a syscall that's blocked by the seccomp profile?

  • The syscall silently succeeds but does nothing
  • Docker automatically restarts the container
  • The kernel returns EPERM or kills the process with SIGSYS
  • The syscall is logged but allowed to proceed

Hands-On Tasks

Task 1: Read-Only Container with Dropped Caps

Run a container that's read-only with all capabilities dropped, and prove it can't do dangerous things:

# Run hardened Alpine
docker run --rm -it \
  --read-only \
  --cap-drop=ALL \
  --tmpfs /tmp:rw,noexec,size=32m \
  alpine sh

# Inside the container, try these:
touch /etc/hacked          # FAILS: Read-only filesystem
apk add curl               # FAILS: Can't write to package dirs
ping 8.8.8.8               # FAILS: No NET_RAW capability
mount -t tmpfs none /mnt   # FAILS: No SYS_ADMIN capability
ip link set eth0 down      # FAILS: No NET_ADMIN capability

# But this works:
echo "hello" > /tmp/ok     # OK: tmpfs is writable
ls /etc/passwd             # OK: reads still work
wget --spider http://example.com  # OK: outbound TCP still works

Task 2: Default vs. Fully-Hardened Comparison

Compare what's possible in a default container versus a fully-hardened one:

# DEFAULT (insecure) — see what's possible
docker run --rm alpine sh -c '
  echo "=== Default Container ==="
  id
  cat /proc/1/status | grep -i cap
  touch /root/test && echo "Write: OK" || echo "Write: BLOCKED"
  ping -c1 127.0.0.1 > /dev/null 2>&1 && echo "Ping: OK" || echo "Ping: BLOCKED"
  mount -t tmpfs none /mnt 2>/dev/null && echo "Mount: OK" || echo "Mount: BLOCKED"
'

# HARDENED — all security options applied
docker run --rm \
  --user 1000:1000 \
  --read-only \
  --cap-drop=ALL \
  --security-opt=no-new-privileges \
  --security-opt seccomp=default \
  --memory=128m \
  --pids-limit=32 \
  --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  alpine sh -c '
  echo "=== Hardened Container ==="
  id
  cat /proc/1/status | grep -i cap
  touch /root/test 2>/dev/null && echo "Write: OK" || echo "Write: BLOCKED"
  ping -c1 127.0.0.1 > /dev/null 2>&1 && echo "Ping: OK" || echo "Ping: BLOCKED"
  mount -t tmpfs none /mnt 2>/dev/null && echo "Mount: OK" || echo "Mount: BLOCKED"
'

Expected: the hardened container blocks writes, ping, and mount — while the default allows writes and ping.

Industry Callout: Cloud Provider Enforcement

Kubernetes Pod Security Admission (PSA) enforces security at the namespace level with three profiles:
  • Privileged — no restrictions (for system components only)
  • Baseline — prevents known privilege escalations (no hostNetwork, no privileged containers)
  • Restricted — full hardening (non-root, read-only root, dropped caps, no privilege escalation)

AWS ECS enforces task-level IAM roles, preventing containers from accessing the instance metadata service directly. GCP Cloud Run runs containers as non-root in a gVisor sandbox by default.

Not Just Docker

Alternative runtimes provide stronger isolation:
  • Podman — rootless by default. Runs the entire container engine as a non-root user. No daemon. User namespace remapping is automatic.
  • gVisor (runsc) — intercepts syscalls in a user-space kernel. Containers never directly touch the host kernel. Used by GCP Cloud Run.
  • Kata Containers — runs each container in a lightweight VM with its own kernel. Full kernel isolation without sharing. Used in multi-tenant environments.
  • Firecracker — microVM technology (powers AWS Lambda/Fargate). Sub-second boot, minimal attack surface.

Key Takeaways

  • Containers share a kernel — namespace isolation is not a security boundary against kernel exploits. Defense in depth is mandatory.
  • Always run non-root — use USER 1000 in Dockerfiles. Verify with docker run myimage id.
  • Make the filesystem read-only — add --read-only and explicit --tmpfs mounts only where needed.
  • Drop all capabilities — start with --cap-drop=ALL and add back only what's proven necessary.
  • Keep seccomp and AppArmor active — never run --privileged or seccomp=unconfined in production.
  • Enable no-new-privileges — prevents setuid escalation inside containers.
  • Set resource limits — memory, CPU, and PID limits are security boundaries, not just operational knobs.
  • Combine all layers — no single mechanism is sufficient. Apply them all systematically.

The Complete Hardened Command

# Production-ready hardened container
docker run -d \
  --name my-secure-app \
  --user 1000:1000 \
  --read-only \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --security-opt=no-new-privileges \
  --security-opt seccomp=default \
  --memory=256m \
  --memory-swap=256m \
  --cpus=0.5 \
  --pids-limit=64 \
  --ulimit nofile=1024:1024 \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --health-cmd="curl -f http://localhost:8080/health || exit 1" \
  --restart=unless-stopped \
  myimage:latest