Why container hardening matters
Containers share the host kernel. A misconfigured container can escape into the host or pivot laterally across the cluster. Most default Docker settings prioritise convenience over security—flipping a handful of flags moves the risk profile considerably.
This post covers the three layers that matter most:
- Image hygiene — what goes into the image
- Runtime flags — what the container is allowed to do
- Resource limits — blast radius containment
Image hygiene
Use a minimal base
Start from the smallest image that actually runs your workload.
# Before — full Debian image, ~120 MB and hundreds of packages
FROM python:3.12
# After — distroless, ~20 MB with no shell, no package manager
FROM gcr.io/distroless/python3-debian12No shell means an attacker who gains code execution can't bash their way around the filesystem.
Drop build tools in multi-stage builds
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM gcr.io/distroless/python3-debian12
COPY --from=build /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=build /app .
ENTRYPOINT ["python", "main.py"]The final image never contains pip, gcc, or any build artifact.
Runtime flags
The table below shows the flags worth setting for most production workloads:
| Flag | What it does |
|---|---|
--read-only | Mounts the root filesystem read-only |
--no-new-privileges | Prevents privilege escalation via setuid binaries |
--cap-drop ALL | Drops all Linux capabilities |
--cap-add NET_BIND_SERVICE | Re-adds only what you actually need |
--security-opt no-new-privileges | Belt and braces with the daemon |
A compose snippet that puts this together:
services:
api:
image: myapp:latest
read_only: true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
- /runThe tmpfs entries let the app write to /tmp and /run even though the root filesystem is read-only.
Resource limits
CPU and memory
deploy:
resources:
limits:
cpus: "0.50"
memory: 256M
reservations:
memory: 64MWithout limits, a single container can starve every other workload on the host.
Ulimits
ulimits:
nofile:
soft: 1024
hard: 2048
nproc: 64nproc: 64 caps fork bombs before they bring down the host.
Verifying your work
Scan the final image with Trivy before pushing:
trivy image --severity HIGH,CRITICAL myapp:latestAnd check your runtime config with Docker Bench:
docker run --rm --net host --pid host --userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /etc:/etc:ro \
-v /lib/systemd/system:/lib/systemd/system:ro \
-v /usr/bin/containerd:/usr/bin/containerd:ro \
-v /usr/bin/runc:/usr/bin/runc:ro \
-v /usr/lib/systemd:/usr/lib/systemd:ro \
-v /var/lib:/var/lib:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--label docker_bench_security \
docker/docker-bench-securityA score above 4.5 on CIS Docker Benchmark items is a reasonable baseline before going to production.