Guide

Build your application on a trusted foundation

  • Intermediate
  • 25 min
  • Published
  • Verified

What you'll learn

  • Explain the difference between a DHI -dev variant and its runtime variant, and which stage of a build each belongs in
  • Migrate a single-stage node:22 Dockerfile to a multi-stage build on dhi.io/node
  • Confirm that the runtime image has no shell and runs as a non-root user
  • Compare the old and new images with docker scout compare for size, package count, and CVEs
  • List the attestations a Docker Hardened Image ships with using docker scout attest list

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

Your team’s API image starts with FROM node:22, and every week the scanner reports more than a hundred and fifty vulnerabilities in it. Almost none of them are in code you wrote. They’re in the Debian packages, compilers, and shell utilities that came with the base image, and your application uses almost none of them. You can’t fix them yourself, and nobody can tell you which ones matter.

Most of any container image is somebody else’s software. The base image decides how much of it you carry, who maintains it, and whether you can check where it came from. Docker Hardened Images (DHI) are minimal base images that Docker builds, patches, and signs. Each one ships with SBOMs, build provenance, and scan results you can inspect.

In this guide, you move a small Node.js API from node:22 to a multi-stage build on dhi.io/node, then measure the result: image size, package count, CVE count, and the attestations the new base comes with.

A multi-stage build on Docker Hardened Images The build stage uses dhi.io/node:22-alpine3.23-dev, which has a shell, npm, and apk and runs as root. It runs npm ci --omit=dev and is discarded after the build. Only the application directory is copied into the final stage, dhi.io/node:22-alpine3.23, which has Node.js and your app but no shell or npm, and runs as the non-root node user. The final stage is pushed to the registry as myorg/my-app:1.1, with an SBOM and provenance attached. Those attestations are not yet signed. BUILD STAGE · DISCARDED builder node:22-alpine3.23-dev shell, npm, apk runs as root npm ci --omit=dev FINAL STAGE · SHIPS runtime node:22-alpine3.23 Node.js and your app no shell, no npm, user node CMD ["node", "index.js"] REGISTRY myorg/my-app:1.1 image + SBOM + provenance not yet signed COPY /app --push Tools to build the app Only what runs the app What you deploy

What you’ll do

  1. Measure the existing node:22 image with Docker Scout.
  2. Rewrite the Dockerfile as two stages: a DHI -dev image to install dependencies, and a DHI runtime image to run them.
  3. Build and push the new image with an SBOM and provenance attached.
  4. Confirm the runtime image has no shell and doesn’t run as root.
  5. Compare the two images, and list the attestations the hardened base ships with.

The terminals in this guide are simulations. Click through them in your browser to see each command and what it prints, then run the same commands on your own machine.

Prerequisites

To follow along on your own machine, you need:

  • Docker Desktop, or Docker Engine with the Buildx and Docker Scout CLI plugins. See Get Docker and Install Docker Scout.
  • The containerd image store turned on, or a Buildx builder that uses the docker-container driver. The default image store can’t hold an image with attestations attached. See Build attestations.
  • A Docker account. DHI Community images are free, and you pull them with your Docker ID.
  • A Docker Hub repository you can push to. This guide uses myorg/my-app. Substitute your own namespace.

Starting state

The application is ~/my-app, a small Express API that listens on port 3000:

my-app/
├── .dockerignore
├── Dockerfile
├── index.js
├── package-lock.json
└── package.json
const express = require("express");

const app = express();

app.get("/", (req, res) => {
  res.json({ status: "ok" });
});

app.listen(3000, () => {
  console.log("Listening on port 3000");
});
{
  "name": "my-app",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js"
  },
  "dependencies": {
    "express": "^4.19.2"
  },
  "devDependencies": {
    "nodemon": "^3.1.9"
  }
}

The committed package-lock.json was generated when Express 4.19.2 was current, and it still pins Express and everything beneath it to the versions resolved that day. To recreate this state in an empty project, run npm install express@4.19.2. The new build uses npm ci, which installs exactly what the lockfile records and fails if it’s missing.

.dockerignore keeps your local node_modules out of the build context, so COPY . . can’t overwrite the dependencies the build installed:

node_modules
npm-debug.log

The current Dockerfile is a single stage on the Docker Official Image:

FROM node:22
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["node", "index.js"]

The image built from it is myorg/my-app:1.0.

Measure what you have now

Start by measuring the current image, so you have a baseline. docker scout quickview summarizes an image’s vulnerabilities and the base image they came from:

~/my-app

docker scout quickview myorg/my-app:1.0
    ✓ Image stored for indexing
    ✓ Indexed 828 packages
    ✓ Provenance obtained from attestation
    ✓ Policy evaluation completed

 Target             │  myorg/my-app:1.0  │    2C    64H    86M   254L    35?
   digest           │  f5d47ce231b8      │
 Base image         │  node:22           │    2C    58H    84M   248L    35?
 Updated base image │  node:26-slim      │    0C     5H     8M    27L
                    │                    │    -2    -53    -76   -221    -35

Policy status  FAILED  (3/7 policies met)
Health score  D  (50%)

 Status │                     Policy                     │           Results
────────┼────────────────────────────────────────────────┼─────────────────────────────
 !      │ Image runs as the root user                    │
 !      │ Copyleft licensed packages found               │    1091 packages
 !      │ Fixable critical or high vulnerabilities found │    1C    17H     0M     0L
 ✓      │ No high-profile vulnerabilities                │    0C     0H     0M     0L
 ✓      │ No outdated base images                        │
 ✓      │ No unapproved base images                      │    0 deviations
 !      │ Required supply chain attestations missing     │    1 deviation

What's next:
    View policy violations → docker scout policy myorg/my-app:1.0
    View vulnerabilities → docker scout cves myorg/my-app:1.0
    View base image update recommendations → docker scout recommendations myorg/my-app:1.0
    Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.0

Nearly every finding is on the Base image row: it accounts for 427 of the image’s 441 CVEs. Debian, its package manager, a compiler toolchain, git, curl, and a shell all ship in node:22 because some images built on it need them. Yours doesn’t, but it still carries them. The 14 findings that aren’t in the base come from your npm dependencies, and the ones in your production dependencies stay yours to fix whichever base you choose.

Scout also evaluates the image against its default policies. Running as root and missing supply chain attestations are two of the failures; you fix both in this guide.

npm install also installed nodemon, a development dependency, into the image that runs in production.

Sign in to the DHI registry

Docker Hardened Images are served from dhi.io. Sign in with your Docker ID and password, or a personal access token:

~/my-app

docker login dhi.io -u your-docker-id
Login Succeeded

Pick the two variants

Every DHI language image comes in two variants, and a multi-stage build uses both:

  • The -dev variant, for example dhi.io/node:22-alpine3.23-dev, has a shell, a package manager (apk here, since it’s Alpine-based), and a root user. Use it in build stages, where you install dependencies.
  • The runtime variant, dhi.io/node:22-alpine3.23, has only what Node.js needs to run. It has no shell, no package manager, and runs as a non-root user by default. Use it for the final stage, the one you ship.

Runtime images differ from what you’re used to in two ways that affect your Dockerfile, so check them before you write it:

~/my-app

docker pull dhi.io/node:22-alpine3.23
22-alpine3.23: Pulling from node
Digest: sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d
Status: Downloaded newer image for dhi.io/node:22-alpine3.23
dhi.io/node:22-alpine3.23
docker image inspect dhi.io/node:22-alpine3.23 --format 'entrypoint={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}} user={{.Config.User}}'
entrypoint=null cmd=["node"] user=1000

There’s no entrypoint, and the default command is a bare node, so your CMD must name both node and the script. CMD ["index.js"] on its own would try to run index.js as a program, and the container wouldn’t start. The user is UID 1000, named node inside the image, not root. The files your app reads must belong to that user, and your app can’t bind to a port below 1024 in Kubernetes or on Docker Engine older than 20.10. Port 3000 is fine.

Rewrite the Dockerfile

Replace the contents of Dockerfile with this:

# syntax=docker/dockerfile:1

FROM dhi.io/node:22-alpine3.23-dev AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

FROM dhi.io/node:22-alpine3.23
ENV PATH=/app/node_modules/.bin:$PATH
COPY --from=builder --chown=node:node /usr/src/app /app
WORKDIR /app
CMD ["node", "index.js"]

What changed, and why:

  • Two FROM lines. The builder stage needs npm, which only the -dev variant has. Nothing from that stage ships except what the final stage copies out of it, so its shell, package manager, and root user stay behind.
  • npm ci --omit=dev. Installs exactly the versions in package-lock.json, and leaves out nodemon and any other development dependency.
  • COPY --from=builder --chown=node:node. Moves the app and its node_modules into the runtime image, owned by the user the image runs as.
  • ENV PATH. Puts any executables your dependencies install on the path, since there’s no npx in the runtime image.
  • CMD ["node", "index.js"]. The runtime image has no entrypoint, so the command names the node binary as well as the script.

RUN instructions work only in the builder stage. The runtime image has no shell to run them, so any setup your app needs, such as compiling native modules or generating files, must happen in the builder and be copied across.

Before you build, check where each piece of the old image ends up:

  1. npm ci: the builder stage only. npm and a shell exist only in the -dev variant.
  2. The Node.js binary: both stages, each from its own base image.
  3. Your code and production node_modules: both. They’re assembled in the builder and copied into the runtime stage.
  4. nodemon: neither. npm ci --omit=dev skips development dependencies.
  5. A private-registry npm token: a build secret mounted with RUN --mount=type=secret, so it’s never written to a layer.
  6. A shell for debugging: neither image you ship. Use docker debug, or run the -dev variant locally.

Build and push the new image

Build the image with an SBOM and maximum-detail provenance, and push it as myorg/my-app:1.1:

~/my-app

docker buildx build --sbom=true --provenance=mode=max --push -t myorg/my-app:1.1 .
[+] Building 18.4s (17/17) FINISHED                          docker:desktop-linux
 => [internal] load build definition from Dockerfile
 => resolve image config for docker-image://docker.io/docker/dockerfile:1
 => [internal] load metadata for dhi.io/node:22-alpine3.23
 => [internal] load metadata for dhi.io/node:22-alpine3.23-dev
 => [internal] load .dockerignore
 => [internal] load build context
 => [builder 1/5] FROM dhi.io/node:22-alpine3.23-dev@sha256:6721841fb09d27f6f644a5524dc75bc0e5d9114787eaa567df4a50e1dc639eec
 => [stage-1 1/3] FROM dhi.io/node:22-alpine3.23@sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d
 => [builder 2/5] WORKDIR /usr/src/app
 => [builder 3/5] COPY package*.json ./
 => [builder 4/5] RUN npm ci --omit=dev
 => [builder 5/5] COPY . .
 => [stage-1 2/3] COPY --from=builder --chown=node:node /usr/src/app /app
 => [stage-1 3/3] WORKDIR /app
 => [linux/arm64] generating sbom using docker.io/docker/buildkit-syft-scanner:stable-1
 => exporting to image
 => => exporting layers
 => => exporting manifest sha256:4d5f2bdd62008cb040408d760c574a3e065700ec97642d43ad614a1f8ec2cd34
 => => exporting attestation manifest sha256:eacd6eea43bf31b9a8d9c02cb8de5507ff198d482f96ac0055d99a65d2e2ff45
 => => exporting manifest list sha256:703ff4f8c3aaac04347d3dfbd69c609144afca6bfa3fb8d20baa75b5b1ad41f2
 => => pushing layers
 => => pushing manifest for docker.io/myorg/my-app:1.1@sha256:703ff4f8c3aaac04347d3dfbd69c609144afca6bfa3fb8d20baa75b5b1ad41f2

The two attestation flags aren’t decoration. When you build on a DHI, Docker Scout reads your image’s provenance to work out which hardened image it was built from, and then applies the VEX statements Docker publishes for that base. Without --provenance=mode=max and --sbom=true, Scout can’t trace the base, and reports CVEs in it that Docker has already assessed as not exploitable. Pushing matters for the same reason: the attestations travel with the image in the registry, which is where Scout reads them.

Confirm the runtime image is locked down

Run the new image and make a request to it:

~/my-app

docker run -d --name my-app -p 3000:3000 myorg/my-app:1.1
f0b50abdca5fac9eca8b99b0b421bab919ec64e6370d896b1f34c636e601fe91
curl http://localhost:3000/
{"status":"ok"}

The app behaves exactly as it did on node:22. Now try what an attacker who got code execution inside the container would try first, opening a shell:

~/my-app

docker exec -it my-app sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
docker image inspect myorg/my-app:1.1 --format 'user={{.Config.User}}'
user=1000

This failure is the point. There’s no sh, bash, apk, or npm in the image, so there’s nothing to drop into and nothing to install tools with. The process runs as UID 1000 (node), so it can’t write to system paths even if something goes wrong.

This changes how you debug. Instead of docker exec, use docker debug, which attaches a temporary toolbox container without changing the image, or run the -dev variant on your own machine when you need a shell.

Compare the old image with the new one

docker scout compare analyzes two images side by side. The registry:// prefix tells Scout to read 1.1 from Docker Hub, where its attestations are, rather than from your local image store:

~/my-app

docker scout compare registry://myorg/my-app:1.1 --to myorg/my-app:1.0 --ignore-unchanged
    ! 'docker scout compare' is experimental and its behaviour might change in the future
    ✓ SBOM obtained from attestation, 76 packages found
    ✓ Provenance obtained from attestation
    ✓ Image stored for indexing
    ✓ Indexed 828 packages
    ✓ Provenance obtained from attestation
    ✓ Policy evaluation completed
    ✓ Policy evaluation completed
    ✓ Pulled


  ## Overview

                     │                Analyzed Image      │          Comparison Image
  ───────────────────┼────────────────────────────────────┼────────────────────────────────────
   Target            │  registry://myorg/my-app:1.1       │  myorg/my-app:1.0
     digest          │  4d5f2bdd6200                      │  f5d47ce231b8
     tag             │  1.1                               │  1.0
     platform        │ linux/arm64                        │ linux/arm64
     vulnerabilities │    0C     4H     2M     6L         │    2C    64H    86M   254L    35?
                     │    -2    -60    -84   -248    -35  │
     size            │ 38 MB (-367 MB)                    │ 405 MB
     packages        │ 76 (-752)                          │ 828
                     │                                    │
   Base image        │  node:22-alpine3.23                │  node:22
     tags            │ also known as                      │ also known as
                     │                                    │   • 22-bookworm
                     │                                    │   • 22.23
                     │                                    │   • 22.23.3-bookworm
                     │                                    │   • jod
                     │                                    │   • jod-bookworm
     vulnerabilities │                                    │    2C    58H    84M   248L    35?

The full output continues with environment, label, policy, package-by-package, and CVE-by-CVE detail. The ## Overview section at the top is the summary. Your numbers will differ, because CVEs are disclosed and images are rebuilt every day, but the shape holds:

  • Size drops by about 90%. Most of node:22 was operating system the app never used.
  • Packages drop from hundreds to under a hundred. That’s what the scanner has to match against, and what an attacker has to work with.
  • Vulnerabilities drop to the ones your own dependencies bring. Every one that remains is in an npm package, such as path-to-regexp and body-parser, that Express 4.19.2 pulls in. They’re in node_modules, and a different base can’t remove them.

See what the hardened base ships with

A smaller image is easier to trust, but a hardened base also comes with evidence you can check: who built it, how, and what’s in it. List the attestations attached to the base image:

~/my-app

docker scout attest list registry://dhi.io/node:22-alpine3.23
    ✓ SBOM obtained from attestation, 11 packages found
    ✓ Provenance obtained from attestation


## Overview

              │                    Analyzed Image
──────────────┼───────────────────────────────────────────────────────
 Subject      │  registry://dhi.io/node:22-alpine3.23
   digest     │  9e2168660d60
   platform   │ linux/arm64
   provenance │ https://github.com/docker-hardened-images/definitions
              │  https://github.com/docker-hardened-images/definitions/blob/95deccd5cb6cd46d06fea043cc65990bbb7f962c
   size       │ 37 MB
   packages   │ 11


## Attestations

https://cyclonedx.org/bom/v1.6  CycloneDX SBOM
https://docker.com/dhi/changelog/v0.1  Changelog
https://docker.com/dhi/source/v0.1
https://in-toto.io/attestation/vulns/v0.2  in-toto vulnerabilities
https://scout.docker.com/provenance/v0.1  Scout provenance
https://scout.docker.com/sbom/v0.1  Scout SBOM
https://scout.docker.com/secrets/v0.1  Scout secret scan
https://scout.docker.com/tests/v0.1  Scout test report
https://scout.docker.com/virus/v0.1  Scout virus/malware report
https://scout.docker.com/vulnerabilities/v0.1  Scout vulnerabilities
https://slsa.dev/provenance/v0.2  SLSA provenance
https://slsa.dev/provenance/v1  SLSA provenance
https://slsa.dev/verification_summary/v1  SLSA verification summary
https://spdx.dev/Document  SPDX SBOM

Scout prints a details table under each attestation; they’re left out here. Each https:// line is a predicate type, which names the kind of statement an attestation makes. The exact set varies by image variant and over time, but you’ll find:

  • SBOMs in CycloneDX, SPDX, and Scout’s own format: every package in the image and its version.
  • Provenance, including a SLSA statement: the source repository and commit the image was built from, and how it was built. DHIs are built to SLSA Build Level 3.
  • Source and changelog: where the image definition lives, and what changed between builds.
  • Scan results: vulnerabilities, secrets, and virus scans, and a record of the tests the image passed.

Some variants also ship a VEX attestation (https://openvex.dev/ns/v0.2.0): Docker’s statements about CVEs that are present in a package but not exploitable in that image.

Docker signs all of these, so you can verify them against Docker’s public key instead of taking the registry’s word for them.

Now compare that with your own image:

~/my-app

docker scout attest list registry://myorg/my-app:1.1
    ✓ SBOM obtained from attestation, 76 packages found
    ✓ Provenance obtained from attestation


## Overview

            │                Analyzed Image
────────────┼────────────────────────────────
 Subject    │  registry://myorg/my-app:1.1
   digest   │  4d5f2bdd6200
   platform │ linux/arm64
   size     │ 38 MB
   packages │ 76
            │
 Base image │  dhi.io/node:22-alpine3.23
            │  920a7460ab7a


## Attestations

https://slsa.dev/provenance/v1  SLSA provenance
https://spdx.dev/Document  SPDX SBOM

Your image carries the two attestations BuildKit generated during the build: SLSA provenance and an SPDX SBOM. Nobody has signed them. They describe your image, but nothing yet proves that they, or the image, came from you.

Understand what happened

You changed two FROM lines, and the trust picture changed in three ways:

Before: node:22 After: DHI multi-stage
Hundreds of OS packages your app never uses Only what Node.js needs, about 90% smaller
Shell, package manager, root user at runtime No shell or package manager, runs as node
Development dependencies in production npm ci --omit=dev in a stage that’s thrown away
Base image with no signed record of its build Signed SBOMs, provenance, and scans you can check

The multi-stage split is what makes the minimal runtime usable. The -dev variant gives the build the tools it needs, and none of them reach the image you deploy.

Be clear about what this doesn’t do:

  • It doesn’t fix your dependencies. The CVEs left in myorg/my-app:1.1 are in node_modules. You still have to find and fix them.
  • It doesn’t make your image trustworthy by itself. DHI’s attestations are signed by Docker and describe the base. Your image’s SBOM and provenance are unsigned, and anyone who can push to myorg/my-app can overwrite the 1.1 tag.
  • The base keeps moving. Docker rebuilds dhi.io/node:22-alpine3.23 as patches land, so the same tag points at a new digest over time. Rebuild regularly to pick those patches up. If your standards require pinning by digest, enforce it with a policy rather than by convention.

Where to go next

The image now starts from a trusted base, and the CVEs that remain are in your own dependencies. Next, sort the ones you can act on from the noise, fix them, and prove the fix in Find and fix vulnerabilities before you ship.

For other languages, see the DHI migration examples for Go, Python, Java, and .NET.

Learning paths

Part of a learning path

This works on its own and is also included in the paths below. Open a path to see the complete route.