Docker container hardening checklist
A container that passes your CVE scanner is not a hardened container. Vulnerability scanning tells you what software versions are installed. Hardening tells you whether the runtime environment limits what a compromised process can actually do. This guide covers the seven checks that matter most — each one independently verifiable, each one automatically scored by Keelix.
Run as a non-root user
If a process inside a root-owned container escapes — via a kernel vulnerability or a supply-chain compromise — the attacker lands on the host as root. The fix is two lines in your Dockerfile and it costs nothing at runtime.
FROM node:20-alpine
# no USER directive → runs as rootFROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
USER appAt runtime you can also pass --user 1000:1000 (or any non-zero UID) to override the image default. In docker-compose.yml: user: "1000:1000". The OWASP Docker Security Cheat Sheet treats this as the first line of defense against privilege escalation.
Drop all capabilities; add back only what you need
Linux capabilities break the all-or-nothing root model into roughly 40 discrete permissions. Docker grants a default subset at container start. --cap-drop all removes every capability; --cap-add lets you add back only the ones your process actually needs.
Most web servers and API processes need nothing. If a service binds to a port below 1024, add NET_BIND_SERVICE. Avoid SYS_ADMIN — it is a catch-all that re-opens most of what you just closed.
docker run \
--cap-drop all \
--cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges \
myimage--security-opt no-new-privileges (OWASP Rule #4) prevents setuid and setgid binaries inside the container from acquiring capabilities at runtime — a common privilege-escalation vector that cap-drop alone does not close.
Never use --privileged
--privilegedgrants all Linux capabilities, disables the default seccomp profile, and disables AppArmor confinement. A privileged container has the same level of access to the host kernel as a process running directly on the host as root. It is not a “slightly less secure” option — it negates the containment model entirely.
Privileged mode appears in tutorials for Docker-in-Docker CI setups and some hardware-access use cases. In those narrow contexts it may be necessary. In a production application workload it is never acceptable. If you find --privileged in a docker-compose.yml or a deployment manifest, remove it and identify what capability the author actually needed — then add only that.
Keep the seccomp profile active (or apply a custom one)
Docker's default seccomp profile blocks approximately 44 system calls out of 300+, including kernel-module loading, namespace manipulation, hardware I/O access, and io_uring operations — a kernel interface with a documented history of container-escape vulnerabilities.
The profile is enabled by default, but it is disabled whenever you pass --privileged or --security-opt seccomp=unconfined. Do neither. For high-assurance services, start from Docker's default profile and remove syscalls you can confirm are unused — but the default is the right baseline for most workloads.
Apply AppArmor (or SELinux) confinement
Seccomp restricts which system calls a process can make. AppArmor restricts what resources — files, network interfaces, capabilities — a process can access. They are complementary layers, not alternatives.
Docker applies its default docker-default AppArmor profile automatically on systems where AppArmor is available (most Debian/Ubuntu hosts). Verify the profile is loaded and active with aa-status | grep docker. On Fedora/RHEL hosts, the equivalent is an SELinux policy — confirm with getenforce. A container running --security-opt apparmor=unconfined or in a permissive-SELinux environment is missing a significant isolation layer.
Set memory and CPU limits
A container without resource limits can exhaust host memory and CPU, taking down every other container and the host itself — a denial-of-service from one compromised workload. This is especially relevant when containers are running AI agent processes that may spawn sub-processes or consume unbounded context windows.
services:
app:
image: myimage
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128MThe deploy.resourcesblock is honoured by Docker Compose in standalone mode (v2 Compose file format). Set limits that reflect your application's actual steady-state usage, not the maximum you could imagine — the goal is to cap runaway processes, not to starve normal operation.
Use a read-only filesystem where possible
--read-onlymounts the container's root filesystem read-only. An attacker who compromises the process cannot drop scripts, modify binaries, or establish persistence via the filesystem. When the application needs to write temporary data, pair it with --tmpfs /tmp to provide an in-memory writable scratch area.
docker run --read-only --tmpfs /tmp myimageNot all applications support a fully read-only root — some write to locations like /etc or /var at startup. Use docker run --read-only in staging first to identify which paths need writable mounts, then add only those explicit volumes or tmpfs mounts in production.
What Keelix verifies automatically
Each of the seven checks above is a deterministic test — the answer is pass, warn, or critical, not a probability. Keelix runs all of them as part of its whole-box scan and folds the results into your 0–100 posture score alongside exposure, host configuration, and AI/MCP findings. One scan, one score.
CVE scanners tell you what software versions are on disk. Keelix tells you whether the runtime configuration limits what a compromised process can actually do — the two questions are independent, and both matter. Run Keelix before you ship; gate CI on the posture score; get the finding before it goes live.
Frequently asked
- What does it mean to harden a Docker container?
- Hardening means reducing the attack surface of a running container so that if it is compromised, the damage is contained. The key levers are: running as a non-root user, dropping Linux capabilities the process doesn't need, applying a seccomp or AppArmor profile to restrict kernel access, never using --privileged, setting memory and CPU limits, and making the container filesystem read-only where possible. Each check is independent — a container that passes one still needs the others.
- Why shouldn't I run Docker containers as root?
- If a process inside a root-owned container escapes the container — via a kernel vulnerability, a misconfiguration, or a vulnerable dependency — the attacker lands on the host as root. Running the container as a non-root user (USER in the Dockerfile, or -u at runtime) means a container escape grants at most the privileges of that unprivileged user on the host, which is a much smaller blast radius. The OWASP Docker Security Cheat Sheet (Rule #2) makes this the first line of defense. [OWASP Docker Cheat Sheet Rule #2 ↗]
- What is --cap-drop all and which capabilities do I add back?
- Linux capabilities break root's all-or-nothing privilege model into roughly 40 discrete permissions. Docker grants a default subset at startup. --cap-drop all removes every capability, then --cap-add lets you add back only what the process actually needs. Most web servers and databases run fine with NET_BIND_SERVICE (bind to ports below 1024) and nothing else. Audit tools that need to inspect the filesystem may need DAC_READ_SEARCH. The principle: enumerate required capabilities, add only those, drop everything else.
- What does Docker's default seccomp profile block?
- Docker's default seccomp profile blocks approximately 44 system calls out of 300+, preventing containers from loading kernel modules, creating new namespaces, manipulating the system clock, accessing hardware I/O ports, and exploiting io_uring (a kernel interface with a history of container-escape CVEs). The profile is an allowlist: it denies access by default and permits only the system calls most applications need. For most workloads, the default profile is the right baseline — custom profiles are worth pursuing only for high-assurance services. [Docker Docs — Seccomp profiles ↗]
- When is --privileged ever acceptable?
- Almost never in production. --privileged grants all Linux capabilities and disables seccomp and AppArmor profiles — it is functionally equivalent to running the process as root directly on the host. The only legitimate uses are very narrow: certain container-in-container CI setups, kernel-level diagnostic tools, and specialized hardware drivers. If you find --privileged in a docker-compose.yml for an application workload, remove it. Keelix flags --privileged as a critical finding.
Related
Verify every hardening check automatically.
Free, local-first, Apache-2.0. Keelix scores your container configuration alongside host, exposure, and AI/MCP findings — one scan, one number.