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.
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.
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.
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.
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.