Docker Container Security Hardening: Image and Runtime Controls
Containers are not a security boundary. Default Docker configs allow privilege escalation, escape, and resource exhaustion. Hardening checklist: minimal images, non-root runtime, dropped capabilities, and a read-only filesystem.
Docker container security hardening is baseline work before you ship cloud-native apps. Containers share the host kernel. By default, in-container processes run as root with a full capability set. If the app is compromised or an escape vuln exists (CVE-2022-0492, CVE-2026-series kernel vulns), an attacker reaches the host. This article gives a copy-paste plan across image build, runtime privileges, and resource limits.
Scope
- Teams deploying production services with Docker / Docker Compose
- Container environments that must pass MLPS or cloud security baseline checks and need hardening remediations
- Workloads facing container escape, privilege escalation, or resource-exhaustion risk
- Orgs migrating from VMs to containers that need a security standard
Prerequisites
- Host Docker 20.10+; `docker info` works
- Permission to build images and edit `docker-compose.yml` / Dockerfile
- Basic Linux (`chmod`, `useradd`) and container ops
How it works
Container risk concentrates in four layers:
- Image: extra toolchain, root, known-vulnerable base images (old Ubuntu) expand the attack surface.
- Shared kernel: container and host share a kernel. In-container root with full capabilities (e.g. `CAP_SYS_ADMIN`) can `mount` the host filesystem and escape.
- Write access: default FS is writable; attackers can drop a persistent backdoor. Mounting `/`, `/etc`, or the Docker socket sharply raises risk.
- Resource abuse: containers without CPU/memory limits can exhaust the host (in-container DoS).
Hardening follows least privilege, least image, least capability: non-root, drop capabilities, read-only FS, resource limits, plus image scanning to block vulnerable bases.
Steps
1. Minimal image build (multi-stage)
# Dockerfile example: multi-stage build + non-root user
# Stage 1: build (full toolchain)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime (artifacts and runtime only)
FROM node:20-alpine
# Create non-root user; do not run as root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev --ignore-scripts && chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
Key points: `USER appuser` is non-root; `--omit=dev` skips dev deps; alpine is small with a thin toolchain. Scan immediately after build with `docker scan` or Trivy.
2. Image security scanning
# Install Trivy (lightweight vulnerability scanner)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Scan local image (base image and dependency vulns)
trivy image your-image:latest
# HIGH+ only, JSON for CI
trivy image --severity HIGH,CRITICAL --format json your-image:latest
# Fail CI on HIGH+
trivy image --exit-code 1 --severity HIGH,CRITICAL your-image:latest
3. Runtime: drop capabilities and privileges
# docker run example: drop all capabilities, add back as needed
docker run -d --name app --cap-drop=ALL --cap-add=NET_BIND_SERVICE --security-opt=no-new-privileges --read-only --tmpfs /tmp -p 3000:3000 your-image:latest
# docker-compose.yml equivalent
services:
app:
image: your-image:latest
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
read_only: true
tmpfs: [/tmp]
Params: `--cap-drop=ALL` removes all kernel capabilities; `NET_BIND_SERVICE` allows binding low ports (80/443). `--read-only` makes the root FS read-only; writes go to `--tmpfs`. `no-new-privileges` blocks privilege elevation (setuid is a no-op).
4. Resource limits and security params
docker run -d --name app --memory=512m --memory-swap=512m --cpus=1.0 --pids-limit=256 --restart=on-failure:5 your-image:latest
# compose equivalent
services:
app:
image: your-image:latest
mem_limit: 512m
memswap_limit: 512m
cpus: 1.0
pids_limit: 256
Set `memory-swap` equal to `memory` to disable swap and reduce escape surface. `pids-limit` caps process count against fork bombs.
5. Never mount sensitive paths
# Wrong (never do this):
# docker run -v /:/host your-image # mount host root
# docker run -v /var/run/docker.sock:/var/run/docker.sock your-image # Docker socket = full host control
# Correct: mount only what the app needs, read-only
docker run -d --name app -v /data/config:/app/config:ro your-image:latest
6. Enable default seccomp and AppArmor
# Docker ships a default seccomp profile; confirm it is not disabled
docker run -d --name app --security-opt seccomp=default.json your-image:latest
# Inspect default seccomp config
docker info --format '{{.SecurityOptions}}'
# Expected output includes name=seccomp and name=apparmor
Verification
# 1. Confirm in-container user is non-root
docker exec app id
# Expected: uid=1000(appuser), not uid=0(root)
# 2. Confirm capabilities are dropped
docker exec app capsh --print 2>/dev/null | grep Current
# Expected: only allowlisted caps such as net_bind_service
# 3. Confirm read-only filesystem
docker exec app touch /root-test.txt
# Expected: read-only file system error
# 4. Confirm resource limits
docker inspect app --format '{{.HostConfig.Memory}}' # 536870912 (512m)
# 5. Image vuln scan
trivy image --severity HIGH,CRITICAL your-image:latest
FAQ
Q1: After cap-drop=ALL the container fails to start or misbehaves?
Usually a missing capability. Add from the error: low-port bind needs `NET_BIND_SERVICE`; packet capture/diagnostics need `NET_RAW`; writing logs needs `CHOWN`/`DAC_OVERRIDE`, not `SYS_ADMIN`. In a test env, `cap-add` one at a time until the app works, then record the allowlist. If the app needs a full cap set, split the service or switch to a runtime such as sysbox.
Q2: After read-only the app cannot write logs/cache?
Mount writable paths as tmpfs or volumes: logs `--tmpfs /var/log`, cache `--tmpfs /tmp`; persistent data with a named volume `-v appdata:/data`. `--tmpfs` is not persistent; it clears on restart—fine for log/cache.
Q3: How to check running containers for compliance?
Use Docker Bench for Security (official baseline checker):
# Run on the host (needs docker.sock access)
docker run -it --net host --pid host --userns host --cap-add audit_control -e DOCKER_CONTENT_TRUST=1 -v /var/lib:/var/lib -v /var/run/docker.sock:/var/run/docker.sock docker/docker-bench-security
# Output is PASS/WARN/FAIL per check; remediate WARN/FAIL items
Takeaway
Docker hardening is minimal image, non-root, dropped capabilities, resource limits. Multi-stage builds plus vuln scan. `USER` non-root cuts privilege-escalation paths. `--cap-drop=ALL` + `--read-only` shrink kernel caps and write surface. Memory/CPU/PID limits against resource abuse. Never mount Docker socket or other sensitive paths. Freeze this as org-level image templates and compose standards; run Docker Bench on a schedule. No silver bullet—defense in depth plus continuous scanning.