Skip to main content
RunBook Academy

Docker & ContainersXXXV Β· Production HardeningHardening

Daemon hardening β€” and the evidence that it took effect

Advanced⏱ ~26 min

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

Not yet marked complete on this device.

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 info and docker inspect are 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"
}
ControlWhat it preventsEvidence command
icc: falseLateral movement on the default bridgedocker network inspect bridge
no-new-privilegessetuid escalation inside containersdocker inspect on a test container
userns-remapContainer root mapping to host rootdocker info shows userns in SecurityOptions
live-restoreContainers dying with the daemondocker info --format '{{.LiveRestoreEnabled}}'
userland-proxy: falsePort-forwarding process per published portpgrep docker-proxy finds nothing
log-optsDisk exhaustion from unbounded logsdocker inspect .HostConfig.LogConfig
default-ulimitsFork and fd exhaustion of the hostdocker inspect .HostConfig.Ulimits

Reading the daemon back

Read-only / Safethe security posture in one line
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 the docker-default profile. 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=unconfined here 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 when userns-remap is active. Its absence in the output above means container UID 0 is host UID 0.
Read-only / Safedaemon flags actually in force
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.

Read-only / Safeconfig file is valid and was loaded
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.json
JSON valid
ActiveEnterTimestamp=Fri 2026-08-07 10:01:14 UTC
2026-08-10 16:22:03.114 /etc/docker/daemon.json

Illustrative 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

Read-only / Safeicc on the default bridge
docker network inspect bridge \
  --format '{{index .Options "com.docker.network.bridge.enable_icc"}}'
false

Illustrative 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:

Read-only / Safeno-new-privileges end to end
docker run --rm alpine:3.20 grep NoNewPrivs /proc/self/status
NoNewPrivs:	1

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

Read-only / Safeaudit existing containers
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
done
MISSING no-new-privileges: /legacy-batch
MISSING no-new-privileges: /adminer

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

Read-only / Safesocket ownership and membership
ls -l /var/run/docker.sock
getent group docker
srw-rw---- 1 root docker 0 Aug  7 10:01 /var/run/docker.sock
docker:x:988:ops,ci-runner

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

Read-only / Safesocket mounted into containers
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
true
SOCKET MOUNT: /watchtower

Illustrative output

  1. Write the intent into /etc/docker/daemon.json, in version control, one file per host class.
  2. Validate the JSON before restarting. A malformed file stops the daemon from starting at all.
  3. Restart the daemon and record the timestamp. Nothing in the file applies until you do.
  4. Read every control back from docker info and from a throwaway container, not from the file.
  5. Audit existing containers for controls that only apply at creation time, and recreate the ones that are missing them.
  6. 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

  1. Q1. `docker info` reports `name=seccomp,profile=unconfined`. What does that mean?

  2. 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?

  3. Q3. Which are true of exposing the daemon on `tcp://` with TLS client verification? Select all that apply.

  4. Q4. Setting `"icc": false` prevents containers on a Compose-created network from reaching each other.

Passing score: 75%. Answers are checked in this browser.