Skip to content

Repository files navigation

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 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):

Pattern Matches Does not match
scratch scratch scratch:latest
node:* node:20, node:latest node:20@sha256:...
ghcr.io/myorg/* ghcr.io/myorg/app:v1 ghcr.io/myorg/sub/app:v1
ghcr.io/myorg/** ghcr.io/myorg/app:v1, ghcr.io/myorg/sub/app:v1 ghcr.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

Pattern Supported
FROM image:tag Yes
FROM image:tag AS name Yes
FROM --platform=linux/amd64 image:tag Yes
FROM image:tag@sha256:... (already pinned) Skipped (use --update to refresh)
FROM scratch Skipped
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:tag Yes
FROM registry:5000/image:tag Yes
COPY --from=image:tag Yes
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=scratch Skipped
COPY --from=image:${TAG} Skipped (BuildKit does not expand variables here)
COPY /src /dst (build context) Nothing to pin
ONBUILD COPY --from=image:tag Yes
# escape= directive Honored
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

Pattern Supported
image: node:20 Yes
image: node:20@sha256:... Skipped (use --update)
Service with build: directive Skipped
Service without image: key Skipped

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

Pattern Supported
jobs.<id>.container.image: node:20 Yes
jobs.<id>.container: node:20 (string shorthand) Yes
jobs.<id>.services.<id>.image: postgres:16 Yes
jobs.<id>.steps[*].uses: docker://image:tag Yes
jobs.<id>.steps[*].uses: actions/checkout@v4 Skipped (not a Docker image)

GitHub Actions action files (action.yml)

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

Layout Recognised
.gitlab-ci.yml Yes, 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 name Treated 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.

Pattern Supported
<job>.image: node:20 Yes
<job>.image.name: node:20 Yes
<job>.services[*]: postgres:16 Yes
<job>.services[*].name: postgres:16 Yes
default.image and default.services Yes
Root-level image and services (deprecated by GitLab) Yes
Hidden job templates (.build:) Yes
image: $CI_REGISTRY_IMAGE:tag Skipped (built from a CI variable)
image: node:20@sha256:... Skipped (use --update)
Files with a component spec: header document Yes (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

About

A CLI tool for digest pinning — adds @sha256:<digest> to Dockerfile, docker-compose.yml, and GitHub Actions to prevent supply chain attacks.

Topics

Resources

Code of conduct

Security policy

Stars

86 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages