Skip to content
mroot.co
← All writing
GuidesJun 24, 2026 · 2 min read

Docker Container Hardening Guide: From Bloat to Distroless

A standard Node Dockerfile ships a full OS, a package manager, and a shell — all of it attack surface. Here's how to strip it down to a distroless image with multi-stage builds, and what it actually buys you in image size and CVEs.

Most Dockerfiles I inherit start the same way: FROM node:20, copy everything, npm install, done. It works — and it also ships a 1.1 GB image with a shell, apt, and build tools that an attacker would love to find.

The problem with the default image

A base image like node:20 is Debian plus Node. That means bash, apt, curl, and a compiler toolchain are all sitting in your production container. None of it runs your app; all of it is surface area for a container breakout or a supply-chain payload.

Dockerfile — the bloated default
FROM node:20WORKDIR /appCOPY . .RUN npm installCMD ["node", "server.js"]

Three problems: the whole build context (including .env and .git if you forgot .dockerignore) is copied in, dev dependencies ship to production, and the final image carries every tool from the build.

Step 1 — Multi-stage builds

Split the build from the runtime. One stage compiles with the full toolchain; the final stage copies only the built artifact and production dependencies. Everything used to build is thrown away.

Dockerfile — multi-stage
# build stageFROM node:20 AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build # runtime stageFROM node:20-slim AS runtimeWORKDIR /appCOPY --from=build /app/dist ./distCOPY --from=build /app/node_modules ./node_modulesCMD ["node", "dist/server.js"]
Pin your base image
Reference base images by digest (node:20@sha256:…), not just a moving tag. Tags drift; digests are reproducible and auditable.

Step 2 — Go distroless

node:20-slim is smaller, but it still has a shell. Distroless images contain only your app and its runtime — no package manager, no shell, no busybox. If there is no shell, a common post-exploitation step simply fails.

Dockerfile — distroless runtime
FROM node:20 AS buildWORKDIR /appCOPY package*.json ./RUN npm ci --omit=devCOPY . .RUN npm run build FROM gcr.io/distroless/nodejs20-debian12WORKDIR /appCOPY --from=build /app/dist ./distCOPY --from=build /app/node_modules ./node_modulesUSER nonrootCMD ["dist/server.js"]

Measuring the win

  • Image size drops from ~1.1 GB to ~180 MB — faster pulls, faster cold starts.
  • Known CVEs in a Trivy scan fall by an order of magnitude — you removed the packages they lived in.
  • No shell means no interactive foothold after a container compromise.
  • Running as USER nonroot removes the last easy privilege-escalation path.

Every binary you do not ship is a CVE you never have to patch.

— the case for distroless

Takeaways

  • Always add a .dockerignore before your first COPY.
  • Separate build and runtime stages so tooling never reaches production.
  • Prefer distroless (or scratch for static binaries) for the final image.
  • Scan in CI and fail the build on new high/critical findings.

I ran this exact playbook against Atlas Market's image — the Q2 dev log has the before/after numbers. If your app has other exposed attack surface worth a look, see how I approach security-minded development.

Marc Delacruz — full-stack, security-minded.Get in touch →