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.
# 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 --helpgo install github.com/azu/dockerfile-pin@latestSee GitHub Releases for all platforms.
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 7Before:
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 /appAfter:
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 /appBefore:
services:
web:
image: node:20.11.1
ports:
- "3000:3000"
db:
image: postgres:16.2
environment:
POSTGRES_PASSWORD: secret
app:
build: .
image: myapp:latestAfter:
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)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@v4After:
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, skippedBefore:
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.shAfter:
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.shValidate 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).
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 nameConfig file patterns are merged with --ignore-images CLI flags. CLI flags are evaluated after config file patterns, so they take precedence (last match wins).
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| 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.
| Pattern | Supported |
|---|---|
image: node:20 |
Yes |
image: node:20@sha256:... |
Skipped (use --update) |
Service with build: directive |
Skipped |
Service without image: key |
Skipped |
| 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) |
| Pattern | Supported |
|---|---|
runs.image: 'docker://debian:stretch-slim' |
Yes |
runs.image: 'Dockerfile' |
Skipped (local Dockerfile) |
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 |
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 checkWithout 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 checkdockerfile-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.
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 --writeFor 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-dockerdockerfile-pin uses ~/.docker/config.json for authentication, so any docker login or credential helper works.
- Uses go-containerregistry (crane) for registry API calls
- Uses BuildKit's Dockerfile parser for accurate FROM line parsing
runresolves digests via HEAD requests (does not count against Docker Hub pull rate limits)checkverifies digest existence via HEAD requests- Authenticates using
~/.docker/config.json(supports Docker Hub, GHCR, GCR, ECR, etc.)
--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 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 7The 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: 7CLI flag takes precedence over the configuration file value.
For automated ongoing digest updates, use Renovate which understands the image:tag@sha256:digest format.
MIT