Docker Security Hardening¶
Overview¶
Containers are not virtual machines. They share the host kernel and isolate workloads with namespaces, cgroups, and security profiles — but misconfiguration erodes that isolation quickly. Running as root, mounting the Docker socket, granting CAP_SYS_ADMIN, or leaving writable root filesystems turns a container escape or RCE into a host compromise.
This tutorial applies defense in depth for Docker workloads: non-root users, read-only root filesystems, dropping Linux capabilities, custom seccomp profiles, and complementary controls (no new privileges, resource limits, user namespaces overview). You will harden a sample container incrementally and validate each layer.
This is Tutorial 14 in Module 5: Operations of the REBASH Academy Docker series. Pair with Linux Security Hardening Basics for host-level controls.
Prerequisites¶
- Docker Engine on Linux (security options vary on Docker Desktop)
- Ability to build images — see Building Images with Dockerfile
- Understanding of File Permissions and Ownership
- Lab environment only — aggressive hardening can break applications
Learning Objectives¶
By the end of this tutorial, you will be able to:
- Run containers as non-root UIDs with USER in Dockerfile and runtime overrides
- Enable read-only root filesystems with tmpfs for writable paths
- Drop all capabilities and add back only required caps
- Apply default and custom seccomp profiles to restrict syscalls
- Combine
--security-opt,--cap-drop, and--read-onlyfor layered hardening - Explain limits of container security and when to add AppArmor/SELinux or Kubernetes policies
Architecture Diagram¶
Security layers stack from the host kernel upward. Each runtime flag removes attack surface without replacing host patching or network segmentation.
flowchart TB
HOST[Host kernel + patches]
SECCOMP["seccomp profile<br/>syscall filter"]
CAPS["Linux capabilities<br/>CAP_DROP / CAP_ADD"]
USER["Non-root UID / GID"]
RO[Read-only rootfs + tmpfs]
APP[Application code]
HOST --> SECCOMP
SECCOMP --> CAPS
CAPS --> USER
USER --> RO
RO --> APP```
## Theory
### Container Threat Model
Assume attackers can:
- Exploit application vulnerabilities (RCE, SSRF, deserialization)
- Read environment variables and mounted secrets if present
- Attempt privilege escalation via misconfigured capabilities
- Probe for Docker socket mounts or host path mounts
Container hardening **reduces blast radius** — it does not replace patching, image scanning, network firewalls, or secrets management.
### Run as Non-Root
By default, many images run as **root (UID 0)** inside the container. Root in a container is not host root, but it can:
- Write to package managers and misconfigured volumes
- Exploit kernel bugs more effectively (historical container escapes)
- Bind privileged ports below 1024 (sometimes needed — use higher ports instead)
**Dockerfile pattern:**
```dockerfile
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser Runtime override (image must support it — files owned correctly):
Kubernetes equivalent: securityContext.runAsNonRoot: true and runAsUser.
Ensure application directories are writable by the non-root UID before switching USER.
Read-Only Root Filesystem¶
--read-only mounts the container root filesystem as read-only. Applications that write to /tmp, caches, or PID files need tmpfs or volume mounts:
docker run --read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /var/run:rw,noexec,nosuid,size=16m \
myapp
Benefits:
- Prevents malware persistence modifying binaries inside the container
- Limits accidental config overwrites
- Forces explicit design of writable paths
Combine with immutable infrastructure — replace containers instead of patching inside them.
Linux Capabilities¶
Traditional Unix root bundles dozens of privileges. Linux capabilities split them (e.g., CAP_NET_BIND_SERVICE, CAP_SYS_TIME). Docker drops several by default but still grants a subset.
Drop all, add minimum:
Common dangerous capabilities to avoid granting:
| Capability | Risk |
|---|---|
CAP_SYS_ADMIN | Near-root; mount, namespace manipulation |
CAP_SYS_PTRACE | Debug/trace other processes |
CAP_NET_RAW | Raw sockets; packet crafting |
CAP_DAC_READ_SEARCH | Bypass file read permissions |
Default Docker capability set is documented in Docker security docs — treat --cap-drop=ALL as the baseline for production services that do not need special caps.
No New Privileges¶
--security-opt no-new-privileges:true prevents processes from gaining additional privileges via setuid binaries or file capabilities. Essential for multi-process images where you cannot trust every binary.
seccomp Profiles¶
seccomp (secure computing mode) filters syscalls a process may invoke. Docker applies a default seccomp profile that blocks dangerous syscalls (e.g., kexec, reboot, mount in many cases).
Options:
- Default profile — enabled automatically on most installs
- Unconfined — disables seccomp (avoid except debugging):
--security-opt seccomp=unconfined - Custom JSON profile — whitelist syscalls for minimal apps
Custom profile sketch (illustrative — validate with your app):
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"names": ["read", "write", "exit", "exit_group", "futex", "clock_gettime"],
"action": "SCMP_ACT_ALLOW"
}
]
}
Apply:
Overly strict profiles break glibc, DNS resolution, and JIT compilers — test thoroughly.
AppArmor and SELinux (Overview)¶
On supported distros, Docker applies AppArmor (docker-default) or SELinux labels. Custom profiles confine filesystem and network access beyond seccomp. Kubernetes Pod Security Standards (restricted profile) apply similar constraints at orchestrator level.
Image and Supply Chain¶
Hardening runtime without fixing images is incomplete:
- Use minimal bases (
distroless,alpinewith caution,scratchfor static binaries) - Scan images in CI (Trivy, Grype, ECR/AR scanning)
- Pin base image digests in Dockerfile
FROM - Sign images with cosign/notary where supported
See Container Registries and Distribution for digest pinning.
Hands-on Lab¶
Harden a simple nginx container step by step.
Step 1 – Baseline: root nginx¶
Command:
docker run -d --name sec-lab-base -p 8090:80 nginx:1.27-alpine
docker exec sec-lab-base id
docker exec sec-lab-base touch /test-write 2>&1 || true
docker rm -f sec-lab-base
Explanation: Stock nginx image runs as root. Writing to / succeeds — baseline attack surface.
Expected output:
Step 2 – Run nginx as non-root user¶
Official nginx image includes user nginx (UID 101).
Command:
docker run -d --name sec-lab-user \
--user nginx:nginx \
-p 8091:8080 \
nginx:1.27-alpine
docker exec sec-lab-user id
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8091/
docker rm -f sec-lab-user
Explanation: nginx listens on 8080 in recent images for non-root. Map host 8091 to container 8080.
Expected output:
Step 3 – Read-only root filesystem¶
Command:
docker run -d --name sec-lab-ro \
--user nginx:nginx \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=32m \
--tmpfs /var/cache/nginx:rw,noexec,nosuid,size=32m \
--tmpfs /var/run:rw,noexec,nosuid,size=16m \
-p 8092:8080 \
nginx:1.27-alpine
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8092/
docker exec sec-lab-ro sh -c 'touch /etc/test 2>&1' || true
docker rm -f sec-lab-ro
Explanation: Writable paths nginx needs are tmpfs mounts. Writes to /etc fail.
Step 4 – Drop all capabilities¶
Command:
docker run -d --name sec-lab-caps \
--user nginx:nginx \
--read-only \
--cap-drop=ALL \
--tmpfs /tmp:rw,noexec,nosuid,size=32m \
--tmpfs /var/cache/nginx:rw,noexec,nosuid,size=32m \
--tmpfs /var/run:rw,noexec,nosuid,size=16m \
-p 8093:8080 \
nginx:1.27-alpine
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8093/
docker rm -f sec-lab-caps
Explanation: nginx does not need extra capabilities when not binding port 80 as root. If curl fails, add back specific caps with --cap-add.
Step 5 – Enable no-new-privileges¶
Command:
docker run -d --name sec-lab-nnp \
--user nginx:nginx \
--read-only \
--cap-drop=ALL \
--security-opt no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=32m \
--tmpfs /var/cache/nginx:rw,noexec,nosuid,size=32m \
--tmpfs /var/run:rw,noexec,nosuid,size=16m \
-p 8094:8080 \
nginx:1.27-alpine
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8094/
docker rm -f sec-lab-nnp
Explanation: Blocks setuid escalation paths inside the container.
Step 6 – Inspect seccomp and security options¶
Command:
docker run -d --name sec-lab-inspect \
--user nginx:nginx \
--cap-drop=ALL \
--security-opt no-new-privileges:true \
-p 8095:8080 \
nginx:1.27-alpine
docker inspect sec-lab-inspect | grep -A20 '"SecurityOpt"'
docker inspect sec-lab-inspect | grep -A10 '"CapDrop"'
docker rm -f sec-lab-inspect
Explanation: Verify runtime flags persisted in container config — matches deploy manifests in Swarm/Kubernetes translations.
Step 7 – Build a hardened custom image¶
Create /tmp/hardened-lab/Dockerfile:
FROM nginx:1.27-alpine
RUN chown -R nginx:nginx /var/cache/nginx /var/log/nginx /etc/nginx/conf.d
USER nginx
EXPOSE 8080
Command:
mkdir -p /tmp/hardened-lab && cd /tmp/hardened-lab
docker build -t hardened-nginx:lab .
docker run -d --name sec-lab-built \
--read-only \
--cap-drop=ALL \
--security-opt no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=32m \
--tmpfs /var/cache/nginx:rw,noexec,nosuid,size=32m \
--tmpfs /var/run:rw,noexec,nosuid,size=16m \
-p 8096:8080 \
hardened-nginx:lab
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8096/
docker rm -f sec-lab-built
cd /tmp && rm -rf hardened-lab
Explanation: Embedding USER nginx in the image prevents accidental root deploys when operators omit --user.
Commands & Code¶
| Flag / option | Description | Example |
|---|---|---|
--user | Run as UID:GID | --user 10001:10001 |
--read-only | Read-only root filesystem | With tmpfs for writable dirs |
--cap-drop=ALL | Drop all capabilities | Add back with --cap-add |
--security-opt no-new-privileges:true | Block privilege escalation | Recommended default |
--security-opt seccomp= | Custom seccomp JSON | Path to profile file |
--pids-limit | Limit process count | Mitigate fork bombs |
--memory / --cpus | Resource limits | Complements security |
Hardened run template¶
docker run -d \
--name myapp-prod \
--user 10001:10001 \
--read-only \
--cap-drop=ALL \
--security-opt no-new-privileges:true \
--pids-limit 100 \
--memory 512m \
--cpus 1.0 \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 8080:8080 \
myregistry.example.com/myapp@sha256:abc123...
Adjust tmpfs mounts and capabilities per application requirements.
Compose security block (Swarm-compatible fields)¶
services:
web:
image: myapp:1.0
read_only: true
user: "10001:10001"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:rw,noexec,nosuid,size=67108864
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
Plain docker compose on a single node supports most keys; validate with docker compose config.
Common Mistakes¶
Running production containers as root
Root inside a container simplifies attacks and violates Pod Security Standards. Always set USER in Dockerfile and enforce in orchestrators.
Read-only without tmpfs for app caches
Apps crash when they cannot write /tmp or cache dirs. Map each required writable path explicitly.
Using --privileged or --cap-add=SYS_ADMIN to fix issues
These bypass isolation. Fix the application port binding or mount needs instead of granting admin caps.
Disabling seccomp globally for convenience
seccomp=unconfined widens syscall surface. Use only during targeted debugging, never as default.
Best Practices¶
Apply CIS Docker Benchmark alignment
Map controls to non-root, read-only, capability drops, and logging — audit-friendly checklist for compliance.
Scan images and pin digests
Runtime hardening cannot fix malware baked into image layers. Scan in CI and deploy by digest.
Never mount /var/run/docker.sock into app containers
Socket access equals host-level container control. Use dedicated tooling with RBAC if Docker API access is required.
Test hardened configs in CI
Automated smoke tests with production security flags catch breakages before deploy.
Troubleshooting¶
| Issue | Cause | Solution |
|---|---|---|
| Permission denied on startup | Non-root cannot read files | chown in Dockerfile or init as root then drop (pattern: gosu/dumb-init) |
| nginx bind to 80 fails | Non-root cannot use port 80 | Listen on 8080+ or use CAP_NET_BIND_SERVICE consciously |
| Read-only container exits immediately | Missing tmpfs for cache/run | Add tmpfs for /tmp, /var/run, app-specific dirs |
| DNS resolution fails with custom seccomp | Syscalls blocked | Compare against default profile; allow connect, poll, etc. |
--cap-drop=ALL breaks Java apps | Needs specific syscalls/caps | Test; may need CHOWN, SETGID, or default profile |
| Cannot write logs | Read-only root | Log to stdout or mount volume for logs |
Summary¶
- Containers require explicit hardening — default images prioritize compatibility over least privilege
- Non-root users, read-only rootfs, capability drops, and seccomp stack to reduce blast radius
no-new-privilegesblocks setuid escalation; resource limits mitigate DoS- Custom seccomp profiles need application-specific testing — default profile is a sensible baseline
- Combine runtime controls with minimal images, scanning, and digest pinning for defense in depth
Interview Questions¶
- Why run containers as non-root even though they are isolated?
- What does
--read-onlydo, and how do apps write temporary files? - Explain Linux capabilities vs traditional root.
- What is seccomp, and what does Docker's default profile block?
- What does
--security-opt no-new-privileges:trueprevent? - Why is mounting the Docker socket dangerous?
- How would you harden an nginx container for production?
- What is the difference between AppArmor/SELinux and seccomp?
- When might you use
--cap-add=NET_BIND_SERVICE? - How do Kubernetes Pod Security Standards relate to Docker run flags?
Sample Answers (Questions 1, 4, and 7)
Q1 — Non-root rationale: Container isolation is defense in depth, not a guarantee. Root in a container can exploit kernel vulnerabilities, modify writable mounts, and access sensitive metadata. Non-root limits damage from RCE and aligns with compliance baselines (PCI, CIS).
Q4 — seccomp: seccomp filters syscalls. Docker's default profile blocks many privileged syscalls (mount, reboot, kexec_load, etc.) while allowing typical application needs. Custom profiles further restrict syscalls for minimal workloads but require testing to avoid breaking libc and DNS.
Q7 — nginx hardening: Use official image with USER nginx, listen on 8080, --read-only with tmpfs for cache and pid paths, --cap-drop=ALL, no-new-privileges:true, resource limits, structured logging to stdout, pinned image digest, and network policies restricting egress.
Related Tutorials¶
- Docker – Category Overview
- Container Logging and Monitoring (previous in Module 5)
- Troubleshooting Docker Containers (next in Module 5)
- Linux Security Hardening Basics
- Environment Variables and Secrets