Docker & ContainersXXXV · Production HardeningHardening
Host hardening — the production baseline
What you'll learn
- Map each hardening control to the specific attack step it interrupts
- Apply host, daemon, image and runtime controls in the right order
- Recognise generic hardening advice that breaks Docker
- Prove a control is active rather than configured
- Judge which controls are safe to apply in place and which need a rebuild
Prerequisites
None — start here.
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
Hardening is defence in depth, and the phrase gets used as if it meant “apply many controls”. It means something more specific: an attacker has to complete a sequence of steps, and each control makes one step of that sequence fail.
That framing is the difference between a checklist people apply and one they
argue with. “Drop all capabilities” invites a debate. “Drop all
capabilities, because CAP_SYS_ADMIN in a container is a mount syscall away
from the host filesystem” ends it.
The sequence a control interrupts
Almost every container compromise follows the same shape:
flowchart LR
A[Code execution<br/>in the container] --> B[Escalate to root<br/>inside the container]
B --> C[Break out to<br/>the host]
C --> D[Reach other<br/>containers or hosts]
The controls map onto it directly, and that mapping is the whole lesson.
| Control | Step it breaks | The attack it stops |
|---|---|---|
Non-root USER in the image | A to B | Attacker starts unprivileged; most local escalations need root in the namespace first |
--security-opt no-new-privileges=true | A to B | A setuid binary inside the image cannot raise privileges |
--cap-drop=ALL | B to C | Root in the container holds no capability that reaches the host |
| Default seccomp profile | B to C | Blocks the syscalls used by most kernel exploits |
| AppArmor or SELinux | B to C | Confines file and syscall access even for a container root |
--read-only root filesystem | A to B | Attacker cannot write a payload, a cron file or a modified binary |
userns-remap or rootless mode | C | Container root maps to an unprivileged host UID; a breakout lands as nobody |
Not mounting /var/run/docker.sock | B to C | The socket is the daemon API, and the daemon is root |
User-defined networks, icc: false | C to D | Lateral movement between containers |
| Host firewall | C to D | Lateral movement between hosts |
Read down the “step it breaks” column. Three controls guard the A-to-B transition and four guard B-to-C, which is deliberate — those are the two transitions where a defence actually costs the attacker something.
Layer 1: the host
sudo tee /etc/sysctl.d/60-docker-hardening.conf >/dev/null <<'EOF'
# Hide kernel pointers from unprivileged users. Defeats the address leaks
# that kernel exploits use to defeat KASLR.
kernel.kptr_restrict = 1
# Only privileged users can read the kernel ring buffer. dmesg leaks
# addresses, driver versions and host details useful for targeting.
kernel.dmesg_restrict = 1
# Restrict ptrace to parent-child only. Blocks a compromised process from
# reading another process memory in the same namespace.
kernel.yama.ptrace_scope = 1
# Refuse to follow symlinks and hardlinks into files you do not own in
# world-writable sticky directories. Kills a whole class of /tmp races.
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
fs.protected_fifos = 1
fs.protected_regular = 2
# Bound the number of user namespaces an unprivileged user can create.
# Zero would be stronger and breaks rootless Docker and userns-remap;
# a bound keeps the feature while limiting exploit surface.
user.max_user_namespaces = 15000
EOF
sudo sysctl --system
# Evidence: read the values back from the running kernel, not the file.
sysctl kernel.kptr_restrict kernel.dmesg_restrict kernel.yama.ptrace_scopeThree corrections to advice that circulates widely:
user_namespace.enable=1is not a sysctl. It was a kernel boot parameter on RHEL 7, where user namespaces were disabled by default. On a current kernel it does nothing at all, and writing it into/etc/sysctl.d/produces an error at boot that most people never see. The relevant modern knob isuser.max_user_namespaces.kernel.yama.ptrace_scope = 3is not simply “more secure”. Level 3 disablesptraceentirely and cannot be lowered without a reboot. You losegdb,strace,perfattach and any crash dumper, permanently, including during the incident where you need them. Level 1 stops cross-process reads and keeps debugging a parent’s own children. Choose 3 deliberately, not by reflex.- Blocking filesystem modules (
cramfs,squashfs,udf) withinstall <module> /bin/truein/etc/modprobe.d/is a genuine CIS control, but name the file after what it does. A file calleddisable-cracklib.confcontaining acramfsdirective is how a control gets removed by someone tidying up. And check first:squashfsis used by snap, so blocking it on Ubuntu can break packages you rely on.
Layer 2: the daemon
{
"icc": false,
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true,
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
},
"default-ulimits": {
"nofile": { "Name": "nofile", "Hard": 8192, "Soft": 4096 }
},
"builder": {
"gc": { "enabled": true, "defaultKeepStorage": "20GB" }
}
}Two entries commonly found in baselines are absent here, and their absence is the point.
"features": {"buildkit": true}is obsolete. BuildKit has been the default builder since Engine 23, and the flag is no longer part of the documentedfeaturesblock — which today carries keys such ascontainerd-snapshotterandcdi. Leaving a stale key indaemon.jsonis harmless in itself and corrosive in aggregate: it teaches readers of the file that its contents are not maintained."selinux-enabled": trueonly applies where SELinux exists. On a Debian or Ubuntu host running AppArmor it is not the control you want, and a baseline that carries it unconditionally produces a FAIL in every audit on half your fleet. Confirm which mandatory access control system the host actually runs before configuring for one.
Layer 3: the image
# Build stage omitted. The runtime stage is where the controls live.
FROM gcr.io/distroless/static-debian12:nonroot
# Non-root by UID, not by name. A numeric UID is what the kernel enforces,
# and it survives an image rebuild that reorders /etc/passwd.
USER 65532:65532
WORKDIR /app
COPY --from=build --chown=65532:65532 /app/binary /app/binary
# EXPOSE is documentation. It publishes nothing and enforces nothing.
EXPOSE 8080
ENTRYPOINT ["/app/binary"]
A distroless base has no shell, no package manager, no curl, no wget.
That matters at a precise point in the attack chain: an attacker who has
achieved code execution through a deserialisation bug or an SSRF usually
needs to stage — download a second-stage payload, or spawn a shell to run
a series of commands. In an image with no shell and no download tool, that
step has to be done entirely within the exploited process, which is a
significantly higher bar.
It also has a real operational cost that should be stated: you cannot
docker exec into it to debug. The answer is docker debug on Docker
Desktop, or attaching an ephemeral debug container that shares the target’s
namespaces — not adding a shell back to the production image.
Layer 4: the container runtime
docker run -d --name api \
--user 65532:65532 \
--read-only \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges=true \
--pids-limit 256 \
--memory 512m \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--publish 127.0.0.1:8080:8080 \
--network app-net \
registry.example.com/myorg/api:1.0.0--cap-add NET_BIND_SERVICEonly if the process binds below port 1024. If your application listens on 8080, drop this too — the container needs no capabilities at all, which is the goal.--pids-limit 256bounds the process table for this container. A fork bomb inside a container without it exhausts the host’s PID space and takes down every other container on the machine, including ones that are otherwise perfectly hardened. It is the cheapest control on this list and the most often omitted.--tmpfs /tmp:rw,noexec,nosuid. A read-only root filesystem needs somewhere writable;noexecmeans the attacker cannot write a binary there and run it, which is exactly the staging step--read-onlywas meant to block.--publish 127.0.0.1:8080:8080. Publishing without an address binds to0.0.0.0on every interface, and — this surprises people — Docker’s forwarding rules are consulted before most host firewall rules, so a published port can be reachable from the network even when the firewall appears to deny it. Bind to a specific address, always.
Proving it, rather than configuring it
$ docker ps -q | while read -r C; do
docker inspect --format '{{.Name}} priv={{.HostConfig.Privileged}} user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}} pids={{.HostConfig.PidsLimit}} caps={{.HostConfig.CapDrop}}' "$C"
done/api priv=false user=65532:65532 ro=true pids=256 caps=[ALL]
/legacy-batch priv=true user= ro=false pids=<nil> caps=[]
/adminer priv=false user= ro=false pids=<nil> caps=[]Illustrative output
Two findings in three containers, and both are the shape you will actually
meet: a --privileged container that someone added years ago to work around
a device permission, and a container running as root with no limits because
it was started with a docker run from a wiki page.
The audit is worth more than the configuration, because a baseline applies to containers created after it. Nothing retrofits.
Docker Bench for Security
docker run --rm --net host --pid host --userns host \
--cap-add audit_control \
-v /etc:/etc: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-security- Write the baseline down with the attack each control interrupts. A control without a stated reason gets removed by the next person who finds it inconvenient.
- Apply image and runtime controls first, per service, in staging. They are the cheapest to test and cover the two most valuable transitions.
- Apply daemon controls next, and restart. Nothing in daemon.json is in effect until the daemon reads it.
- Apply host sysctls last, and reboot once to confirm they persist. Check ip_forward is still 1 afterwards.
- Audit running containers, not the configuration. Controls apply at creation; existing containers keep what they were born with.
- Re-audit quarterly and keep the output. The diff between the baseline and the audit is the artefact that matters, not the baseline.
- Never resolve a breakage by removing a control. Find the specific capability, path or device required and grant exactly that.
Knowledge check
Knowledge check · 6 questions
Q1. A container has the Docker socket bind-mounted with `:ro`. What does the `:ro` prevent?
Q2. A generic Linux hardening playbook sets `net.ipv4.ip_forward = 0` on a Docker host. What happens?
Q3. Which of these are in Docker default capability set and worth dropping? Select all that apply.
Q4. Why is `userns-remap` excluded from a baseline applied to existing production hosts?
Q5. Setting `kernel.yama.ptrace_scope = 3` is strictly better than 1 for a production Docker host.
Q6. An application that will not start under `--cap-drop ALL` should have the flag removed for that service.
Passing score: 75%. Answers are checked in this browser.