A CLI tool that adds @sha256:<digest> to FROM lines in Dockerfiles, image fields in docker-compose.yml, and Docker image references in GitHub Actions 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 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 scratchAfter:
FROM node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5
FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4 AS builder
FROM scratchBefore:
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, skippedValidate 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 |
| 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) |
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,.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, and GitHub Actions 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