Harden Your Docker Images: Distroless, Rootless and Read-Only Filesystems (with Real Dockerfiles)

Harden Your Docker Images: Distroless, Rootless and Read-Only Filesystems (with Real Dockerfiles)

📋 Key Takeaways
  • Switch to a distroless base, run as a non-root USER, and enforce --read-only.
  • Here's a Dockerfile you've probably written—or inherited—hundreds of times.
  • Think of a container in terms of blast radius.
  • Before changing anything, record the "before" state so you can demonstrate risk reduction
10 min read · 1,869 words
Educational & Ethical Use Only — This article is provided for educational and ethical cybersecurity research purposes only. The techniques described should only be used on systems you own or have explicit permission to test. Always follow responsible disclosure and the laws applicable to you. Mitigations are included so engineers can harden real systems.
Security· 10 min read

🔎 Last updated · Editorial policy · Report an error

Harden Your Docker Images: Distroless, Rootless and Read-Only Filesystems (with Real Dockerfiles)

{
“@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:20 ships bash, sh, apt, curl, and hundreds of binaries. Any code execution becomes an interactive foothold.
  • Package manager present: apt means an attacker can install tooling—cryptominers, reverse shells, credential stealers—at will.
  • Root by default: No USER directive 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 install leaves 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 install is 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_SERVICE via setcap at build time.
  • File ownership matters. Files copied before USER are root-owned; use COPY --chown so the app can read (and only where necessary, write) them.
  • Kubernetes: add runAsNonRoot: true and runAsUser: 65532 in your securityContext, 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 :debug variant 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 --tmpfs or emptyDir mount for it. Common candidates: /tmp, /var/log, /run, framework cache dirs like ~/.npm or ~/.cache.
  • Crypto materials permission errors. TLS keys mounted as root-owned secrets will be unreadable to UID 65532. Set defaultMode: 0400 with the correct group ownership on the Secret volume, or fsGroup in 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 runAsNonRoot and readOnlyRootFilesystem, and run trivy plus docker 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.

Hmmnm
Published by Hmmnm

Hands-on cybersecurity tutorials, CVE breakdowns, and guided learning paths — written and lab-tested by the Hmmnm team.

Keep going — the structured way
This post is one step. The learning paths chain the next ones for you, with progress tracking and no account needed.
Follow a learning path →

Prabhu Kalyan Samal

Application Security Consultant and founder of Hmmnm. Certified Red Team Operator, Burp Suite Certified Practitioner, eWPTX, CompTIA SecurityX and Azure Security Engineer. Writes hands-on labs and threat analysis on web, cloud and AI-agent security.