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 --help
go install github.com/azu/dockerfile-pin@latest
See 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 7
Before:
FROM node:20.11.1
FROM python:3.12-slim AS builder
FROM scratch
After:
FROM node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5
FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4 AS builder
FROM scratch
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)
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
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).
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).
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 |
.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) |
action.yml)| 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 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,.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 --write
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.
run resolves digests via HEAD requests (does not count against Docker Hub pull rate limits)check verifies digest existence via HEAD requests~/.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--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.
MIT
$ claude mcp add dockerfile-pin \
-- python -m otcore.mcp_server <graph>