Docker Security Hardening Tutorial 2026: Daemon, Rootless, Runtime & Scanning

·

Containers are everywhere, and so is the assumption that containerised means contained. It does not. A container is not a virtual machine — it is a set of processes sharing the host's kernel, isolated only by namespaces, cgroups and a handful of Linux security modules. A Docker container launched with defaults runs its main process as root, keeps a broad set of Linux capabilities, has a writable root filesystem, and talks to a daemon that itself runs as root and holds a socket equivalent to unrestricted host access. Misconfigure any one of those layers and a single application bug becomes a host compromise.

This guide hardens Docker the way it is actually broken into: from the outside in. We lock down the host and the daemon, then switch to rootless Docker so the daemon no longer runs as root at all. We rebuild images to be minimal and non-root, then constrain the running container with capability drops, a custom seccomp profile, AppArmor, read-only filesystems and resource limits — all captured in a complete, production-ready docker-compose.yml you can copy verbatim. Finally we audit the result with docker-bench-security and Trivy, wire scanning into CI, and ship container telemetry into Microsoft Sentinel and Splunk so you can detect the attacks the hardening is designed to prevent.

Every command targets Docker Engine 27.x / 28.x on a modern Debian 13 or Ubuntu 24.04 host and is production-accurate. Nothing here requires Docker Desktop or an enterprise licence. Work through it on a disposable lab VM first — several steps restart the daemon and will drop running containers.

◈ Table of Contents

01 Why Default Docker Is Risky 02 Prerequisites & Requirements 03 How Containers Get Compromised 04 Method 1: Harden the Daemon 05 Method 2: Rootless Docker 06 Hardening Images at Build 07 Runtime Hardening + Compose 08 Secrets & Supply Chain 09 Scanning & Auditing 10 Detection & Monitoring 11 Sources & References
🐳

01 — Why Default Docker Is Risky

BACKGROUND

Docker's defaults optimise for "it just works," not for "it is safe to run hostile code." Understanding exactly what the defaults give away is the whole point of hardening — you cannot tighten a control you do not know exists. Four design facts drive almost every container incident:

👑

Root Daemon = Root Host

The classic Docker daemon runs as root. Anyone who can reach /var/run/docker.sock — or is in the docker group — can mount the host filesystem into a container and read or write any file as root. Docker group membership is root-equivalent.

🧬

Shared Kernel

Containers are not VMs. Every container shares the host kernel. A kernel exploit or a dangerous syscall reached from inside a container can break isolation entirely — which is why seccomp and capability dropping matter.

🔓

Root-by-Default Processes

Unless an image sets a USER, the process inside runs as UID 0. Combined with a writable root filesystem and extra capabilities, an application RCE becomes in-container root with room to pivot.

📦

Opaque Supply Chain

A :latest tag is a moving target. Public base images carry hundreds of OS packages, many with known CVEs. Without pinning and scanning, you ship vulnerabilities you never wrote.

The good news: the Linux kernel already ships the primitives to shut most of this down — namespaces, cgroups v2, capabilities, seccomp-bpf, and LSMs (AppArmor / SELinux). Docker exposes all of them through flags and the daemon config. Hardening is largely a matter of turning them on deliberately instead of accepting the permissive defaults. The CIS Docker Benchmark codifies this into roughly 100 checks across seven sections — host, daemon, daemon config files, images, container runtime, security operations and Swarm — and gives us a scoreboard to aim at.

Mental model: treat every container as if the process inside it is already compromised. Your job is to make sure that "compromised" means "stuck in a locked, resource-capped, read-only box with no capabilities and no path to the kernel" — not "root on the host."

🧰

02 — Prerequisites & Requirements

SETUP

You need a Linux host with a current Docker Engine, cgroups v2, and the kernel security modules enabled. Everything below assumes a non-Desktop install of Docker Engine on Debian/Ubuntu; RHEL-family hosts work identically apart from AppArmor being replaced by SELinux.

ComponentMinimumNotes
Docker Engine27.x 28.x recCheck with docker version. Rootless and modern seccomp defaults assume a recent engine.
Host OSUbuntu 24.04 / Debian 13Any systemd distro with kernel 5.15+. RHEL 9 / Rocky 9 use SELinux instead of AppArmor.
cgroupsv2 (unified)Required for rootless resource limits. Verify: stat -fc %T /sys/fs/cgroup returns cgroup2fs.
Security modulesAppArmor or SELinuxDebian/Ubuntu ship AppArmor enabled. Confirm with aa-status.
Rootless depsuidmap, dbus-user-sessionProvides newuidmap/newgidmap + a user systemd session for the rootless daemon.
Auditing toolsgit, jq, auditddocker-bench-security + Trivy + log shipping. Install per the steps below.
Privilegessudo on the hostDaemon hardening needs root; the rootless section explicitly drops it afterwards.
1
Confirm Your Baseline
Verify

Before changing anything, record the current state so you can measure improvement and roll back. Capture engine version, storage driver, cgroup version and whether any LSM is active.

BASELINE CHECK
$ docker version # note the Server Engine version $ docker info | grep -Ei 'cgroup version|cgroup driver|security options' -A2 $ stat -fc %T /sys/fs/cgroup # expect: cgroup2fs $ aa-status --enabled && echo "AppArmor active"

If docker info shows warnings such as "No swap limit support" or a bridge driver without userland-proxy notes, fix those at the host level first — a hardened container on an unhardened host is a false sense of safety.

2
Set Up a Disposable Lab Target
Lab

Hardening steps restart the daemon and swap seccomp/AppArmor profiles. Do this on a throwaway VM (Proxmox, VirtualBox, a cloud instance you can destroy) — never first on a host running live workloads. Create a snapshot now.

PREP LAB
$ mkdir -p ~/docker-hardening && cd ~/docker-hardening $ docker run --rm hello-world # confirm the engine works at all # take a VM snapshot here so you can revert the daemon changes
🎯

03 — How Containers Actually Get Compromised

THREAT MODEL

Every hardening control below maps to a real attack path. Here are the five that account for most container incidents, with the specific control that closes each one. Keep this table next to you — it tells you why each later step exists.

Attack PathWhat It Looks LikeControl That Closes It
Socket exposureContainer mounts /var/run/docker.sock; attacker spawns a privileged sibling container and mounts host /.Never mount the socket; use a socket-proxy; rootless daemon.
Privileged / cap abuse--privileged or CAP_SYS_ADMIN lets an attacker mount devices, load modules, or escape via cgroups.--cap-drop=ALL, no --privileged, no-new-privileges.
Dangerous syscall → kernelIn-container RCE calls an exploitable syscall (e.g. a kernel LPE) to break isolation.Default + custom seccomp profile; AppArmor/SELinux.
Writable rootfs persistenceAttacker drops a web shell / cron / modifies binaries inside a writable container filesystem.--read-only + explicit tmpfs mounts.
Vulnerable / poisoned imageBase image ships a known-CVE library, or a typosquatted image contains a miner/backdoor.Pin digests, minimal base, scan with Trivy, verify signatures.
FIG 1 · Each green break-line is a control covered later in this guide
⚙️

04 — Method 1: Harden the Docker Daemon (Bare Metal)

STEP-BY-STEP

The root-mode daemon is the single most powerful component on a Docker host. This method hardens it in place: a locked-down /etc/docker/daemon.json, user-namespace remapping so container root is not host root, no-new-privileges by default, and auditd rules so every daemon action is logged. This is the right path for a host that must keep a root daemon (Swarm managers, CI runners, legacy tooling).

1
Write a Hardened daemon.json
/etc/docker/daemon.json

The daemon config is where host-wide defaults live. This configuration enables user-namespace remapping, turns on live-restore (containers survive daemon restarts), disables inter-container communication on the default bridge, enables no-new-privileges globally, and caps log growth so a chatty container cannot fill the disk.

CREATE THE CONFIG
$ sudo mkdir -p /etc/docker $ sudo tee /etc/docker/daemon.json > /dev/null <<'JSON' { "icc": false, "userns-remap": "default", "no-new-privileges": true, "live-restore": true, "userland-proxy": false, "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }, "default-ulimits": { "nofile": { "Hard": 4096, "Soft": 1024, "Name": "nofile" } } } JSON
APPLY & VERIFY
$ sudo systemctl restart docker $ docker info | grep -A6 'Security Options' # expect to see: userns and no-new-privileges listed

Enabling userns-remap re-namespaces image and volume storage — existing containers and named volumes created before the remap will appear "missing." Enable it on a fresh host, or plan a migration: bind-mounted data must have ownership adjusted for the remapped subordinate UID range in /etc/subuid.

2
Lock Down the Socket and Config Permissions
Access Control

The CIS Benchmark is strict about file ownership on the daemon socket and config files, because write access to any of them is effectively root. Set correct ownership and modes, and audit who is in the docker group — treat that group as a list of root-equivalent accounts.

FILE PERMISSIONS (CIS §3)
$ sudo chown root:root /etc/docker/daemon.json && sudo chmod 0644 /etc/docker/daemon.json $ sudo chown root:docker /var/run/docker.sock && sudo chmod 0660 /var/run/docker.sock $ getent group docker # review every member — each is root-equivalent

If an application genuinely needs Docker API access (CI, dashboards), do not hand it the raw socket. Put a read-only docker-socket-proxy (tecnativa) in front and expose only the endpoints it needs — e.g. allow GET /containers/json but deny /exec and /volumes.

3
Add auditd Rules for the Docker Surface
auditd

The Linux audit framework records every access to the Docker binaries, socket and config. These rules are the raw signal we later forward to a SIEM. Each watch key (-k) becomes a searchable field downstream.

INSTALL + RULES
$ sudo apt-get install -y auditd $ sudo tee /etc/audit/rules.d/docker.rules > /dev/null <<'RULES' -w /usr/bin/dockerd -k docker -w /var/run/docker.sock -k docker_socket -w /etc/docker -k docker_config -w /usr/bin/docker -k docker_cli -w /usr/bin/containerd -k containerd RULES $ sudo augenrules --load && sudo systemctl restart auditd $ sudo ausearch -k docker_socket --start recent # confirm events land
4
Default Everything to No-New-Privileges
Hardening

With no-new-privileges set globally in daemon.json (step 1), any setuid binary inside a container can no longer gain additional privileges — the kernel PR_SET_NO_NEW_PRIVS bit is set on container processes. Confirm it actually takes effect by trying to escalate inside a test container.

PROVE IT WORKS
$ docker run --rm alpine sh -c 'grep NoNewPrivs /proc/1/status' # expect: NoNewPrivs: 1 $ docker run --rm --security-opt no-new-privileges alpine id # a setuid-based escalation inside the container now fails to raise privileges
Daemon Hardened When
  • docker info lists userns and no-new-privileges under SecurityOptions
  • daemon.json is root:root 0644; socket is root:docker 0660
  • auditd emits events keyed docker, docker_socket, docker_config
  • Container PID 1 shows NoNewPrivs: 1 and remapped UID is not host 0
🧑‍💻

05 — Method 2: Rootless Docker (No Root Daemon At All)

STEP-BY-STEP

Rootless mode is the strongest single change you can make: the entire daemon runs inside a user namespace as an unprivileged user, so even a full daemon compromise yields only that user's privileges — there is no host root to steal. This method installs a rootless daemon for a dedicated service account. It is the recommended default for single-tenant application hosts.

1
Create a Dedicated Service User
Least Privilege

Never run rootless Docker as your interactive login account. Create a locked service user with its own subordinate UID/GID range and a persistent systemd user session (linger), so the daemon survives logout.

CREATE USER + SUBIDS
$ sudo adduser --disabled-password --gecos "" dockeruser $ grep dockeruser /etc/subuid /etc/subgid # expect a 65536-wide range, e.g. dockeruser:362144:65536 $ sudo loginctl enable-linger dockeruser # keep the user daemon running after logout
2
Install the Rootless Dependencies
apt

Rootless mode needs uidmap (for newuidmap/newgidmap), dbus-user-session for the per-user systemd bus, and slirp4netns/fuse-overlayfs for userspace networking and storage.

INSTALL DEPS
$ sudo apt-get update $ sudo apt-get install -y uidmap dbus-user-session slirp4netns fuse-overlayfs docker-ce-rootless-extras

Rootless mode reduces privilege-escalation risk but it does not protect against kernel-level exploits — the kernel is still shared. You still need seccomp, AppArmor/SELinux and image scanning on top. Rootless is a layer, not a silver bullet.

3
Run the Rootless Installer as the Service User
dockerd-rootless

Switch into the service user's own systemd session and run the bundled setup tool. It installs a docker.service under the user's systemd, not the system one.

SET UP ROOTLESS
$ sudo machinectl shell dockeruser@ # enter a clean user session dockeruser$ dockerd-rootless-setuptool.sh install dockeruser$ systemctl --user enable --now docker dockeruser$ echo 'export DOCKER_HOST=unix:///run/user/'$(id -u)'/docker.sock' >> ~/.bashrc
VERIFY ROOTLESS
dockeruser$ docker info | grep -A6 'Security Options' # expect: name=rootless to appear in the list dockeruser$ docker run --rm alpine cat /proc/self/uid_map # shows the UID remap — container root (0) maps to the host subuid, not host 0
4
Handle Privileged Ports & Limits
Networking

A rootless daemon cannot bind ports below 1024 by default. Rather than grant cap_net_bind_service to the whole daemon, publish on a high port and reverse-proxy, or allow low ports explicitly via sysctl for the rootless process only.

OPTION A — HIGH PORT (PREFERRED)
dockeruser$ docker run -d --name web -p 8080:8080 nginx:1.27-alpine # front it with the host's nginx/caddy on 443 → 127.0.0.1:8080
OPTION B — ALLOW LOW PORTS FOR ROOTLESS
$ sudo sh -c 'echo "net.ipv4.ip_unprivileged_port_start=80" > /etc/sysctl.d/99-rootless.conf' $ sudo sysctl --system
Rootless Trade-offImpactMitigation
Ports < 1024Blocked by defaultHigh port + reverse proxy, or sysctl above
Overlay performancefuse-overlayfs slower than nativeKernel 5.13+ enables native rootless overlay
Some net pluginsLimited under slirp4netnsUse --network=host sparingly or pasta
🏗️

06 — Hardening Images at Build Time

BUILD

The smallest, cleanest image you can ship is also the most secure: fewer packages means fewer CVEs and fewer tools for an attacker to pivot with. This phase rebuilds an application image to be minimal, non-root, pinned and reproducible using a multi-stage Dockerfile.

1
Pin the Base Image to a Digest
Supply Chain

Tags move; digests do not. Pinning image@sha256:... guarantees every build and every host pulls the exact bytes you reviewed and scanned. Resolve the current digest once, then commit it.

RESOLVE DIGEST
$ docker pull python:3.13-slim $ docker inspect python:3.13-slim | jq -r '.[0].RepoDigests[0]' # e.g. python@sha256:ab12cd... — copy this into the Dockerfile FROM line
2
Write a Multi-Stage, Non-Root Dockerfile
Dockerfile

The build stage has compilers and dev headers; the final stage ships only the runtime and the app, running as a dedicated non-root UID with no shell package. The USER directive is the single highest-value line in the file.

Dockerfile
# ---- build stage ---- FROM python@sha256:ab12cd... AS build WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # ---- final stage ---- FROM python@sha256:ab12cd... RUN useradd -r -u 10001 -g nogroup appuser COPY --from=build /install /usr/local COPY --chown=appuser:nogroup . /app WORKDIR /app USER 10001:65534 EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8080/healthz')" || exit 1 ENTRYPOINT ["python", "-m", "app"]

For compiled languages (Go, Rust) go one step further and ship on gcr.io/distroless/static or scratch — no shell, no package manager, nothing for an attacker to exec. If the image has no /bin/sh, a huge class of reverse-shell payloads simply cannot run.

3
Add a .dockerignore and Build with BuildKit
Hygiene

A missing .dockerignore is how .git, .env files and SSH keys end up baked into images and pushed to a registry. Exclude everything secret or unnecessary, and build with BuildKit for better caching and secret handling.

.dockerignore
.git .env *.pem *.key node_modules __pycache__ Dockerfile docker-compose*.yml README.md
BUILD
$ DOCKER_BUILDKIT=1 docker build -t myapp:1.0.0 . $ docker image inspect myapp:1.0.0 | jq -r '.[0] | "user="+.Config.User+" size="+(.Size|tostring)' # confirm user=10001:65534 and a small size — not root, not 1GB

Never use ADD with a remote URL or to auto-extract archives — it silently fetches and unpacks attacker-controllable content. Use COPY for local files and an explicit curl | sha256sum -c when you must fetch, so the download is integrity-checked.

🛡️

07 — Runtime Hardening & a Complete Hardened Compose File

RUNTIME

Even a perfect image runs insecurely if you launch it with permissive flags. Runtime hardening is where capability dropping, seccomp, read-only filesystems and resource limits come together. We build the controls up on the CLI, then capture the whole thing in a production docker-compose.yml.

1
Drop All Capabilities, Add Back Only What Is Needed
Capabilities

Containers start with a default capability set that most apps never use. Start from zero and add back the minimum. A web server that binds a low port needs only NET_BIND_SERVICE; a plain app server usually needs nothing.

CAP-DROP
$ docker run -d --name web \ --cap-drop=ALL \ --cap-add=NET_BIND_SERVICE \ --security-opt no-new-privileges \ nginx:1.27-alpine $ docker inspect web | jq '.[0].HostConfig | {CapDrop, CapAdd}'

Never run production workloads with --privileged. It disables almost every isolation control at once — seccomp, AppArmor, capability bounding and device cgroups — and is the fastest known path from container to host. If a tool "requires privileged," find the one specific capability or device it actually needs and grant only that.

2
Read-Only Root Filesystem + tmpfs
Immutability

A read-only root filesystem defeats persistence: an attacker with code execution cannot drop a web shell, modify a binary or write a cron entry. Mount small writable tmpfs areas only where the app genuinely needs to write (temp, caches, pid files).

READ-ONLY RUN
$ docker run -d --name web \ --read-only \ --tmpfs /tmp:size=16m,mode=1770 \ --tmpfs /var/cache/nginx:size=16m \ --tmpfs /var/run:size=4m \ nginx:1.27-alpine $ docker exec web sh -c 'touch /etc/x 2>&1' # expect: Read-only file system — persistence blocked
3
Apply a Custom Seccomp Profile
seccomp

Docker's default seccomp profile already blocks around 44 dangerous syscalls. For sensitive workloads, tighten further: start from the default profile and remove syscalls the app does not use. Below we derive the exact set with strace, then whitelist only those.

CAPTURE REQUIRED SYSCALLS
$ docker run --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ myapp:1.0.0 strace -qcf -S name -o /proc/1/fd/1 python -m app 2>/dev/null \ | awk 'NR>2 {print $NF}' | sort -u > app-syscalls.txt # exercise EVERY code path during capture: startup, requests, errors, shutdown
APPLY THE TIGHT PROFILE
$ docker run -d --name web --security-opt seccomp=./seccomp-app.json myapp:1.0.0 $ docker inspect web | jq '.[0].HostConfig.SecurityOpt'

The strace-derived allowlist gives the tightest possible profile, but it needs thorough testing — exercise startup, normal requests, error handling, graceful shutdown and log rotation during capture, or you will block a syscall the app only uses occasionally and get a hard-to-trace crash in production.

Do not reach for --security-opt seccomp=unconfined to "make it work." That disables the entire syscall filter and is one of the most common findings in a container audit. If something breaks under seccomp, add the missing syscalls to a custom profile — never remove the filter.

4
AppArmor Profile + Resource Limits
LSM + cgroups

AppArmor (or SELinux on RHEL) constrains file and capability access beyond seccomp's syscall layer. Resource limits via cgroups stop a compromised container from starving the host — a crypto-miner pinned to 0.5 CPU and 256 MB is loud and contained.

LOAD AN APPARMOR PROFILE
$ sudo apparmor_parser -r -W /etc/apparmor.d/docker-nginx $ docker run -d --name web --security-opt apparmor=docker-nginx nginx:1.27-alpine
RESOURCE LIMITS
$ docker run -d --name web \ --memory=256m --memory-swap=256m \ --cpus=0.50 --pids-limit=100 \ --ulimit nofile=1024:2048 \ nginx:1.27-alpine $ docker stats --no-stream web # confirm caps are enforced
5
The Complete Hardened docker-compose.yml
Production

Here is everything above expressed as a single declarative stack. Every security control is explicit: non-root user, dropped capabilities, no-new-privileges, custom seccomp, AppArmor, read-only rootfs with scoped tmpfs, pids and memory limits, a loopback-only published port, a healthcheck, and bounded logging. Copy it, swap the image digest, and adapt the tmpfs paths to your app.

docker-compose.yml
services: web: image: myapp:1.0.0@sha256:ab12cd... user: "10001:65534" read_only: true cap_drop: - ALL cap_add: - NET_BIND_SERVICE security_opt: - no-new-privileges:true - seccomp=./seccomp-app.json - apparmor=docker-nginx pids_limit: 100 mem_limit: 256m memswap_limit: 256m cpus: 0.50 tmpfs: - /tmp:size=16m,mode=1770 - /var/run:size=4m networks: - frontend ports: - "127.0.0.1:8080:8080" healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"] interval: 30s timeout: 3s retries: 3 restart: unless-stopped logging: driver: json-file options: max-size: "10m" max-file: "3" networks: frontend: driver: bridge
DEPLOY & VERIFY
$ docker compose up -d $ docker inspect $(docker compose ps -q web) \ | jq '.[0] | {ro:.HostConfig.ReadonlyRootfs, user:.Config.User, caps:.HostConfig.CapDrop}' # expect: ro=true user=10001:65534 caps=[ALL]
Runtime Hardened When
  • Container runs as a non-zero UID with CapDrop=[ALL]
  • Root filesystem is read-only; writes only succeed in declared tmpfs
  • SecurityOpt lists no-new-privileges, a named seccomp profile and AppArmor
  • Port is published to 127.0.0.1 only, not 0.0.0.0
  • pids-limit and mem-limit are enforced (visible in docker stats)
🔐

08 — Secrets & Supply-Chain Integrity

SUPPLY CHAIN

Two of the most common real-world container failures are leaked secrets baked into images and unverified images pulled from a registry. This phase keeps secrets out of layers and verifies that the image you run is the image you built.

1
Never Bake Secrets — Use BuildKit Secrets & Runtime Mounts
Secrets

An ENV API_KEY=... or a COPY .env is permanent — it lives in the image history even after a later layer "deletes" it. Use BuildKit secret mounts at build time (never persisted) and file-based secrets at runtime.

BUILD-TIME SECRET (NOT STORED IN LAYERS)
$ DOCKER_BUILDKIT=1 docker build \ --secret id=pip,src=$HOME/.pip-token \ -t myapp:1.0.0 . # Dockerfile uses: RUN --mount=type=secret,id=pip pip install ...
INSPECT HISTORY FOR LEAKS
$ docker history --no-trunc myapp:1.0.0 | grep -iE 'secret|token|key|password' # should return nothing

At runtime, prefer Docker/Swarm secrets or a mounted file over environment variables. Env vars leak into docker inspect, child processes, and crash dumps; a file mounted at /run/secrets/db_password read at startup does not.

2
Sign & Verify Images; Generate an SBOM
cosign + SBOM

Signing proves provenance; an SBOM (software bill of materials) lets you answer "are we affected?" the next time a library CVE lands. Sign on push with cosign and attach an SBOM.

SIGN + VERIFY (COSIGN)
$ cosign generate-key-pair $ cosign sign --key cosign.key registry.example.com/myapp:1.0.0 $ cosign verify --key cosign.pub registry.example.com/myapp:1.0.0
GENERATE SBOM (TRIVY / DOCKER SBOM)
$ docker sbom myapp:1.0.0 --format spdx-json > myapp.sbom.json $ trivy image --format cyclonedx -o myapp.cdx.json myapp:1.0.0
🔎

09 — Scanning & Auditing (Trivy + docker-bench-security)

VERIFY

Hardening you cannot measure is hardening you cannot trust. Two free tools give you continuous assurance: Trivy scans images for vulnerable packages, misconfigurations and secrets; docker-bench-security grades your host and runtime against the CIS Docker Benchmark.

1
Scan Images with Trivy
Trivy

Trivy finds OS-package and language-dependency CVEs, hardcoded secrets and Dockerfile misconfigurations. Run it locally, and make the build fail on HIGH/CRITICAL so vulnerable images never leave CI.

INSTALL + SCAN
$ sudo apt-get install -y wget apt-transport-https gnupg $ wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg $ echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list $ sudo apt-get update && sudo apt-get install -y trivy
FAIL THE BUILD ON HIGH/CRITICAL
$ trivy image --severity HIGH,CRITICAL --exit-code 1 \ --ignore-unfixed myapp:1.0.0 $ trivy image --scanners vuln,secret,misconfig myapp:1.0.0 # full scan
2
Grade the Host with docker-bench-security
CIS

The official Docker Bench script runs the CIS Docker Benchmark checks against your live host — daemon config, file permissions, and container runtime flags — and prints exactly which controls pass, warn or fail. Run it from the host or as a container.

RUN AS A CONTAINER
$ git clone https://github.com/docker/docker-bench-security.git $ cd docker-bench-security $ docker build --no-cache -t docker-bench-security . $ docker run --rm --net host --pid host --userns host --cap-add audit_control \ -v /etc:/etc:ro -v /var/lib:/var/lib:ro \ -v /var/run/docker.sock:/var/run/docker.sock:ro \ docker-bench-security

Section 5 (container runtime) is where the findings with real blast radius live — it is checking the exact flags from Phase 07. Treat every [WARN] in section 5 as a ticket, and re-run the bench after each change to watch your score climb.

3
Wire Scanning into CI (GitHub Actions)
DevSecOps

Shift scanning left so a vulnerable image never reaches a registry. This pipeline builds, scans with Trivy, and uploads SARIF to GitHub code scanning — failing the job on HIGH/CRITICAL.

.github/workflows/container-scan.yml
name: container-scan on: [push] jobs: build-scan: runs-on: ubuntu-latest permissions: { contents: read, security-events: write } steps: - uses: actions/checkout@v4 - name: Build run: DOCKER_BUILDKIT=1 docker build -t myapp:scan . - name: Trivy scan uses: aquasecurity/trivy-action@master with: image-ref: myapp:scan format: sarif output: trivy.sarif severity: HIGH,CRITICAL exit-code: "1" ignore-unfixed: true - name: Upload SARIF if: always() uses: github/codeql-action/upload-sarif@v3 with: { sarif_file: trivy.sarif }
📡

10 — Detection: Monitoring Docker in Your SIEM

BLUE TEAM

Prevention fails eventually; detection is how you find out. Ship the auditd events from Phase 04 (and optionally Falco runtime alerts) into your SIEM and hunt for the behaviours hardening is designed to stop. Below, each detection pairs a KQL (Microsoft Sentinel / Defender) query with the equivalent SPL (Splunk), with a one-line note on what it finds.

1
Ship auditd + Docker Logs
Ingest

The Sentinel AMA agent collects auditd via the Linux data connector; Splunk uses the Add-on for Unix and Linux with an auditd input, or the Splunk Connect for Syslog. Deploy Falco for high-signal runtime alerts (shell-in-container, sensitive mount, outbound connection from an unexpected container).

OPTIONAL — FALCO RUNTIME DETECTION
$ docker run -d --name falco --privileged \ -v /var/run/docker.sock:/host/var/run/docker.sock:ro \ -v /proc:/host/proc:ro -v /etc:/host/etc:ro \ falcosecurity/falco:latest # Falco needs host visibility by design — run it, and nothing else, this way
2
Detect Docker Socket Access & Privileged Launches
KQL + SPL

Writes to the Docker socket from an unexpected process, and any container started with --privileged, are the two highest-value signals. The socket watch (docker_socket) and daemon command lines surface both.

KQL — SOCKET ACCESS BY UNEXPECTED PROCESS (SENTINEL)
Syslog | where Facility == "user" and SyslogMessage has "docker_socket" | parse SyslogMessage with * "exe=\"" exe "\"" * "uid=" uid " " * | where exe !in ("/usr/bin/dockerd", "/usr/bin/docker", "/usr/bin/containerd") | summarize hits=count(), procs=make_set(exe) by Computer, uid, bin_time=bin(TimeGenerated, 5m) | where hits > 0
SPL — SAME DETECTION (SPLUNK)
index=linux sourcetype=auditd key=docker_socket | rex field=_raw "exe=\"(?<exe>[^\"]+)\"" | search NOT exe IN ("/usr/bin/dockerd","/usr/bin/docker","/usr/bin/containerd") | stats count AS hits values(exe) AS procs by host,uid,bin(_time span=5m) | where hits > 0
KQL — PRIVILEGED / SENSITIVE-MOUNT CONTAINER STARTS
Syslog | where SyslogMessage has "docker" and SyslogMessage has_any ("--privileged", "/var/run/docker.sock", "-v /:", "cap-add=SYS_ADMIN") | project TimeGenerated, Computer, SyslogMessage | sort by TimeGenerated desc
SPL — EQUIVALENT
index=linux (sourcetype=auditd OR sourcetype=bash_history) docker ("--privileged" OR "/var/run/docker.sock" OR "cap-add=SYS_ADMIN" OR "-v /:") | table _time host user _raw | sort - _time
3
Detect Interactive Shells Inside Containers
KQL + SPL

A hardened container should rarely, if ever, have someone exec an interactive shell into it in production. docker exec landing a shell, or a shell process whose parent is a container runtime, is a strong hands-on-keyboard signal.

KQL — SHELL SPAWNED UNDER A CONTAINER (DEFENDER XDR)
DeviceProcessEvents | where InitiatingProcessFileName in ("containerd-shim", "runc", "dockerd") | where FileName in ("bash", "sh", "ash", "zsh") | project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine, InitiatingProcessFileName | sort by Timestamp desc
SPL — EQUIVALENT (SYSMON FOR LINUX EVENTID 1)
index=linux sourcetype="sysmon:linux" EventID=1 ParentImage IN ("*containerd-shim*","*runc*","*dockerd*") Image IN ("*/bash","*/sh","*/ash","*/zsh") | table _time host User Image CommandLine ParentImage | sort - _time
SignalWhy It MattersMITRE ATT&CK
Socket write (non-daemon)Pre-cursor to sibling-container escapeT1610 Deploy Container
--privileged / host mountIsolation disabled; escape-readyT1611 Escape to Host
Shell in containerHands-on-keyboard / discoveryT1059 Command & Scripting
New image pulled at runtimeAttacker tooling deliveryT1105 Ingress Tool Transfer
4
Troubleshooting — Common Hardening Breakages
Fixes

Hardening sometimes breaks the app. Use this table to tell a genuine security block from a profile that is simply too tight.

SymptomLikely CauseFix
"Read-only file system"App writes outside declared tmpfsAdd a scoped tmpfs for that exact path — not disable read-only
Operation not permitted (syscall)Custom seccomp too tightAdd the missing syscall to the profile; re-capture with strace
Permission denied on bind mountuserns-remap changed ownershipchown host dir to the remapped subuid in /etc/subuid
Cannot bind port 80 (rootless)Rootless can't use low portsHigh port + reverse proxy, or sysctl ip_unprivileged_port_start
Container can't reach anothericc:false blocks default bridgePut both on an explicit user-defined network
Existing containers "vanished"userns-remap re-namespaced storageExpected — migrate data; enable remap on a fresh host

When something breaks under hardening, the wrong instinct is to relax the control globally (--privileged, seccomp=unconfined, writable rootfs). The right move is always narrower: grant the one capability, add the one syscall, mount the one tmpfs. Every broad relaxation hands an attacker back a control you worked to remove.

📚

11 — Sources & References

VERIFY

Primary vendor and standards documentation used to verify every command and control in this guide. Always confirm flags against your installed engine version, as defaults evolve between releases.

Docker Docs — Engine Security (capabilities, seccomp, userns, rootless) Docker Docs — Run the Docker daemon as a non-root user (Rootless mode) Docker Docs — Seccomp security profiles for Docker Docker Docs — dockerd & daemon.json configuration reference GitHub — docker/docker-bench-security (CIS Docker Benchmark automation) Center for Internet Security — CIS Docker Benchmark Aqua Security — Trivy documentation (image, secret & misconfig scanning) Falco — Runtime security documentation Sigstore — Cosign container signing & verification MITRE ATT&CK — Containers Matrix

Harden once, verify continuously.

Run through this guide on a lab VM, then adopt the pieces that fit production: rootless where you can, a locked-down daemon.json where you cannot, the hardened compose template for every new service, Trivy in CI, and docker-bench-security on a schedule. Pair the preventive controls with the SIEM detections in Phase 10 so a control that slips is caught, not silently bypassed. For incident response runbooks covering container escape and cloud-storage exfiltration, browse the CyberHawk SOP library, and test your images against the IOC Scanner before you ship.

◈ Stay Connected

Follow CyberHawk Threat Intel for threat intelligence, deployment guides and hands-on SOC tooling content.

🌐 Website ▶️ YouTube ▶️ YouTube (2) 𝕏 Twitter / X ♪ TikTok ✈️ Telegram
🔍 IOC Scanner 🛠️ Live Tools 📚 Courses 🚨 Threat Intel 📝 Blog 📋 SOPs

"They can't exploit you if you are the Exploit."