🔎 Last updated · Editorial policy · Report an error
{
“@context”: “https://schema.org”,
“@type”: “TechArticle”,
“headline”: “Harden Your Docker Images: Distroless, Rootless and Read-Only Filesystems (with Real Dockerfiles)”,
“description”: “Step-by-step tutorial converting a vulnerable Docker image to distroless, non-root, and read-only — with real Dockerfiles, commands, and verification for each control.”,
“author”: {“@type”: “Organization”, “name”: “Hmmnm – Cybersecurity Tutorials”, “url”: “https://hmmnm.com”},
“publisher”: {“@type”: “Organization”, “name”: “Hmmnm”}
}
Hardening a Docker image comes down to three moves: switch to a distroless base image, run the container as a non-root user, and enforce a read-only root filesystem. Each control removes a distinct attacker capability—shell access, privilege escalation, and persistence—and each can be applied to an existing Dockerfile in under an hour.
Container security has a well-established playbook: scan CVEs, pin base images, sign artifacts. But the most damaging container escapes in recent memory didn’t start with a missing patch—they started with an attacker getting a foothold in an image that gave them everything they needed: a shell, root privileges, and a writable filesystem. In 2023, CISA and the NSA published Kubernetes hardening guidance specifically calling out non-root containers and immutable filesystems as baseline controls, not nice-to-haves. This post walks through applying all three controls to a real image, with verification commands proving each one works.
TL;DR: How Do You Harden a Docker Image in Three Moves?
Switch to a distroless base, run as a non-root USER, and enforce --read-only. This post shows each conversion with real before/after Dockerfiles and hands-on verification commands for every control.
The Starting Point: A Typical Vulnerable Dockerfile
Here’s a Dockerfile you’ve probably written—or inherited—hundreds of times. It works. It’s also a gift to any attacker who gets code execution inside it:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
What makes this image vulnerable by default? Count the problems:
- Full Debian userland:
node:20ships bash, sh, apt, curl, and hundreds of binaries. Any code execution becomes an interactive foothold. - Package manager present:
aptmeans an attacker can install tooling—cryptominers, reverse shells, credential stealers—at will. - Root by default: No
USERdirective means everything runs as UID 0. A container escape lands you as root on the host (especially true pre-user-namespaces). - Writable root filesystem: Attackers can modify binaries, plant backdoors, and survive restarts.
- Build toolchain in the runtime image:
npm installleaves gcc, make, and Python in the final layers.
Every one of these is a foothold. Hardening is the systematic removal of footholds.
Why Container Image Hardening Matters: Attack Surface and Blast Radius
Think of a container in terms of blast radius. When an attacker exploits your Node.js application—say, via an SSRF-to-RCE chain—they inherit the capabilities of the container process. Four things determine what they can do next:
- Shells and interpreters turn a one-shot exploit into persistent interactive access. No shell, no foothold.
- Package managers turn a constrained environment into a fully loaded attack box.
apt installis all it takes. - Root privileges mean any container escape succeeds with UID 0 on the host—capability to read host mounts, tamper with other containers, and abuse the Docker socket if mounted.
- Writable filesystems enable persistence: modified binaries, cron-like mechanisms, planted webshells that survive process restarts.
The OWASP Docker Security Cheat Sheet lists non-root execution and read-only filesystems among its top controls, and Google’s distroless project exists precisely because most container images carry thousands of files the application never needs. Smaller attack surface, smaller blast radius, fewer CVEs to triage.
Baseline Check: Scanning the Original Image
Before changing anything, record the “before” state so you can demonstrate risk reduction:
docker build -t app:vulnerable .
# Who runs by default?
docker inspect -f '{{.Config.User}}' app:vulnerable
# (empty output = root)
# How big is the attack surface?
docker run --rm app:vulnerable ls /bin | wc -l
# CVE baseline
trivy image app:vulnerable
Typical results for node:20: empty User field, hundreds of binaries in /bin, and dozens of OS-level CVEs in Trivy output—most of them in Debian packages your app never invokes. That’s your baseline.
Step 1: Moving to a Distroless Base Image
Distroless images from Google contain only your application and its runtime dependencies. No shell. No package manager. No ls. The conversion requires a multi-stage build, because you need build tooling to compile/install dependencies—just not in the final image.
Before (root, full OS):
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
After (distroless, multi-stage):
# ---- Build stage ----
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# ---- Runtime stage ----
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app .
EXPOSE 3000
CMD ["server.js"]
Note two changes: the distroless Node images don’t invoke node via a shell—there is no shell—so the entrypoint is baked in and CMD is just the script path. And everything that used to live in the image but isn’t your app simply disappears: apt, bash, curl, and with them, the OS CVE noise in your Trivy reports.
For Python, the equivalent pattern uses gcr.io/distroless/python3-debian12 with pip install done in a builder stage and COPY --from=build of your virtualenv.
Step 2: Running as a Non-Root User
Distroless :nonroot tags run as UID 65532, user nonroot—the work is already done. For conventional bases, add it yourself:
RUN groupadd -r app && useradd -r -g app app
RUN chown -R app:app /app
USER app
Three practical gotchas:
- Ports below 1024 require root. Bind to a high port (e.g., 8080) and put a reverse proxy or load balancer in front, or grant only
CAP_NET_BIND_SERVICEviasetcapat build time. - File ownership matters. Files copied before
USERare root-owned; useCOPY --chownso the app can read (and only where necessary, write) them. - Kubernetes: add
runAsNonRoot: trueandrunAsUser: 65532in yoursecurityContext, so the runtime rejects any image that tries to run as root anyway.
Why does this matter? If an attacker escapes a root container on a default Docker setup, they’re root on your host. Escape a non-root container and they’re an unprivileged user with a UID no host account shares. That’s a categorical difference, not a marginal one.
Step 3: Enforcing a Read-Only Root Filesystem
Even a hardened image can be tampered with at runtime unless the filesystem is immutable. Enforce it at deployment:
docker run --read-only -p 3000:3000 app:hardened
Most applications need somewhere to write—temp files, uploads, sockets. Mount a TMPFS at exactly those paths:
docker run --read-only
--tmpfs /tmp:rw,noexec,nosuid,size=64m
-p 3000:3000 app:hardened
noexec is the key flag: an attacker who drops a binary in /tmp can’t run it. In Kubernetes, the equivalent is:
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
readOnlyRootFilesystem: true maps directly to the Docker --read-only flag and should be in every production pod spec.
Verifying Each Control (Hands-On)
Don’t trust the Dockerfile—prove the controls work:
docker run -d --name hardened --read-only
--tmpfs /tmp:rw,noexec,nosuid
-p 3000:3000 app:hardened
# 1. No shell available
docker exec -it hardened sh
# OCI runtime exec failed: exec: "sh": executable file not found in $PATH
# 2. Running as non-root — exec a known binary instead
docker exec hardened /busybox/id 2>/dev/null
|| docker run --rm --entrypoint /usr/bin/id app:hardened
# uid=65532(nonroot) gid=65532(nonroot)
# 3. Read-only filesystem
docker exec hardened /usr/bin/touch /tmp/test 2>/dev/null
docker run --rm --entrypoint /bin/sh node:20 -c
"mount | grep ' / '"
# Compare: hardened container shows 'ro' for the root mount
Each failure message is your evidence. Automated check: run these assertions in CI so a regressive Dockerfile change fails the build.
Common Breakages and How to Fix Them
- “I can’t debug my distroless container.” Use the
:debugvariant of distroless images (e.g.,gcr.io/distroless/nodejs20-debian12:debug), which includes busybox and a shell. In Kubernetes, use an ephemeral debug container attached to the target pod’s process namespace—no debug tag needed in production. - App crashes writing to a path. The read-only filesystem is doing its job. Identify the path from the error, add a
--tmpfsoremptyDirmount for it. Common candidates:/tmp,/var/log,/run, framework cache dirs like~/.npmor~/.cache. - Crypto materials permission errors. TLS keys mounted as root-owned secrets will be unreadable to UID 65532. Set
defaultMode: 0400with the correct group ownership on the Secret volume, orfsGroupin the pod securityContext. - npm/pip failing at runtime. They shouldn’t run at runtime. If they do, your multi-stage split is wrong—move dependency installation to the build stage.
Before vs After: Risk Reduction Summary
| Dimension | Original (node:20, root) |
Hardened (distroless, nonroot, read-only) |
|---|---|---|
| OS binaries / shell | bash, apt, curl, hundreds of tools | None—app runtime only |
| OS CVEs (Trivy) | Dozens of Debian findings to triage | Near zero |
| Runtime privilege | UID 0 (root) | UID 65532 (nonroot) |
| Host impact on escape | Root on host | Unprivileged UID, no matching host account |
| Persistence capability | Writable FS: plant binaries, modify app | Immutable root; tmpfs is noexec and ephemeral |
| Interactive foothold | Full shell on exploit | No shell to spawn |
Going Further: Capabilities, seccomp, and CI Enforcement
These three controls are the core, not the ceiling. Next steps:
- Drop capabilities:
--cap-drop ALL, adding back only what’s provably needed (usually nothing). - Seccomp: Docker’s default seccomp profile is good; a custom profile (as CISA’s guidance recommends) can cut available syscalls dramatically.
- Enforce in CI: Use policy tooling like OPA/Gatekeeper or Kyverno to reject pod specs missing
runAsNonRootandreadOnlyRootFilesystem, and runtrivyplusdocker inspect-based assertions on every image build.
The pattern across all of it is the same as this post: don’t deploy a control unless you can verify it fails closed. Harden the image, then prove the hardening—automatically, on every build.
Frequently Asked Questions
What is a distroless container image?
An image containing only your app and its runtime dependencies—no shell, no package manager, no OS utilities. This shrinks the attack surface to the minimum needed to run your code and eliminates most OS-level CVE findings from your scans.
Can I debug a distroless container if something breaks?
Yes. Use the :debug distroless tag (includes busybox), spin up a Kubernetes ephemeral debug container, or attach from a sidecar sharing the process namespace. Production images stay minimal; debugging capability is added only when needed.
Does running as non-root break applications that bind port 80?
Ports below 1024 require privileges. Remap your app to a high port (8080+), grant only CAP_NET_BIND_SERVICE, or terminate traffic at a reverse proxy that forwards to your high port.
How do I make a read-only container work if my app writes files?
Mount tmpfs volumes (or emptyDir in Kubernetes) at the specific writable paths the app needs—typically /tmp, log directories, and framework caches. Keep the root filesystem immutable and carve out exactly what’s required.
Is distroless compatible with all languages?
Official images exist for Java, Python, Node.js, Go, and .NET. Statically compiled binaries (Go, Rust) can use the base or static variants—the smallest images of all.
Related reading
- Weekly Threat Intel: Agentic AI Abuse, Autumn 2026 CVE Exploitation and Stealer Tradecraft
- Agent Identity Crisis: Why Service Accounts, Not Users, Hold Agentic AI's Real Keys




