Guide

Enforce your organization's container standards

  • Advanced
  • 35 min
  • Published
  • Verified

What you'll learn

  • Write a Dockerfile.rego build policy that only allows base images from dhi.io pinned by digest, and watch Buildx refuse a build before it runs
  • Test a build policy with docker buildx policy test, and debug a denial with --policy log-level=debug
  • Tune Docker Scout's built-in policies with --policy-config, and add your own Rego policy with --policy-dir
  • Use docker scout policy --exit-code as a pass or fail check on a built image

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 agreed on a set of rules for container images. Base images come from Docker Hardened Images, pinned by digest. Nothing ships with a fixable critical or high vulnerability. Every image runs as a non-root user and records the repository it was built from. The rules are written down on a wiki page.

Then a pull request arrives with FROM node:22 in a new service’s Dockerfile. It builds, the tests pass, and a reviewer approves it, because nobody reads the wiki page while reviewing a diff. A rule that a person has to remember is a rule that gets skipped.

In this guide, you turn those rules into checks that run at two points:

  • Before the build, a Buildx build policy checks every build input: base images, downloads, and Git sources. A Dockerfile that breaks the rules never starts building.
  • After the build, Docker Scout policies check the image you produced: its packages, vulnerabilities, attestations, and configuration. An image that breaks the rules fails the check, and --exit-code turns that failure into something a pipeline can act on.
Two places to enforce container standards Before the build, Buildx resolves the build inputs, such as FROM images, ADD downloads, and the build context, and checks each one against the build policy in Dockerfile.rego. If an input is denied, the build never starts. If every input is allowed, the build runs and produces an image. After the build, docker scout policy indexes the image into an SBOM, adds vulnerability data, and evaluates it against Scout policies. If a policy isn't met, the command returns exit code 2. BEFORE THE BUILD Build inputs FROM images ADD downloads build context Build policy checks each input before anything runs Dockerfile.rego denied: the build never starts resolve AFTER THE BUILD Image packages, config, attestations myorg/my-app Scout policies SBOM + CVE data, evaluated locally --exit-code not met: exit code 2 index build

The two layers check different things. A build policy sees what goes into a build, so it can refuse a base image before a single instruction runs, but it can’t see a vulnerable npm package that npm ci installs. Scout sees what came out, so it catches that package, but only after you’ve spent the build. Most teams want both.

What you’ll do

Starting from a small Node.js API whose Dockerfile already uses Docker Hardened Images, you’ll:

  1. Write a build policy that only allows base images from dhi.io, pinned by digest, and watch it refuse the current Dockerfile.
  2. Debug the denial, pin the digests, and build successfully.
  3. Write unit tests for the policy.
  4. Evaluate the built image with Docker Scout’s built-in policies, tuned to your standards.
  5. Add a custom Scout policy, watch the image fail it, fix the image, and pass.

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

Prerequisites

To follow along on your own machine, you need:

  • Docker with Buildx 0.31.0 or later and BuildKit 0.27.0 or later, for build policies. See the build policies prerequisites.
  • The Docker Scout CLI plugin. It’s included in Docker Desktop. See Install Docker Scout.
  • A Docker account, signed in to dhi.io with docker login dhi.io to pull Docker Hardened Images, and to Docker Hub with docker login to push. See Use a Docker Hardened Image.

The examples push to myorg/my-app on Docker Hub. Replace myorg with your own namespace.

Starting point

The project is a small Express API in ~/my-app:

my-app/
├── Dockerfile
├── index.js
├── package.json
└── package-lock.json

Its Dockerfile is a multi-stage build on Docker Hardened Images. The -dev variant has npm and a shell for installing dependencies. The runtime variant has neither, and runs as a non-root user:

# 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"]

If you’re not using Docker Hardened Images yet, see Build your application on a trusted foundation.

Write a build policy

Check that your Buildx version supports build policies:

~/my-app

docker buildx version
github.com/docker/buildx v0.38.0 0dbe87f36ed472fe36b6796c3f70b4210a731fc5

A build policy is a Rego file named after the Dockerfile it governs. Buildx looks for Dockerfile.rego next to Dockerfile and loads it automatically on every build. You don’t need a flag.

Create Dockerfile.rego in ~/my-app:

package docker

default allow := false

# The build context, including the Dockerfile itself
allow if input.local

# Docker's own build tooling: the Dockerfile frontend named by
# "# syntax=", and the SBOM scanner that --sbom=true runs
allow if {
  input.image.fullRepo in {
    "docker.io/docker/dockerfile",
    "docker.io/docker/buildkit-syft-scanner",
  }
}

# Base images: Docker Hardened Images, pinned by digest
allow if {
  input.image.host == "dhi.io"
  input.image.isCanonical
}

deny_msg contains msg if {
  not allow
  input.image
  input.image.host != "dhi.io"
  msg := sprintf("image %s is not from dhi.io; base images must be Docker Hardened Images", [input.image.ref])
}

deny_msg contains msg if {
  not allow
  input.image.host == "dhi.io"
  not input.image.isCanonical
  msg := sprintf("image %s must be pinned by digest (@sha256:...)", [input.image.ref])
}

decision := {"allow": allow, "deny_msg": deny_msg}

Buildx evaluates the policy once for each build input, with that input’s details in input. The policy has three parts:

  • default allow := false makes it deny by default. An input is allowed only if one of the allow rules matches it. Several allow rules act as OR, and the conditions inside one rule act as AND.
  • allow if input.local lets Buildx read your build context. Without it, Buildx can’t read the Dockerfile, so every build would fail.
  • The two image rules allow Docker’s build tooling, and allow images from dhi.io only when isCanonical is true: the reference in the Dockerfile includes a digest. A tag can be moved to different content. A digest can’t.

The deny_msg rules don’t change the decision. They explain it, so the person whose build fails knows what to change.

See the build refused

Build the image:

~/my-app

docker build -t myorg/my-app:1.1 .
[+] Building 0.5s (5/5) FINISHED                           docker:desktop-linux
 => ERROR loading policies Dockerfile.rego                                 0.5s
 => [internal] load build definition from Dockerfile                       0.0s
 => => transferring dockerfile: 345B                                       0.0s
 => resolve image config for docker-image://docker.io/docker/dockerfile:1  0.4s
 => [auth] docker/dockerfile:pull token for registry-1.docker.io           0.0s
 => CACHED docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3f  0.0s
 => => resolve docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906  0.0s
------
 > loading policies Dockerfile.rego:
0.000 checking policy for source local://dockerfile
0.001 policy decision for source local://dockerfile: ALLOW
0.003 checking policy for source docker-image://docker.io/docker/dockerfile:1
0.005 policy decision for source docker-image://docker.io/docker/dockerfile:1: ALLOW
0.446 checking policy for source docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e (linux/arm64)
0.448 policy decision for source docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e (linux/arm64): ALLOW
0.486 checking policy for source local://dockerfile
0.487 policy decision for source local://dockerfile: ALLOW
0.489 checking policy for source docker-image://dhi.io/node:22-alpine3.23-dev (linux/arm64)
0.489 checking policy for source docker-image://dhi.io/node:22-alpine3.23 (linux/arm64)
0.492 policy decision for source docker-image://dhi.io/node:22-alpine3.23-dev (linux/arm64): DENY
0.492  - image dhi.io/node:22-alpine3.23-dev must be pinned by digest (@sha256:...)
0.492 policy decision for source docker-image://dhi.io/node:22-alpine3.23 (linux/arm64): DENY
0.492  - image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...)
------
Policy: image dhi.io/node:22-alpine3.23-dev must be pinned by digest (@sha256:...)
Dockerfile:3
--------------------
   1 |     # syntax=docker/dockerfile:1
   2 |
   3 | >>> FROM dhi.io/node:22-alpine3.23-dev AS builder
   4 |     WORKDIR /usr/src/app
   5 |     COPY package*.json ./
--------------------
ERROR: failed to build: failed to solve: could not resolve image due to policy: source "docker-image://dhi.io/node:22-alpine3.23-dev" not allowed by policy: action DENY

This failure is expected. The base images come from the right registry, but by tag, so the policy refuses them. The log under the summary shows every source the policy checked: the build context and the Dockerfile frontend are allowed, and both FROM images are denied with your deny_msg. Buildx stops before running npm ci or any other instruction, so a noncompliant Dockerfile costs seconds, not a full build.

Debug the denial

When a denial isn’t what you expected, ask Buildx to show what the policy saw. --progress=plain prints each policy decision, and --policy log-level=debug adds the full input for every source. The output below is the policy step, #1, only. The other build steps and the error summary that follow it are left out:

~/my-app

docker buildx build --progress=plain --policy log-level=debug .
#1 loading policies Dockerfile.rego
#1 0.000 policy input: {
#1 0.000   "env": {
#1 0.000     "filename": "Dockerfile",
#1 0.000     "capsRequest": true,
#1 0.000     "depth": 0
#1 0.000   }
#1 0.000 }
#1 0.003 checking policy for source local://dockerfile
#1 0.003 policy input: {
#1 0.003   "env": {
#1 0.003     "filename": "Dockerfile",
#1 0.003     "depth": 0
#1 0.003   },
#1 0.003   "local": {
#1 0.003     "name": "dockerfile"
#1 0.003   }
#1 0.003 }
#1 0.004 policy response: &{Allow:0x462758c7866a DenyMessages:[] Caps:map[]}
#1 0.004 policy decision for source local://dockerfile: ALLOW
#1 0.007 checking policy for source docker-image://docker.io/docker/dockerfile:1
#1 0.007 policy input: {
#1 0.007   "env": {
#1 0.007     "filename": "Dockerfile",
#1 0.007     "depth": 0
#1 0.007   },
#1 0.007   "image": {
#1 0.007     "ref": "docker.io/docker/dockerfile:1",
#1 0.007     "host": "docker.io",
#1 0.007     "repo": "docker/dockerfile",
#1 0.007     "fullRepo": "docker.io/docker/dockerfile",
#1 0.007     "tag": "1",
#1 0.007     "platform": "linux/arm64",
#1 0.007     "os": "linux",
#1 0.007     "arch": "arm64"
#1 0.007   }
#1 0.007 }
#1 0.007 unknowns for policy evaluation: [image.checksum image.labels image.user image.volumes image.workingDir image.env image.hasProvenance image.provenance image.signatures]
#1 0.009 policy response: &{Allow:0x462758d901c8 DenyMessages:[] Caps:map[]}
#1 0.009 policy decision for source docker-image://docker.io/docker/dockerfile:1: ALLOW
#1 0.119 checking policy for source docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e (linux/arm64)
#1 0.119 policy input: {
#1 0.119   "env": {
#1 0.119     "filename": "Dockerfile",
#1 0.119     "depth": 0
#1 0.119   },
#1 0.119   "image": {
#1 0.119     "ref": "docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e",
#1 0.119     "host": "docker.io",
#1 0.119     "repo": "docker/dockerfile",
#1 0.119     "fullRepo": "docker.io/docker/dockerfile",
#1 0.119     "tag": "1",
#1 0.119     "platform": "linux/arm64",
#1 0.119     "os": "linux",
#1 0.119     "arch": "arm64",
#1 0.119     "isCanonical": true,
#1 0.119     "checksum": "sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e"
#1 0.119   }
#1 0.119 }
#1 0.119 unknowns for policy evaluation: [image.labels image.user image.volumes image.workingDir image.env image.hasProvenance image.provenance image.signatures]
#1 0.120 policy response: &{Allow:0x4627591c1a38 DenyMessages:[] Caps:map[]}
#1 0.120 policy decision for source docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e (linux/arm64): ALLOW
#1 0.158 checking policy for source local://dockerfile
#1 0.158 policy input: {
#1 0.158   "env": {
#1 0.158     "filename": "Dockerfile",
#1 0.158     "depth": 0
#1 0.158   },
#1 0.158   "local": {
#1 0.158     "name": "dockerfile"
#1 0.158   }
#1 0.158 }
#1 0.159 policy response: &{Allow:0x462758cc569a DenyMessages:[] Caps:map[]}
#1 0.159 policy decision for source local://dockerfile: ALLOW
#1 0.161 checking policy for source docker-image://dhi.io/node:22-alpine3.23-dev (linux/arm64)
#1 0.161 checking policy for source docker-image://dhi.io/node:22-alpine3.23 (linux/arm64)
#1 0.161 policy input: {
#1 0.161   "env": {
#1 0.161     "filename": "Dockerfile",
#1 0.161     "depth": 0
#1 0.161   },
#1 0.161   "image": {
#1 0.161     "ref": "dhi.io/node:22-alpine3.23",
#1 0.161     "host": "dhi.io",
#1 0.161     "repo": "dhi.io/node",
#1 0.161     "fullRepo": "dhi.io/node",
#1 0.161     "tag": "22-alpine3.23",
#1 0.161     "platform": "linux/arm64",
#1 0.161     "os": "linux",
#1 0.161     "arch": "arm64"
#1 0.161   }
#1 0.161 }
#1 0.161 unknowns for policy evaluation: [image.checksum image.labels image.user image.volumes image.workingDir image.env image.hasProvenance image.provenance image.signatures]
#1 0.162 policy input: {
#1 0.162   "env": {
#1 0.162     "filename": "Dockerfile",
#1 0.162     "depth": 0
#1 0.162   },
#1 0.162   "image": {
#1 0.162     "ref": "dhi.io/node:22-alpine3.23-dev",
#1 0.162     "host": "dhi.io",
#1 0.162     "repo": "dhi.io/node",
#1 0.162     "fullRepo": "dhi.io/node",
#1 0.162     "tag": "22-alpine3.23-dev",
#1 0.162     "platform": "linux/arm64",
#1 0.162     "os": "linux",
#1 0.162     "arch": "arm64"
#1 0.162   }
#1 0.162 }
#1 0.162 unknowns for policy evaluation: [image.checksum image.labels image.user image.volumes image.workingDir image.env image.hasProvenance image.provenance image.signatures]
#1 0.164 policy response: &{Allow:0x462759060cc0 DenyMessages:[image dhi.io/node:22-alpine3.23-dev must be pinned by digest (@sha256:...)] Caps:map[]}
#1 0.164 policy decision for source docker-image://dhi.io/node:22-alpine3.23-dev (linux/arm64): DENY
#1 0.164  - image dhi.io/node:22-alpine3.23-dev must be pinned by digest (@sha256:...)
#1 0.164 policy response: &{Allow:0x462758d90bf0 DenyMessages:[image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...)] Caps:map[]}
#1 0.164 policy decision for source docker-image://dhi.io/node:22-alpine3.23 (linux/arm64): DENY
#1 0.164  - image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...)
#1 ERROR: failed to solve: could not resolve image due to policy: source "docker-image://dhi.io/node:22-alpine3.23" not allowed by policy: action DENY

Two things to read in this output:

  • The Dockerfile frontend is an input too. The # syntax= line makes BuildKit pull docker/dockerfile:1, and Buildx checks it against your policy like any other image. That’s why the policy allows Docker’s build tooling. A strict registry allowlist without that rule fails on the first line of the Dockerfile.
  • ref has a tag and no digest. That’s the field isCanonical is derived from, so it’s missing from the dhi.io inputs and present on the frontend once Buildx has resolved it by digest. The unknowns line lists fields Buildx hasn’t fetched yet, such as the image’s labels and signatures. It fetches them only when a rule needs them.

To check one source without a whole build, use docker buildx policy eval. It prints the denial message when the policy refuses the source, and nothing when it allows it:

~/my-app

docker buildx policy eval docker-image://node:22
ERROR: policy denied: image docker.io/library/node:22 is not from dhi.io; base images must be Docker Hardened Images
docker buildx policy eval docker-image://dhi.io/node:22-alpine3.23@sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d

Pin the base images and build

Find the digest each tag points to now:

~/my-app

docker buildx imagetools inspect dhi.io/node:22-alpine3.23-dev --format "{{.Manifest}}"
Name:      dhi.io/node:22-alpine3.23-dev
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:6721841fb09d27f6f644a5524dc75bc0e5d9114787eaa567df4a50e1dc639eec

Manifests:
  Name:        dhi.io/node:22-alpine3.23-dev@sha256:bc9d441c70e69a604ef734660a159d419b62d1da167b61a9616de000c371938a
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/amd64
  Annotations:
    com.docker.dhi.build.id: builder-c6540a56-d341-4456-8672-b621b4323def/linux-amd64/ifwb4kc850t237mqsmgfbs039

  Name:        dhi.io/node:22-alpine3.23-dev@sha256:bd52c45262ab1a61089b76b6f3266d81d46aa8f30f51d81536eb33e9d3be6861
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/arm64
  Annotations:
    com.docker.dhi.build.id: builder-a5cb8d2c-fb42-4746-ae09-99a8174bd8ea/linux-arm64/bir800rq7b6dqeuhph11zlulz
docker buildx imagetools inspect dhi.io/node:22-alpine3.23 --format "{{.Manifest}}"
Name:      dhi.io/node:22-alpine3.23
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d

Manifests:
  Name:        dhi.io/node:22-alpine3.23@sha256:9e2168660d606eeaf1a9b6f2f1fd1a22dca8325ffc28c830de868ef5ea9a6784
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/arm64
  Annotations:
    com.docker.dhi.build.id: builder-45929e12-b78a-4b28-9f5f-eb89b2002d61/linux-arm64/vp54th8sa7zk55o9uunwq5rtb

  Name:        dhi.io/node:22-alpine3.23@sha256:a4b1a07ca79f72955577d2e3fb88c0a3ed8107e74660e7830cc115f3557137f7
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/amd64
  Annotations:
    com.docker.dhi.build.id: builder-d99bddca-86d8-4d26-9cbb-feb22f6cf7f3/linux-amd64/wus9hma0dwojhx3pmra43sibm

Use the top-level Digest, the image index. It covers every platform, so the same Dockerfile builds on an Intel CI runner and an Apple silicon laptop. Update both FROM lines. Keeping the tag in front of the digest is allowed, and it tells the next reader which version the digest is:

# syntax=docker/dockerfile:1

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

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

Build again. This time, build with an SBOM and provenance attestations and push the result, because the Scout policies later in this guide check for them:

~/my-app

docker buildx build --sbom=true --provenance=mode=max --push -t myorg/my-app:1.1 .
[+] Building 3.4s (21/21) FINISHED                         docker:desktop-linux
 => loading policies Dockerfile.rego                                       0.9s
 => [internal] load build definition from Dockerfile                       0.0s
 => resolve image config for docker-image://docker.io/docker/dockerfile:1  0.1s
 => resolve image config for docker-image://docker.io/docker/buildkit-syf  0.2s
 => [internal] load metadata for dhi.io/node:22-alpine3.23-dev@sha256:672  0.0s
 => [internal] load metadata for dhi.io/node:22-alpine3.23@sha256:920a746  0.0s
 => [builder 1/5] FROM dhi.io/node:22-alpine3.23-dev@sha256:6721841fb09d2  0.0s
 => [builder 3/5] COPY package*.json ./                                    0.0s
 => [builder 4/5] RUN npm ci --omit=dev                                    0.8s
 => [builder 5/5] COPY . .                                                 0.0s
 => [stage-1 2/3] COPY --from=builder --chown=node:node /usr/src/app /app  0.0s
 => [linux/arm64] generating sbom using docker.io/docker/buildkit-syft-sc  1.9s
 => exporting to image                                                     0.1s
 => => exporting manifest list sha256:eccd859cd89da042eef9e23205321b24193  0.0s
 => => naming to docker.io/myorg/my-app:1.1                                0.0s
 => => pushing layers                                                      0.0s
 => => pushing manifest for docker.io/myorg/my-app:1.1@sha256:eccd859cd89  0.0s

The same policy ran again, in the loading policies step, and every input passed: the context, the frontend, the SBOM scanner, and both pinned base images.

Test the policy

A policy is code that decides whether your team can ship, so test it like code. docker buildx policy test runs OPA-style unit tests against mocked inputs, without building or contacting a registry.

Create Dockerfile_test.rego next to the policy. Test files end in _test.rego, use the same package docker, and each test’s name starts with test_:

package docker

test_local_context_allowed if {
  decision.allow with input as {"local": {"name": "."}}
}

test_pinned_dhi_image_allowed if {
  decision.allow with input as {"image": {
    "ref": "dhi.io/node:22-alpine3.23@sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d",
    "host": "dhi.io",
    "fullRepo": "dhi.io/node",
    "isCanonical": true,
  }}
}

test_unpinned_dhi_image_denied if {
  result := decision with input as {"image": {
    "ref": "dhi.io/node:22-alpine3.23",
    "host": "dhi.io",
    "fullRepo": "dhi.io/node",
    "isCanonical": false,
  }}
  not result.allow
  "image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...)" in result.deny_msg
}

test_docker_hub_image_denied if {
  not decision.allow with input as {"image": {
    "ref": "docker.io/library/node:22",
    "host": "docker.io",
    "fullRepo": "docker.io/library/node",
    "isCanonical": false,
  }}
}

Each test overrides input with with input as, then asserts on the decision. The third test checks the message as well as the decision, so a change that breaks the explanation fails too. Run the tests. They’re reported in alphabetical order:

~/my-app

docker buildx policy test .
test_docker_hub_image_denied: PASS (allow=false, deny_msg=image docker.io/library/node:22 is not from dhi.io; base images must be Docker Hardened Images)
test_local_context_allowed: PASS (allow=true)
test_pinned_dhi_image_allowed: PASS (allow=true)
test_unpinned_dhi_image_denied: PASS (allow=false, deny_msg=image dhi.io/node:22-alpine3.23 must be pinned by digest (@sha256:...))

When a test fails, the output shows the input, the full decision, and any input fields the policy needed but the test didn’t provide. To build test inputs from real sources, use docker buildx policy eval --print. See Test build policies.

What a build policy doesn’t do

A build policy runs on the machine doing the build, from a file in the repository. Anyone who can edit the repository can edit or delete Dockerfile.rego, and anyone building locally can skip it with --policy disabled=true. It’s a guardrail for the people following the rules, not a control against the people who aren’t.

It becomes a control when the only builds that can ship are the ones your CI runs, and CI names the policy file instead of relying on automatic loading: --policy filename=Dockerfile.rego,strict=true fails the build if the file is missing or the builder can’t evaluate policies. --policy strict=true on its own isn’t enough: with no Dockerfile.rego next to the Dockerfile, the build runs without a policy. Building releases only in CI is covered in Build a trusted container delivery pipeline.

Check the image with Scout policies

The build policy can’t see what npm ci installed, which user the image runs as, or whether its attestations were attached. Docker Scout can.

docker scout policy indexes the image into an SBOM, adds vulnerability and VEX data, and evaluates each policy in the CLI. No data is sent to the Scout service. With no other options, it uses a set of built-in policies.

Tune those built-in policies to match your standards with a policy configuration file. Create policies.json in ~/my-app:

{
  "policies": [
    {
      "name": "approved-base-images",
      "config": {
        "allowed_base_images": ["dhi.io/*"]
      }
    },
    {
      "name": "fixable-vulnerabilities",
      "config": {
        "severities": ["CRITICAL", "HIGH"],
        "grace_period_days": 7
      }
    },
    {
      "name": "copyleft-license",
      "enabled": false
    }
  ]
}

Each entry names a policy by its stable ID. The first restricts No unapproved base images to dhi.io, which checks the same rule as your build policy from the other side: it reads the base image from the provenance attestation. The second keeps the default severities for No fixable critical or high vulnerabilities, and gives your team seven days to fix a newly disclosed CVE before the policy fails. The third turns off No copyleft licenses: Alpine packages in the hardened base image, such as libgcc, libstdc++, and alpine-baselayout-data, carry GPL, LGPL, or MPL licenses, so every image on this base fails it. Policies you don’t list run with their defaults. The configuration reference lists every key.

Evaluate the image you pushed:

~/my-app

docker scout policy myorg/my-app:1.1 --policy-config policies.json
    ! 'docker scout policy' is experimental and its behaviour might change in the future
    ✓ Image stored for indexing
    ✓ SBOM obtained from attestation, 76 packages found
    ✓ Provenance obtained from attestation
    ✓ Pulled
    ✓ Policy evaluation completed


## Overview

            │   Analyzed Image
────────────┼────────────────────
 Target     │  myorg/my-app:1.1
   digest   │  78707cd79c2b
   platform │ linux/arm64


## Policies

Policy status  SUCCESS  (6/6 policies met)
Health score  A  (100%)

 Status │                   Policy                    │           Results
────────┼─────────────────────────────────────────────┼─────────────────────────────
 ✓      │ Default non-root user                       │
 ✓      │ No fixable critical or high vulnerabilities │    0C     0H     0M     0L
 ✓      │ No high-profile vulnerabilities             │    0C     0H     0M     0L
 ✓      │ No outdated base images                     │
 ✓      │ No unapproved base images                   │    0 deviations
 ✓      │ Supply chain attestations                   │    0 deviations


What's next:
    Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.1

Every policy that’s enabled passes: six of the seven built-in policies, with No copyleft licenses turned off. The base image is current and from dhi.io, the DHI runtime runs as a non-root user, and the SBOM and provenance attestations from the build are attached. Without the copyleft-license entry, the same image reports Copyleft licensed packages found for 7 packages, and the status is FAILED (6/7 policies met). The health score is a weighted share of passed policies. The exact layout of this report varies between Scout CLI versions.

Your results may differ. If No fixable critical or high vulnerabilities fails, see Find and fix vulnerabilities before you ship. If a reported CVE doesn’t affect your application, see Document vulnerabilities that don’t affect your application.

Write a custom Scout policy

The built-in policies don’t cover the last rule from the wiki page: every image records the repository it was built from, so whoever is on call can find the source of a running image. You can write that rule yourself.

A Scout policy is a Rego module in the docker.scout package. It defines a boolean pass and a set of violation objects, and a # METADATA block tells the CLI how to show the result. Create a policies directory with one file:

# METADATA
# title: Source repository label
# description: Images must name the myorg repository they were built from.
# custom:
#   name: source-label
#   result_type: generic
#   weight: 10
#   not_compliant_title: Missing source repository label
#   details_order:
#   - label
#   - reason
package docker.scout

import rego.v1

default pass := false

pass if {
    count(violation) == 0
}

source := object.get(input, ["source", "image", "config", "config", "Labels", "org.opencontainers.image.source"], "")

violation contains v if {
    not startswith(source, "https://github.com/myorg/")
    v := {
        "message": "Image does not name the myorg repository it was built from",
        "detail": {
            "label": "org.opencontainers.image.source",
            "reason": "missing, or not a github.com/myorg repository",
        },
        "remediation": "Add LABEL org.opencontainers.image.source=\"https://github.com/myorg/<repo>\" to the final stage",
    }
}

The input is the enriched SBOM, and input.source.image.config holds the image’s configuration, including its labels. object.get returns an empty string when the label is missing, so a missing label and a wrong one both produce a violation. custom.name is the stable ID you’d use to configure the policy in policies.json, not_compliant_title is the row title when the policy fails, and details_order sets the columns of the violation table.

Evaluate the image against it. Add --exit-code so the command reports the result the way a pipeline reads it, and print the exit code afterwards:

~/my-app

docker scout policy myorg/my-app:1.1 --policy-dir ./policies --exit-code
    ! 'docker scout policy' is experimental and its behaviour might change in the future
    ✓ SBOM of image already cached, 76 packages indexed
    ✓ Policy evaluation completed


## Overview

            │   Analyzed Image
────────────┼────────────────────
 Target     │  myorg/my-app:1.1
   digest   │  78707cd79c2b
   platform │ linux/arm64


## Policies

Policy status  FAILED  (0/1 policies met)
Health score  F  (0%)

 Status │             Policy              │    Results
────────┼─────────────────────────────────┼────────────────
 !      │ Missing source repository label │    1 deviation


## "Source repository label" policy evaluation results
Images must name the myorg repository they were built from.

              Label              │                    Reason
─────────────────────────────────┼───────────────────────────────────────────────
 org.opencontainers.image.source │ missing, or not a github.com/myorg repository


What's next:
    View vulnerabilities → docker scout cves myorg/my-app:1.1
    View base image update recommendations → docker scout recommendations myorg/my-app:1.1
    Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.1
echo $?
2

The failure is expected: nothing in the Dockerfile sets that label. The report shows each violation’s detail, not its remediation. With --exit-code, an unmet policy returns 2 instead of 0, so any CI system can stop on it.

Notice that only one policy ran. When you pass --policy-file or --policy-dir, the built-in policies aren’t loaded. You’ll combine them at the end of this section.

Debug a custom policy

When a custom policy fails and you can’t tell why, write the raw evaluation result to a file:

~/my-app

docker scout policy myorg/my-app:1.1 --policy-dir ./policies --result-file result.json
    ! 'docker scout policy' is experimental and its behaviour might change in the future
    ✓ SBOM of image already cached, 76 packages indexed
    ✓ Policy evaluation completed
    i Wrote policy evaluation results to result.json


## Overview

            │   Analyzed Image
────────────┼────────────────────
 Target     │  myorg/my-app:1.1
   digest   │  78707cd79c2b
   platform │ linux/arm64


## Policies

Policy status  FAILED  (0/1 policies met)
Health score  F  (0%)

 Status │             Policy              │    Results
────────┼─────────────────────────────────┼────────────────
 !      │ Missing source repository label │    1 deviation


## "Source repository label" policy evaluation results
Images must name the myorg repository they were built from.

              Label              │                    Reason
─────────────────────────────────┼───────────────────────────────────────────────
 org.opencontainers.image.source │ missing, or not a github.com/myorg repository


What's next:
    View vulnerabilities → docker scout cves myorg/my-app:1.1
    View base image update recommendations → docker scout recommendations myorg/my-app:1.1
    Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.1
cat result.json
{
  "source-label": {
    "pass": false,
    "violations": [
      {
        "label": "org.opencontainers.image.source",
        "reason": "missing, or not a github.com/myorg repository"
      }
    ],
    "bindings": {
      "source": "",
      "violation": [
        {
          "detail": {
            "label": "org.opencontainers.image.source",
            "reason": "missing, or not a github.com/myorg repository"
          },
          "message": "Image does not name the myorg repository it was built from",
          "remediation": "Add LABEL org.opencontainers.image.source=\"https://github.com/myorg/\u003crepo\u003e\" to the final stage"
        }
      ]
    },
    "metrics": {
      "timer_rego_external_resolve_ns": 83,
      "timer_rego_input_parse_ns": 1291000,
      "timer_rego_query_compile_ns": 33875,
      "timer_rego_query_eval_ns": 51250,
      "timer_rego_query_parse_ns": 15167
    }
  }
}

bindings holds every rule the policy computed, not only the result. Here, source is an empty string, so the label really is missing, and the policy logic isn’t at fault. For more detail, add print() calls to the rules and set "debug": true in policies.json. See Debug policies.

Fix the image and pass

Add the label to the final stage of the Dockerfile:

# syntax=docker/dockerfile:1

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

FROM dhi.io/node:22-alpine3.23@sha256:920a7460ab7ac19ce6a9a38fb23b244bcfed1cf78705f7fe117195c58b307f4d
LABEL org.opencontainers.image.source="https://github.com/myorg/my-app"
ENV PATH=/app/node_modules/.bin:$PATH
COPY --from=builder --chown=node:node /usr/src/app /app
WORKDIR /app
CMD ["node", "index.js"]

Build and push a new version, then evaluate it again:

~/my-app

docker buildx build --sbom=true --provenance=mode=max --push -t myorg/my-app:1.2 .
[+] Building 2.6s (20/20) FINISHED                         docker:desktop-linux
 => loading policies Dockerfile.rego                                       0.8s
 => [internal] load build definition from Dockerfile                       0.0s
 => CACHED [builder 4/5] RUN npm ci --omit=dev                             0.0s
 => [builder 5/5] COPY . .                                                 0.0s
 => [stage-1 2/3] COPY --from=builder --chown=node:node /usr/src/app /app  0.0s
 => [linux/arm64] generating sbom using docker.io/docker/buildkit-syft-sc  2.0s
 => exporting to image                                                     0.1s
 => => exporting manifest list sha256:4cdf2eb0da5cdd2a40500bb451981369bae  0.0s
 => => naming to docker.io/myorg/my-app:1.2                                0.0s
 => => pushing manifest for docker.io/myorg/my-app:1.2@sha256:4cdf2eb0da5  0.0s
docker scout policy myorg/my-app:1.2 --policy-dir ./policies --exit-code
    ! 'docker scout policy' is experimental and its behaviour might change in the future
    ✓ Image stored for indexing
    ✓ SBOM obtained from attestation, 76 packages found
    ✓ Provenance obtained from attestation
    ✓ Pulled
    ✓ Policy evaluation completed


## Overview

            │   Analyzed Image
────────────┼────────────────────
 Target     │  myorg/my-app:1.2
   digest   │  e499f7880fe4
   platform │ linux/arm64


## Policies

Policy status  SUCCESS  (1/1 policies met)
Health score  A  (100%)

 Status │         Policy          │     Results
────────┼─────────────────────────┼─────────────────
 ✓      │ Source repository label │    0 deviations


What's next:
    Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.2
echo $?
0

The policy passes, and the command exits 0. LABEL doesn’t add a build step, so the build output has no line for it.

Run built-in and custom policies together

Your standards are the built-in policies and your custom one, so the check that gates a release needs both. Because local policy files replace the built-in set, publish the built-in set to a registry as an OCI policy bundle first:

~/my-app

docker scout policy publish myorg/scout-policies:1.0
    ✓ Published policy bundle myorg/scout-policies:1.0

  digest:   sha256:9ccb14e89988b3e8e087b1014ba46446b1815197f349008247c066585cfa9edd
  policies: 7
    - approved-base-images.rego
    - copyleft-license.rego
    - default-non-root-user.rego
    - fixable-vulnerabilities.rego
    - high-profile-vulnerabilities.rego
    - no-stale-base-images.rego
    - supply-chain-attestations.rego
docker scout policy myorg/my-app:1.2 --policy-bundle myorg/scout-policies:1.0 --policy-dir ./policies --policy-config policies.json --exit-code
    ! 'docker scout policy' is experimental and its behaviour might change in the future
    ✓ SBOM of image already cached, 76 packages indexed
    ✓ Policy evaluation completed


## Overview

            │   Analyzed Image
────────────┼────────────────────
 Target     │  myorg/my-app:1.2
   digest   │  e499f7880fe4
   platform │ linux/arm64


## Policies

Policy status  SUCCESS  (7/7 policies met)
Health score  A  (100%)

 Status │                   Policy                    │           Results
────────┼─────────────────────────────────────────────┼─────────────────────────────
 ✓      │ Default non-root user                       │
 ✓      │ No fixable critical or high vulnerabilities │    0C     0H     0M     0L
 ✓      │ No high-profile vulnerabilities             │    0C     0H     0M     0L
 ✓      │ No outdated base images                     │
 ✓      │ No unapproved base images                   │    0 deviations
 ✓      │ Source repository label                     │    0 deviations
 ✓      │ Supply chain attestations                   │    0 deviations


What's next:
    Compare with the latest in the registry → docker scout compare --to-latest myorg/my-app:1.2

That last command is the whole standard in one check: the bundle, your custom policy, and your configuration, which turns off No copyleft licenses in the bundle as it did for the built-in set. A bundle also gives every repository in your organization the same version of the rules. Publish a new tag when the rules change, and each team adopts it by changing one reference.

Check yourself

  1. A Dockerfile starts with FROM node:22. Both checks catch it: the build policy before the build, and No unapproved base images afterwards.
  2. An npm package with a fixable critical CVE. Only the Scout policies. The build policy doesn’t see packages installed by npm ci.
  3. A pinned base image digest that has fallen behind its tag. Only the Scout policies, through No outdated base images. The digest still satisfies the build policy.
  4. A laptop build with policies disabled, pushed and deployed outside CI. Neither check in this guide stops it. A cluster admission policy that requires your CI workflow’s signature does.

What you built

  • A build policy, Dockerfile.rego, that Buildx loads automatically and evaluates against every build input before the build starts. It allows the build context, Docker’s build tooling, and Docker Hardened Images pinned by digest, and explains every denial.
  • Unit tests for that policy, run with docker buildx policy test.
  • A Scout policy configuration, policies.json, that tunes the built-in policies to your standards.
  • A custom Scout policy in policies/, and a single docker scout policy … --exit-code command that evaluates the built-in bundle, your policy, and your configuration together.

Both layers check rules. Neither proves anything to someone downstream: a registry that receives myorg/my-app:1.2 can’t tell whether these checks ran, or whether the image it holds is the one that passed them. That takes a signature, tied to the image’s digest.

Next steps

Continue with Prove where your container image came from, which signs an image by digest with Cosign and shows verification failing on an image that wasn’t signed.

To run these checks on every push, and gate the release on docker scout policy --exit-code, see Build a trusted container delivery pipeline.

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.