dockerfile-pin

A CLI tool that adds @sha256:<digest> to FROM and COPY --from= lines in Dockerfiles, image fields in docker-compose.yml, and Docker image references in GitHub Actions and GitLab CI files to prevent supply chain attacks.

Install

curl

# Download binary (macOS Apple Silicon)
curl -sL "https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_darwin_arm64.tar.gz" | tar xz
sudo mv dockerfile-pin /usr/local/bin/

# Download binary (Linux amd64)
curl -sL "https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_linux_amd64.tar.gz" | tar xz
sudo mv dockerfile-pin /usr/local/bin/

aqua

aqua init
aqua generate -i azu/dockerfile-pin
aqua i
aqua exec -- dockerfile-pin --help

Go

go install github.com/azu/dockerfile-pin@latest

See GitHub Releases for all platforms.

Usage

Run

Add digests to Dockerfile FROM lines, docker-compose.yml image fields, and GitHub Actions and GitLab CI Docker image references. By default, shows changes without modifying files (dry-run).

# Preview changes (dry-run, default)
dockerfile-pin run

# Actually write changes to files
dockerfile-pin run --write

# Preview a specific file
dockerfile-pin run -f path/to/Dockerfile

# Preview docker-compose.yml
dockerfile-pin run -f docker-compose.yml

# Preview multiple files using glob
dockerfile-pin run --glob '**/Dockerfile*'

# Multiple patterns with brace expansion
dockerfile-pin run --glob '**/{Dockerfile,Dockerfile.*,docker-compose.yml,compose.yaml}'

# Ignore specific images (glob patterns, repeatable)
dockerfile-pin run --ignore-images "mcr.microsoft.com/**"

# Re-resolve existing digests
dockerfile-pin run --write --update

# Skip images built within the last 7 days
dockerfile-pin run --write --update --min-age 7

Dockerfile

Before:

FROM node:20.11.1
FROM python:3.12-slim AS builder
FROM scratch
COPY --from=nginx:1.27 /etc/nginx /etc/nginx
COPY --from=builder /app /app

After:

FROM node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5
FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4 AS builder
FROM scratch
COPY --from=nginx:1.27@sha256:6784fb08b4b7c3b6bcd3f4a1b4d1b1f3e3b7a7ca42ec3e0d9df8a97a2c9a3b1d /etc/nginx /etc/nginx
COPY --from=builder /app /app

docker-compose.yml

Before:

services:
  web:
    image: node:20.11.1
    ports:
      - "3000:3000"
  db:
    image: postgres:16.2
    environment:
      POSTGRES_PASSWORD: secret
  app:
    build: .
    image: myapp:latest

After:

services:
  web:
    image: node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5
    ports:
      - "3000:3000"
  db:
    image: postgres:16.2@sha256:4aea012537edfad80f98d870a36e6b90b4c09b27be7f4b4759d72db863baeebb
    environment:
      POSTGRES_PASSWORD: secret
  app:
    build: .
    image: myapp:latest  # skipped (has build directive)

GitHub Actions

Before:

name: CI
on: push
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: node:24
    services:
      db:
        image: postgres:18
    steps:
      - uses: docker://ghcr.io/astral-sh/uv:latest
      - uses: actions/checkout@v4

After:

name: CI
on: push
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: node:24@sha256:bb20cf73b3ad7212834ec48e2174cdcb5775f6550510a5336b842ae32741ce6c
    services:
      db:
        image: postgres:18@sha256:a9abf4275f9e99bff8e6aed712b3b7dfec9cac1341bba01c1ffdfce9ff9fc34a
    steps:
      - uses: docker://ghcr.io/astral-sh/uv:latest@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823
      - uses: actions/checkout@v4  # not a Docker image, skipped

GitLab CI

Before:

default:
  image:
    name: node:24
    entrypoint: [""]

test:
  services:
    - postgres:18
    - name: redis:7
      alias: cache
  script:
    - npm test

deploy:
  image: $CI_REGISTRY_IMAGE:latest
  script:
    - ./deploy.sh

After:

default:
  image:
    name: node:24@sha256:bb20cf73b3ad7212834ec48e2174cdcb5775f6550510a5336b842ae32741ce6c
    entrypoint: [""]

test:
  services:
    - postgres:18@sha256:a9abf4275f9e99bff8e6aed712b3b7dfec9cac1341bba01c1ffdfce9ff9fc34a
    - name: redis:7@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823
      alias: cache
  script:
    - npm test

deploy:
  image: $CI_REGISTRY_IMAGE:latest  # built from a CI variable, skipped
  script:
    - ./deploy.sh

Check

Validate that digests are present and exist in the registry.

# Check a single Dockerfile
dockerfile-pin check -f Dockerfile

# Check multiple files
dockerfile-pin check --glob '**/Dockerfile*'

# Multiple patterns with brace expansion
dockerfile-pin check --glob '**/{Dockerfile,Dockerfile.*,dockerfile_*.tmpl,docker-compose.yml,compose.yaml}'

# Syntax check only (no registry queries)
dockerfile-pin check --syntax-only

# JSON output for CI
dockerfile-pin check --format json

# Ignore specific images (glob patterns, repeatable)
dockerfile-pin check --ignore-images "scratch"
dockerfile-pin check --ignore-images "ghcr.io/myorg/*" --ignore-images "mcr.microsoft.com/**"

Output:

FAIL  Dockerfile:1    FROM node:20.11.1                                  missing digest
OK    Dockerfile:3    FROM python:3.12@sha256:abc123...
SKIP  Dockerfile:5    FROM scratch                                       scratch image

Exit code is 1 when any check fails (configurable with --exit-code).

Configuration File

Create .dockerfile-pin.yaml (or .dockerfile-pin.yml) in your project root to configure ignore rules:

# .dockerfile-pin.yaml
min-age: 7                                         # Skip images built within the last 7 days
ignore-images:
  - "ghcr.io/myorg/*"                              # Ignore all images under myorg
  - "!ghcr.io/myorg/public-*"                      # But still check public-* images
  - "*.dkr.ecr.*.amazonaws.com/**"                 # Ignore all ECR images
  - "mcr.microsoft.com/**"                         # Ignore all Microsoft container images
  - "scratch"                                      # Ignore exact image name

Config file patterns are merged with --ignore-images CLI flags. CLI flags are evaluated after config file patterns, so they take precedence (last match wins).

Pattern Syntax

Patterns use glob matching (doublestar syntax):

PatternMatchesDoes not match
scratchscratchscratch:latest
node:*node:20, node:latestnode:20@sha256:...
ghcr.io/myorg/*ghcr.io/myorg/app:v1ghcr.io/myorg/sub/app:v1
ghcr.io/myorg/**ghcr.io/myorg/app:v1, ghcr.io/myorg/sub/app:v1ghcr.io/other/app:v1
*.dkr.ecr.*.amazonaws.com/*123.dkr.ecr.us-east-1.amazonaws.com/app:v1

Negation patterns (prefixed with !) override previous matches:

ignore-images:
  - "ghcr.io/myorg/*"            # Ignore all
  - "!ghcr.io/myorg/public-*"    # But check public-* images

Supported Patterns

Dockerfiles

PatternSupported
FROM image:tagYes
FROM image:tag AS nameYes
FROM --platform=linux/amd64 image:tagYes
FROM image:tag@sha256:... (already pinned)Skipped (use --update to refresh)
FROM scratchSkipped
FROM <stage-name> (multi-stage ref)Skipped
ARG VERSION=1.0 + FROM image:${VERSION}Yes (expanded from default)
ARG BASE + FROM ${BASE} (no default)Skipped with warning
FROM ghcr.io/org/image:tagYes
FROM registry:5000/image:tagYes
COPY --from=image:tagYes
COPY --from=image:tag@sha256:... (already pinned)Skipped (use --update to refresh)
COPY --from=<stage-name> (multi-stage ref)Skipped
COPY --from=0 (stage index)Skipped
COPY --from=scratchSkipped
COPY --from=image:${TAG}Skipped (BuildKit does not expand variables here)
COPY /src /dst (build context)Nothing to pin
ONBUILD COPY --from=image:tagYes
# escape= directiveHonored
ADD --from=..., RUN --mount=...,from=...Not supported

An ONBUILD COPY --from=name is resolved as an image even when a stage in the same file shares that name. The trigger does not run in this build: it is recorded into the image config and executed later, inside whichever build uses this image as its base, against that Dockerfile’s stages — the ones declared here are gone by then.

A digest makes a name an image even when a build stage shares it: BuildKit matches the whole value against its stage names, so COPY --from=nginx@sha256:... finds no stage named nginx and is resolved from the registry. The same holds for FROM.

A COPY --from=<name> that matches no build stage is treated as an image, the same way FROM ubuntu is. A named build context (docker buildx build --build-context name=...) is written the same way and cannot be told apart from the Dockerfile alone; use --ignore-images to exclude one.

Variables are not expanded in COPY --from=: BuildKit reads the value verbatim and the build fails with failed to parse stage name (moby/buildkit#2374), so such a reference is reported as skipped rather than pinned. FROM does expand them.

docker-compose.yml

PatternSupported
image: node:20Yes
image: node:20@sha256:...Skipped (use --update)
Service with build: directiveSkipped
Service without image: keySkipped

GitHub Actions workflow files (.github/workflows/*.yml)

PatternSupported
jobs.<id>.container.image: node:20Yes
jobs.<id>.container: node:20 (string shorthand)Yes
jobs.<id>.services.<id>.image: postgres:16Yes
jobs.<id>.steps[*].uses: docker://image:tagYes
jobs.<id>.steps[*].uses: actions/checkout@v4Skipped (not a Docker image)

GitHub Actions action files (action.yml)

PatternSupported
runs.image: 'docker://debian:stretch-slim'Yes
runs.image: 'Dockerfile'Skipped (local Dockerfile)

GitLab CI files (.gitlab-ci.yml)

The default search finds only .gitlab-ci.yml, the one name GitLab reads. The layouts below are also recognised when named with -f or --glob, but are not searched for by default, because templates is an ordinary directory name that other tools use and the .gitlab/ci convention is not something GitLab defines.

LayoutRecognised
.gitlab-ci.ymlYes, and found by default
*.gitlab-ci.yml (a pipeline split into parts)Yes, with -f or --glob
templates/<name>.yml (CI component)Yes, with -f or --glob
templates/<name>/template.yml (CI component)Yes, with -f or --glob
.gitlab/ci/**/*.yml (pulled in with include: local:)Yes, with -f or --glob
Any other nameTreated as a compose file

The two component rows are matched from the root, because GitLab requires a component’s templates directory to be at the root of the repository; name such a file by a path relative to that root, not an absolute one or one reaching up with ... The compose filenames keep their own handling wherever they sit, so a templates/docker-compose.yml is still a compose file.

The keywords read inside a recognised file are the following.

PatternSupported
<job>.image: node:20Yes
<job>.image.name: node:20Yes
<job>.services[*]: postgres:16Yes
<job>.services[*].name: postgres:16Yes
default.image and default.servicesYes
Root-level image and services (deprecated by GitLab)Yes
Hidden job templates (.build:)Yes
image: $CI_REGISTRY_IMAGE:tagSkipped (built from a CI variable)
image: node:20@sha256:...Skipped (use --update)
Files with a component spec: header documentYes (all YAML documents are read)
Global keywords (variables, workflow, include, stages, spec)Not scanned

CI Integration

Check (PR validation)

Validate that all images are pinned on every pull request.

With aqua (if your project already uses aqua, add azu/dockerfile-pin to your aqua.yaml):

# .github/workflows/dockerfile-check.yml
name: Dockerfile Digest Check
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aquaproj/aqua-installer@v3
        with:
          aqua_version: v2.45.0
      - run: dockerfile-pin check

Without aqua:

# .github/workflows/dockerfile-check.yml
name: Dockerfile Digest Check
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dockerfile-pin
        run: |
          curl -sL "https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_linux_amd64.tar.gz" | tar xz -C /usr/local/bin
      - run: dockerfile-pin check

dockerfile-pin check exits with code 1 if any image is missing a digest.

When -f and --glob are omitted, it auto-detects target files using git ls-files filtered by the default glob pattern: **/{Dockerfile,Dockerfile.*,docker-compose*.yml,docker-compose*.yaml,compose.yml,compose.yaml,action.yml,action.yaml,.gitlab-ci.yml,.github/workflows/*.yml,.github/workflows/*.yaml}

Outside a git repository, it falls back to the same glob pattern with common directories (node_modules, vendor) excluded.

Pin (migration)

Run locally to add digests to all Dockerfiles, compose files, GitHub Actions files, and GitLab CI files:

# Preview changes
dockerfile-pin run

# Apply changes
dockerfile-pin run --write

Private registries

For private registries (GCR, GHCR, ECR), configure Docker credentials before running:

# GHCR
- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

# GCR
- uses: google-github-actions/auth@v2
  with:
    credentials_json: ${{ secrets.GCP_SA_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud auth configure-docker

dockerfile-pin uses ~/.docker/config.json for authentication, so any docker login or credential helper works.

How It Works

  • Uses go-containerregistry (crane) for registry API calls
  • Uses BuildKit’s Dockerfile parser for accurate FROM line parsing
  • run resolves digests via HEAD requests (does not count against Docker Hub pull rate limits)
  • check verifies digest existence via HEAD requests
  • Authenticates using ~/.docker/config.json (supports Docker Hub, GHCR, GCR, ECR, etc.)

Digest Updates

--update (-u) re-resolves each tag against the registry and replaces the existing digest with the current digest of that tag. The tag itself is not changed.

# Re-resolve all pinned digests from the registry
dockerfile-pin run --write --update

—min-age

--min-age N skips images whose build date is within the last N days. This acts as a cooldown period — only pin to images that have been stable for at least N days.

# Skip images built within the last 7 days
dockerfile-pin run --write --update --min-age 7

The build date is read from the image’s OCI config (Created field). Images with no creation timestamp (e.g., reproducible builds) are not skipped.

--min-age can also be set in the configuration file:

# .dockerfile-pin.yaml
min-age: 7

CLI flag takes precedence over the configuration file value.

For automated ongoing digest updates, use Renovate which understands the image:tag@sha256:digest format.

License

MIT


Summary

CLI adding @sha256: digests to Dockerfile FROM / COPY —from= lines, docker-compose image: fields, and GitHub Actions / GitLab CI image references (87 stars, azu) — supply-chain pinning for container images. Dry-run by default; config-file driven.

Learnings

  • The container-image half of the pinning pair in Supply Chain Security (actions half = pinact). Same doctrine as Golden repo’s ‘pin to SHAs’ and Cilium’s CI lessons: pin what you run. Digest-pinned FROM lines make the registry’s mutable tags irrelevant to your build.

Gotchas

  • (none yet)

Hands-on

(candidate for repos with Dockerfiles / CI image refs)