Docker & ContainersXXXV Β· Production HardeningHardening
Daemon hardening β and the evidence that it took effect
What you'll learn
- Configure the daemon-level controls that apply to every container
- Verify each control against the running daemon rather than the config file
- Recognise the controls that silently do not apply to existing containers
Prerequisites
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-11
The baseline lesson gave you the layers. This one is about a specific failure of hardening work: a control that is written down, reviewed, approved, and not actually in effect.
/etc/docker/daemon.json is a request. The running daemon is the
fact. They diverge for at least four ordinary reasons β a systemd drop-in
that passes conflicting flags on the command line, a JSON syntax error
that made the daemon fall back to defaults, a change made after the
last daemon restart, or a setting that only applies to containers
created afterwards. Every control in this lesson therefore comes with
the command that reads it back off the running daemon.
The rule
A hardening control is not in place until a command run against the live system returns the expected value. The config file is the intent;
docker infoanddocker inspectare the evidence.
The daemon controls that matter
{
"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 } },
"userns-remap": "default"
}
| Control | What it prevents | Evidence command |
|---|---|---|
icc: false | Lateral movement on the default bridge | docker network inspect bridge |
no-new-privileges | setuid escalation inside containers | docker inspect on a test container |
userns-remap | Container root mapping to host root | docker info shows userns in SecurityOptions |
live-restore | Containers dying with the daemon | docker info --format '{{.LiveRestoreEnabled}}' |
userland-proxy: false | Port-forwarding process per published port | pgrep docker-proxy finds nothing |
log-opts | Disk exhaustion from unbounded logs | docker inspect .HostConfig.LogConfig |
default-ulimits | Fork and fd exhaustion of the host | docker inspect .HostConfig.Ulimits |
Reading the daemon back
docker info --format '{{json .SecurityOptions}}' | tr ',' '\n'["name=apparmor"
"name=seccomp,profile=builtin"
"name=cgroupns"]Read that carefully β it is a hardening report:
name=apparmorβ a mandatory access control module is loaded and the daemon will apply thedocker-defaultprofile. If AppArmor or SELinux is missing from this list, containers have no MAC confinement at all, and no per-container flag will add it.name=seccomp,profile=builtinβ the default seccomp profile is active.profile=unconfinedhere means seccomp is off for every container on the host, which is the single most consequential thing this command can tell you.name=cgroupnsβ containers get their own cgroup namespace, so a container cannot read the hostβs cgroup tree through/sys/fs/cgroup.name=usernsβ present only whenuserns-remapis active. Its absence in the output above means container UID 0 is host UID 0.
systemctl show docker --property=ExecStart --no-pager
systemctl cat docker | grep -A2 '^ExecStart'ExecStart={ path=/usr/bin/dockerd ; argv[]=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock ; ... }Illustrative output
Flags on the ExecStart line take precedence over daemon.json. A
drop-in under /etc/systemd/system/docker.service.d/ that someone
added years ago to work around a bug is the classic source of a
control that is configured and not in effect.
sudo python3 -c 'import json,sys; json.load(open("/etc/docker/daemon.json")); print("JSON valid")'
systemctl show docker --property=ActiveEnterTimestamp --no-pager
sudo stat -c '%y %n' /etc/docker/daemon.jsonJSON valid
ActiveEnterTimestamp=Fri 2026-08-07 10:01:14 UTC
2026-08-10 16:22:03.114 /etc/docker/daemon.jsonIllustrative output
The file was edited on 10 August; the daemon has been running since 7 August. Everything in that file that changed on the 10th is not in effect. This two-line check catches more real gaps than any scanner.
Inter-container communication
docker network inspect bridge \
--format '{{index .Options "com.docker.network.bridge.enable_icc"}}'falseIllustrative output
Proving no-new-privileges is applied
The daemon setting applies to containers created after the daemon read it. Prove it on a throwaway container rather than assuming:
docker run --rm alpine:3.20 grep NoNewPrivs /proc/self/statusNoNewPrivs: 1Illustrative output
That is the real evidence. docker inspect showing
no-new-privileges in .HostConfig.SecurityOpt tells you the flag was
requested; /proc/self/status tells you the kernel applied it.
docker ps -q | while read -r c; do
N=$(docker inspect --format '{{.Name}}' "$c")
S=$(docker inspect --format '{{json .HostConfig.SecurityOpt}}' "$c")
case "$S" in
*no-new-privileges*) ;;
*) echo "MISSING no-new-privileges: $N" ;;
esac
doneMISSING no-new-privileges: /legacy-batch
MISSING no-new-privileges: /adminerIllustrative output
The socket
The Docker socket is the daemonβs entire API, and the daemon runs as root. Access to the socket is equivalent to root on the host β not βclose to rootβ, equivalent.
ls -l /var/run/docker.sock
getent group dockersrw-rw---- 1 root docker 0 Aug 7 10:01 /var/run/docker.sock
docker:x:988:ops,ci-runnerIllustrative output
getent group docker is the most under-run command in this lesson.
Every name in it can start a privileged container with the host root
filesystem bind-mounted, and therefore holds root. Review that list on
the same cadence as /etc/sudoers, because it is the same grant.
docker ps -q | while read -r c; do
docker inspect --format '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' "$c" \
| grep -q 'docker.sock' && docker inspect --format 'SOCKET MOUNT: {{.Name}}' "$c"
done
trueSOCKET MOUNT: /watchtowerIllustrative output
- Write the intent into
/etc/docker/daemon.json, in version control, one file per host class. - Validate the JSON before restarting. A malformed file stops the daemon from starting at all.
- Restart the daemon and record the timestamp. Nothing in the file applies until you do.
- Read every control back from
docker infoand from a throwaway container, not from the file. - Audit existing containers for controls that only apply at creation time, and recreate the ones that are missing them.
- Diff config against evidence quarterly, and keep the output. The diff is the artefact an auditor wants, not the config file.
Sanity check
Knowledge check Β· 4 questions
Q1. `docker info` reports `name=seccomp,profile=unconfined`. What does that mean?
Q2. `daemon.json` was edited at 16:22 today. The daemon has been active since three days ago. What is the state of the new settings?
Q3. Which are true of exposing the daemon on `tcp://` with TLS client verification? Select all that apply.
Q4. Setting `"icc": false` prevents containers on a Compose-created network from reaching each other.
Passing score: 75%. Answers are checked in this browser.