Skip to main content
RunBook Academy

Docker & ContainersXXXV · Production HardeningHardening

Host hardening — the production baseline

Advanced⏱ ~30 min

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

Not yet marked complete on this device.

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.

ControlStep it breaksThe attack it stops
Non-root USER in the imageA to BAttacker starts unprivileged; most local escalations need root in the namespace first
--security-opt no-new-privileges=trueA to BA setuid binary inside the image cannot raise privileges
--cap-drop=ALLB to CRoot in the container holds no capability that reaches the host
Default seccomp profileB to CBlocks the syscalls used by most kernel exploits
AppArmor or SELinuxB to CConfines file and syscall access even for a container root
--read-only root filesystemA to BAttacker cannot write a payload, a cron file or a modified binary
userns-remap or rootless modeCContainer root maps to an unprivileged host UID; a breakout lands as nobody
Not mounting /var/run/docker.sockB to CThe socket is the daemon API, and the daemon is root
User-defined networks, icc: falseC to DLateral movement between containers
Host firewallC to DLateral 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

Configuration change/etc/sysctl.d/60-docker-hardening.conf
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_scope

Three corrections to advice that circulates widely:

  • user_namespace.enable=1 is 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 is user.max_user_namespaces.
  • kernel.yama.ptrace_scope = 3 is not simply “more secure”. Level 3 disables ptrace entirely and cannot be lowered without a reboot. You lose gdb, strace, perf attach 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) with install <module> /bin/true in /etc/modprobe.d/ is a genuine CIS control, but name the file after what it does. A file called disable-cracklib.conf containing a cramfs directive is how a control gets removed by someone tidying up. And check first: squashfs is used by snap, so blocking it on Ubuntu can break packages you rely on.

Layer 2: the daemon

Service impact possible/etc/docker/daemon.json
{
"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 documented features block — which today carries keys such as containerd-snapshotter and cdi. Leaving a stale key in daemon.json is harmless in itself and corrosive in aggregate: it teaches readers of the file that its contents are not maintained.
  • "selinux-enabled": true only 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

Configuration changea hardened docker run
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_SERVICE only 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 256 bounds 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; noexec means the attacker cannot write a binary there and run it, which is exactly the staging step --read-only was meant to block.
  • --publish 127.0.0.1:8080:8080. Publishing without an address binds to 0.0.0.0 on 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

Read-only / Safeaudit every running container against the baseline
$ 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

Read-only / Safedocker-bench-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
  1. 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.
  2. Apply image and runtime controls first, per service, in staging. They are the cheapest to test and cover the two most valuable transitions.
  3. Apply daemon controls next, and restart. Nothing in daemon.json is in effect until the daemon reads it.
  4. Apply host sysctls last, and reboot once to confirm they persist. Check ip_forward is still 1 afterwards.
  5. Audit running containers, not the configuration. Controls apply at creation; existing containers keep what they were born with.
  6. Re-audit quarterly and keep the output. The diff between the baseline and the audit is the artefact that matters, not the baseline.
  7. 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

  1. Q1. A container has the Docker socket bind-mounted with `:ro`. What does the `:ro` prevent?

  2. Q2. A generic Linux hardening playbook sets `net.ipv4.ip_forward = 0` on a Docker host. What happens?

  3. Q3. Which of these are in Docker default capability set and worth dropping? Select all that apply.

  4. Q4. Why is `userns-remap` excluded from a baseline applied to existing production hosts?

  5. Q5. Setting `kernel.yama.ptrace_scope = 3` is strictly better than 1 for a production Docker host.

  6. 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.