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
BACKGROUNDDocker'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
SETUPYou 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.
| Component | Minimum | Notes |
|---|---|---|
| Docker Engine | 27.x 28.x rec | Check with docker version. Rootless and modern seccomp defaults assume a recent engine. |
| Host OS | Ubuntu 24.04 / Debian 13 | Any systemd distro with kernel 5.15+. RHEL 9 / Rocky 9 use SELinux instead of AppArmor. |
| cgroups | v2 (unified) | Required for rootless resource limits. Verify: stat -fc %T /sys/fs/cgroup returns cgroup2fs. |
| Security modules | AppArmor or SELinux | Debian/Ubuntu ship AppArmor enabled. Confirm with aa-status. |
| Rootless deps | uidmap, dbus-user-session | Provides newuidmap/newgidmap + a user systemd session for the rootless daemon. |
| Auditing tools | git, jq, auditd | docker-bench-security + Trivy + log shipping. Install per the steps below. |
| Privileges | sudo on the host | Daemon hardening needs root; the rootless section explicitly drops it afterwards. |
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.
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.
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.
03 — How Containers Actually Get Compromised
THREAT MODELEvery 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 Path | What It Looks Like | Control That Closes It |
|---|---|---|
| Socket exposure | Container 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 → kernel | In-container RCE calls an exploitable syscall (e.g. a kernel LPE) to break isolation. | Default + custom seccomp profile; AppArmor/SELinux. |
| Writable rootfs persistence | Attacker drops a web shell / cron / modifies binaries inside a writable container filesystem. | --read-only + explicit tmpfs mounts. |
| Vulnerable / poisoned image | Base image ships a known-CVE library, or a typosquatted image contains a miner/backdoor. | Pin digests, minimal base, scan with Trivy, verify signatures. |
04 — Method 1: Harden the Docker Daemon (Bare Metal)
STEP-BY-STEPThe 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).
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.
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.
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.
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.
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.
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.
- docker info lists
usernsandno-new-privilegesunder SecurityOptions - daemon.json is
root:root 0644; socket isroot:docker 0660 - auditd emits events keyed
docker,docker_socket,docker_config - Container PID 1 shows
NoNewPrivs: 1and remapped UID is not host 0
05 — Method 2: Rootless Docker (No Root Daemon At All)
STEP-BY-STEPRootless 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.
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.
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.
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.
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.
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.
| Rootless Trade-off | Impact | Mitigation |
|---|---|---|
| Ports < 1024 | Blocked by default | High port + reverse proxy, or sysctl above |
| Overlay performance | fuse-overlayfs slower than native | Kernel 5.13+ enables native rootless overlay |
| Some net plugins | Limited under slirp4netns | Use --network=host sparingly or pasta |
06 — Hardening Images at Build Time
BUILDThe 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.
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.
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.
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.
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.
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
RUNTIMEEven 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.
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.
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.
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).
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.
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.
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.
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.
- 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.1only, not0.0.0.0 - pids-limit and mem-limit are enforced (visible in
docker stats)
08 — Secrets & Supply-Chain Integrity
SUPPLY CHAINTwo 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.
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.
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.
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.
09 — Scanning & Auditing (Trivy + docker-bench-security)
VERIFYHardening 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.
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.
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.
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.
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.
10 — Detection: Monitoring Docker in Your SIEM
BLUE TEAMPrevention 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.
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).
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.
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.
| Signal | Why It Matters | MITRE ATT&CK |
|---|---|---|
| Socket write (non-daemon) | Pre-cursor to sibling-container escape | T1610 Deploy Container |
| --privileged / host mount | Isolation disabled; escape-ready | T1611 Escape to Host |
| Shell in container | Hands-on-keyboard / discovery | T1059 Command & Scripting |
| New image pulled at runtime | Attacker tooling delivery | T1105 Ingress Tool Transfer |
Hardening sometimes breaks the app. Use this table to tell a genuine security block from a profile that is simply too tight.
| Symptom | Likely Cause | Fix |
|---|---|---|
| "Read-only file system" | App writes outside declared tmpfs | Add a scoped tmpfs for that exact path — not disable read-only |
| Operation not permitted (syscall) | Custom seccomp too tight | Add the missing syscall to the profile; re-capture with strace |
| Permission denied on bind mount | userns-remap changed ownership | chown host dir to the remapped subuid in /etc/subuid |
| Cannot bind port 80 (rootless) | Rootless can't use low ports | High port + reverse proxy, or sysctl ip_unprivileged_port_start |
| Container can't reach another | icc:false blocks default bridge | Put both on an explicit user-defined network |
| Existing containers "vanished" | userns-remap re-namespaced storage | Expected — 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
VERIFYPrimary 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.
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.
"They can't exploit you if you are the Exploit."