Migrating to a Hardened Image
Migrating to a Hardened Image
You know the image you want: the DHI Node.js variants. Now you'll rewrite the Dockerfile into a multi-stage build that uses the -dev variant to install dependencies and the minimal distroless runtime variant to actually run the app.
Note
The Dockerfile below pulls hardened images directly from the DHI registry at dhi.io. Make sure you're authenticated (docker login dhi.io) with an account entitled to Docker Hardened Images.
✍️ Step 1: Rewrite the Dockerfile
Replace the contents of Dockerfile with this hardened, multi-stage version:
# ---- Build stage: the -dev variant has a shell and npm ----
FROM dhi.io/node:20-debian12-dev AS build
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY src ./src
# ---- Runtime stage: minimal, distroless, non-root, no shell ----
FROM dhi.io/node:20-debian12 AS runtime
WORKDIR /app
# Bring over only what's needed to run the app.
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/src ./src
COPY --from=build /app/package.json ./package.json
EXPOSE 3000
# Invoke node explicitly. Use exec form so the app is PID 1 and receives signals.
CMD ["node", "src/server.js"]
What changed — and why it matters:
- Multi-stage — build tooling (
npm, shell) lives only in thebuildstage and never ships to production. - Distroless runtime — the final image has no shell and no package manager. Even with code execution, an attacker can't drop into a shell.
- Non-root by default — DHI runtime images run as an unprivileged user out of the box.
🏗️ Step 2: Build the hardened image
docker build -t product-catalog:hardened .
🚀 Step 3: Run it and confirm it still works
docker run -d --rm --name catalog-hardened -p 8090:3000 product-catalog:hardened && \
sleep 2 && curl -s http://localhost:8090/health
Open it again in your browser at http://localhost:8090 to be sure.
Then stop it:
docker stop catalog-hardened
📉 Step 4: Compare the results
Look at the size difference between the vulnerable build and the hardened one:
docker images 'product-catalog' --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}'
And re-scan to confirm the CVEs are gone:
docker scout quickview product-catalog:hardened
Tip
You just turned a sprawling, root-running, hundreds-of-CVEs image into a small, non-root, near-zero-CVE one — without changing a line of application code. That's the whole point of starting from a hardened base.
The image is secure. Next, you'll prove it by inspecting the attestations baked into the DHI. ➡️