Crane lets you copy, inspect and modify your container images without
needing the Docker daemon. Developed by Google as part of the
go-containerregistry project, this command-line tool talks directly to
registries through the OCI API. You can use it to copy an image between Docker
Hub and your private registry in a single command, inspect the layers (the
file layers) of an image, or merge those layers into one with flatten.
Particularly useful in CI/CD, where Docker is not always available, Crane
installs in a few seconds and runs on Linux, macOS and Windows. The list of
archives for each version, with their architectures, is on the project's
releases page.
What you will learn
- Install Crane on Linux or macOS, with the release binary, asdf or Homebrew
- Inspect the images of a registry (manifest, config, layers)
- Copy images between registries without Docker, and know when the copy keeps the digest of the index, that is, the list of manifests of a multi-platform image
- Authenticate to a registry and check that access works
- Flatten the layers of an image and measure what it really changes
- Promote an image in CI/CD by its digest, without rebuilding it
What prerequisites do you need to follow the examples?
Crane needs nothing but its binary, but the lab on this page requires a few
tools. You need a Docker Engine 29 (version 29.9.2 was used for the
measurements) to start the local registry and build the test image: the daemon
serves the lab, not Crane. You also need
jq to read the JSON, outbound access to Docker
Hub for the copies of alpine and nginx, port 5000 free on 127.0.0.1,
and sudo to install the binary in /usr/local/bin.
Why Crane rather than Docker?
Docker can copy an image from one registry to another, but it does so through its daemon and its local storage. Crane talks directly to the registry: it reads and writes the manifests (the description of an image: its configuration and the list of its layers) and the layers themselves, without installing anything but itself. This is what makes it handy in CI/CD, or when you simply want to inspect or copy an image quickly. It also handles the digest, the SHA256 fingerprint that identifies an image by its content.
The table compares the most common operations with Docker and with Crane.
| Need | With Docker | With Crane |
|---|---|---|
| Copy an image between registries | docker pull + docker tag + docker push, which can lose platforms (see BuildKit cache in CI and image promotion between registries) | crane copy source dest, which copies the index and keeps its digest |
| See the layers of an image | docker history (limited) | crane manifest (detailed) |
| Get the SHA256 digest | docker inspect | crane digest |
| Export an image to tar | docker save | crane export |
| Flatten the layers | docker export then docker import, which flattens but loses the configuration (CMD, ENV...) | crane flatten |
How do you install Crane?
Several installation methods are available depending on your operating system and your preferences. The recommended method is the release binary, whose archive is verified by its checksum before being extracted. For this page, only the installation from the release binary was replayed (Linux x86_64, Crane v0.22.1). The other tabs reproduce the project's documentation and that of the package managers.
This is the recommended method: it works on every Linux system, pins a precise version and can be verified by checksum. The available architectures vary by version: see the project's releases page.
The project publishes a checksums.txt file next to the archives: download
both and verify the checksum before extracting anything. An archive
downloaded without a check is a direct way into the machine that runs it.
# Pin a precise version rather than following "latest"VERSION=v0.22.1ARCHIVE=go-containerregistry_Linux_x86_64.tar.gz # other architectures: see the releases pageBASE="https://github.com/google/go-containerregistry/releases/download/${VERSION}"
curl -fsSL -o "$ARCHIVE" "${BASE}/${ARCHIVE}"curl -fsSL -o checksums.txt "${BASE}/checksums.txt"
# Only check the line that matches our archivegrep " ${ARCHIVE}$" checksums.txt | sha256sum --check -# go-containerregistry_Linux_x86_64.tar.gz: OK
tar -xzf "$ARCHIVE" cranesudo install -m 0755 crane /usr/local/bin/cranerm -f crane "$ARCHIVE" checksums.txtasdf-vm (in French) is a general-purpose
version manager. It lets you pin a precise version per project through the
.tool-versions file.
asdf plugin add craneasdf install crane 0.22.1asdf set --home crane 0.22.1This method was not replayed: there was no asdf on the test machine.
The advantage of asdf is that it can use different versions of Crane depending on the project, which is useful when you work on codebases with different compatibility constraints.
If you use Homebrew (in French), installation
takes a single command. Homebrew handles updates automatically when you run
brew upgrade.
brew install craneOn Arch Linux, Crane is available in the official repositories. Installing with pacman guarantees integration with the package system and automatic updates.
pacman -S craneIf you have Go installed, you can build Crane from source, pinning the version
as with the other methods. This path was not replayed, and the minimum Go
version was not checked: it is in the repository's go.mod file.
go install github.com/google/go-containerregistry/cmd/crane@v0.22.1The binary will be installed in $GOPATH/bin (usually ~/go/bin). Make sure
this directory is in your PATH.
You can use Crane directly through its official Docker image, which is handy for CI/CD pipelines or for testing without a local installation.
# Basic usagedocker run --rm gcr.io/go-containerregistry/crane:v0.22.1@sha256:1f968817b95790bed063f71175aa6b8ff879fa17064020415f3e18bb6e6a36e1 ls ubuntu
# Version with a shell (for debugging)docker run --rm -it --entrypoint /busybox/sh gcr.io/go-containerregistry/crane/debug:v0.22.1@sha256:e78770b31258a3846f878036d9c1f63fbe4c871f9f56990bf77fd95c013e3c1bThe crane/debug image includes a BusyBox shell, useful for debugging:
launched with --entrypoint /busybox/sh, it does open a shell. The images
without a shell are published at gcr.io/go-containerregistry/crane:[version],
and their variant with a shell at
gcr.io/go-containerregistry/crane/debug:[version]. The image without debug
contains no shell: it does not suit a CI job that chains commands.
To check the installation, run crane version. With the release binary, the
output is the version number. In the Docker image, the same command does not
display this number but a 40-character commit fingerprint
(8a72a424fdecb4caa14f2d525e5d2503331442b5 for the image pinned above).
crane version# 0.22.1 with the release binaryHow do you set up a test lab for Crane?
For the examples that follow, I will use a local registry. This lets you test safely and without creating an account on Docker Hub.
-
Start a local registry
Terminal window docker run -d -p 127.0.0.1:5000:5000 --name registry \registry:3.1.2@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3The registry will be reachable at
localhost:5000. -
Create a test Dockerfile
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07dRUN apk --no-cache add nginx \&& mkdir -p /var/www/htmlCOPY index.html /var/www/html/WORKDIR /var/www/htmlEXPOSE 80 -
Create the index.html file
<!DOCTYPE html><html><body><h1>Test Crane</h1></body></html> -
Build and push the image
Terminal window docker build --provenance=false . -t localhost:5000/monserveurweb:0.1docker push localhost:5000/monserveurweb:0.1With Docker 29,
docker buildadds a provenance attestation by default and then pushes a manifest index instead of a single manifest. The--provenance=falseoption keeps a single manifest, the one the examples below describe. With the index,crane manifesthas nolayersfield andcrane rebaseanswersrebasing an index is not yet supported.
What are the essential Crane commands?
Crane offers many commands to interact with registries. In this section, we will look at the ones used most often day to day. All these commands talk directly to the registry over HTTP/HTTPS, without going through the Docker daemon.
List the images of a registry
The catalog command queries the registry API to get the list of all images
(called "repositories" in OCI terminology). It is the starting point for
exploring the contents of a registry.
crane catalog localhost:5000# monserveurwebOn a public registry such as Docker Hub, this command can return thousands of results. Use it on your private registries instead.
List the tags of an image
Once you know the name of an image, ls displays all the available tags. A tag
is a pointer to a specific version of the image.
crane ls localhost:5000/monserveurweb# 0.1This command is useful to check which versions are available before copying or deploying an image.
Inspect the manifest of an image
The manifest holds the image's metadata: its layers, their size, their SHA256 digest. It is the equivalent of a "table of contents" for the image.
The outputs on this page were collected on 2026-11-04. The layer of the RUN
instruction changes with every build (here, the installation of nginx by apk
at build time), and the manifest digest with it: yours will be different, the
shape of the output will stay the same. The layer of the pinned base and that of
WORKDIR, on the other hand, stay identical from one build to the next.
crane manifest localhost:5000/monserveurweb:0.1 | jq{ "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": { "mediaType": "application/vnd.oci.image.config.v1+json", "digest": "sha256:402497a2948b...", "size": 1444 }, "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:897d797d2723...", "size": 3646875 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:c173c577271e...", "size": 623739 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:38d15c98fef5...", "size": 225 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:4f4fb700ef54...", "size": 32 } ]}Get the full configuration
The config command displays the full configuration of the image: layer
history, environment variables, default command, labels... The output below is
an excerpt: crane config also displays, among other things, the creation dates
(created) and the layer fingerprints (rootfs).
crane config localhost:5000/monserveurweb:0.1 | jq{ "architecture": "amd64", "os": "linux", "config": { "Cmd": ["/bin/sh"], "Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], "WorkingDir": "/var/www/html", "ExposedPorts": { "80/tcp": {} } }, "history": [ { "created_by": "ADD alpine-minirootfs-3.21.7-x86_64.tar.gz / # buildkit" }, { "created_by": "CMD [\"/bin/sh\"]", "empty_layer": true }, { "created_by": "RUN /bin/sh -c apk --no-cache add nginx && mkdir -p /var/www/html # buildkit" }, { "created_by": "COPY index.html /var/www/html/ # buildkit" }, { "created_by": "WORKDIR /var/www/html" }, { "created_by": "EXPOSE [80/tcp]", "empty_layer": true } ]}The history has six entries, while the manifest lists only four layers: CMD
and EXPOSE carry empty_layer: true, they create no layer. The four layers
of the manifest, in order, come from the base image (ADD), the RUN, the
COPY and WORKDIR. The fourth, of 32 bytes, therefore comes from WORKDIR.
Get the SHA256 digest
The digest is the unique identifier of an image, computed from its content. Unlike the tag, which can be moved, the digest guarantees that you use exactly the same image.
crane digest localhost:5000/monserveurweb:0.1# sha256:7abf7ac6187ccccb869c42692647544ca66eb35b49348f32c46cfbc9bac21fa5How do you copy an image between registries with Crane?
With Crane, copying an image from one registry to another, including a
multi-platform image, takes a single command. Unlike the traditional method
(docker pull + docker tag + docker push), Crane does not go through the
Docker daemon and does not store the image in local storage: it reads the layers
in the source registry and sends them to the destination registry. Their bytes
still transit through the machine that runs Crane.
The difference matters for a multi-platform image. The pull, tag and push
trio only pushes the platforms present in local storage. On a runner (the
machine that runs the CI job) that starts from scratch, this gives a single
platform, a different digest and lost attestations, with no signal other than
an Info line. crane copy, on the other hand, copies the image's index and
keeps its digest. The page
BuildKit cache in CI and image promotion between registries
measures this gap.
crane copy alpine:3.21 localhost:5000/alpine:3.21 --platform linux/amd64# 2026/11/04 08:35:27 Copying from alpine:3.21 to localhost:5000/alpine:3.21# 2026/11/04 08:35:28 pushed blob: sha256:f439434f9e67...# 2026/11/04 08:35:28 pushed blob: sha256:759efa4719c9...# 2026/11/04 08:35:28 localhost:5000/alpine:3.21: digest: sha256:f765c92e5953... size: 1022This command copies the alpine:3.21 image from Docker Hub to your local
registry. It deliberately keeps the tag, to show that Crane resolves it itself.
On 2026-11-04, alpine:3.21 pointed to the index
sha256:ee16d92f96db08d9d2d07da48555a27f3a69d8f0759021dbc601195ed0d2b5bd, and
this tag can move with the next versions of the image. In a pipeline, copy
alpine:3.21@sha256: followed by the full digest instead: the source is then
frozen. The nginx copies further down also keep their tag, for the same reason.
The --platform option limits the copy to a specific platform: only the
manifest of that platform is then pushed, and not the index, so the target
does not have the digest of the index. On the local registry of this page, the
alpine:3.21 source index has the digest sha256:ee16d92f… and the target
copied with --platform linux/amd64 has sha256:f765c92e…. To keep the index
and all its platforms, leave it out.
Behind the scenes, Crane follows four steps.
- It fetches the manifest of the source image (the "table of contents")
- For each layer, it checks whether it already exists in the destination
- It copies only the missing layers (saving bandwidth)
- It pushes the final manifest to make the image available
To check the copy, list the local registry and then the tags of the image.
crane catalog localhost:5000# alpine# monserveurweb
crane ls localhost:5000/alpine# 3.21What does crane flatten do, and should you use it?
A Docker image is made of several layers, each one representing a change to
the file system. For example, the nginx:1.27.3-alpine image contains 8 layers:
the Alpine base image, the nginx installation, the configuration files, etc.
The flatten command merges all these layers into one. The resulting image
contains the same files, spread over a single layer. Docker has no equivalent
command: docker export followed by docker import also flattens, but the
imported image loses its configuration. On the lab's monserveurweb image,
docker image inspect counts 4 layers before and 1 after, and CMD, ENV,
WORKDIR and the exposed port, present before, are null or empty after.
# Copy nginx to the local registrycrane copy nginx:1.27.3-alpine localhost:5000/nginx:1.27.3-alpine --platform linux/amd64
# Flatten the imagecrane flatten localhost:5000/nginx:1.27.3-alpine \ -t localhost:5000/nginx:1.27.3-alpine-flatThe -t (or --tag) option specifies the name and tag of the flattened image.
Here, we create a new image rather than modify the original.
Count the layers before and after to see the merge.
# Before: 8 layerscrane manifest localhost:5000/nginx:1.27.3-alpine | jq '.layers | length'# 8
# After: a single layercrane manifest localhost:5000/nginx:1.27.3-alpine-flat | jq '.layers | length'# 1To compare sizes, pull both images with Docker. With Docker 29, the last two
columns are DISK USAGE (the space taken once the image is decompressed in the
engine) and CONTENT SIZE (the compressed size to transfer). Both barely drop:
0.2 MB less to transfer and 0.7 MB less once decompressed.
docker pull localhost:5000/nginx:1.27.3-alpinedocker pull localhost:5000/nginx:1.27.3-alpine-flatdocker images | grep nginx# localhost:5000/nginx:1.27.3-alpine 679a5fd058f6 71.9MB 20.5MB# localhost:5000/nginx:1.27.3-alpine-flat 19f22f691564 71.2MB 20.3MBHow do you manage image tags with Crane?
Tags are pointers to specific versions of an image. A single digest (a
unique identifier based on content) can have several tags. For example, an
image can be tagged 1.0.0, 1.0 and 1 at the same time.
Add a tag to an existing image
The tag command creates a new pointer to an existing image. It is a
lightweight operation that does not duplicate data: both tags point to the same
content.
crane tag localhost:5000/alpine:3.21 3.21.7This command adds the tag 3.21.7, the full version of the Alpine base read in
the image history, to the image that already carries the tag 3.21. Both tags
coexist and point to the same version. A tag as precise as 3.21.7 suits a
deployment better than 3.21, which can move.
List the image's tags: the output contains 3.21 and 3.21.7.
crane ls localhost:5000/alpine# 3.21# 3.21.7Validate an image
The validate command checks that the image complies with the OCI (Open
Container Initiative) specifications. It checks that the manifest is well
formed and that all the referenced layers exist.
crane validate --remote localhost:5000/monserveurweb:0.1# PASS: localhost:5000/monserveurweb:0.1Use this command after a copy or a modification to make sure the image is usable.
How do you authenticate to a registry with Crane?
Private registries (Docker Hub for private images, Artifact Registry, ECR, ACR,
Harbor, GitLab Registry...) require authentication before reading or writing
images. At Google, project registries are Artifact Registry repositories, whose
addresses end in -docker.pkg.dev; gcr.io still serves the official Crane
image, as seen above.
crane auth login writes the credentials to ~/.docker/config.json, the
same file as Docker, which lets you reuse existing authentications. It does
not contact the registry: according to cmd/crane/cmd/auth.go, the command
stores the entry in the credential store and stops there. A successful login
therefore proves neither that the password is right nor that the account has
rights on the repository. The proof comes from a command that really queries the
registry, for example crane ls.
Here is the form observed on the lab's local registry, which has no
authentication. The fake password is passed with the -p option only because it
is a fake lab secret.
crane auth login localhost:5000 -u username -p password## WARNING! Your credentials are stored unencrypted in '/tmp/crane-work/dockercfg/config.json'.# Configure a credential helper to remove this warning. See# https://docs.docker.com/go/credential-store/## 2026/11/04 08:35:36 logged in via /tmp/crane-work/dockercfg/config.jsonThe displayed path depends on your machine. Crane warns that the file is not encrypted: to stop seeing the warning, configure a credential helper, as the link in the output indicates.
For a real secret, pass it on standard input with --password-stdin, then check
access with crane ls. The option appears in the help of
crane auth login --help. Replayed with Crane 0.22.1 on the local registry,
echo test | crane auth login localhost:5000 -u test --password-stdin displays
the same warning and the same logged in via message as the -p form. The
password typed with printf '%s' contains no newline, which echo adds and
which Crane strips.
printf '%s' "$REGISTRY_PASSWORD" | crane auth login "$REGISTRY" -u "$REGISTRY_USER" --password-stdincrane ls "$REGISTRY/monapp"crane auth logout localhost:5000 removes the entry from the file and answers
logged out via followed by the path of config.json.
On cloud providers, you can use temporary tokens. For example, for Artifact Registry with gcloud (form not replayed):
gcloud auth print-access-token | crane auth login europe-docker.pkg.dev -u oauth2accesstoken --password-stdinWhat advanced commands does Crane offer?
These commands are used less often day to day but can prove very useful in specific cases: security audit, debugging, image migration, or advanced automation.
Change the labels of an image
Labels are metadata attached to an image (version, maintainer, license, etc.).
The mutate command lets you change them without rebuilding the image, which is
useful to add information after the fact.
crane mutate localhost:5000/alpine:3.21 \ --label "maintainer=equipe-plateforme" \ --label "version=3.21" \ -t localhost:5000/alpine:3.21-labeledRead the configuration of the new image back to check the labels: the following
command displays the two labels maintainer and version.
crane config localhost:5000/alpine:3.21-labeled | jq '.config.Labels'# {# "maintainer": "equipe-plateforme",# "version": "3.21"# }The -t (or --tag) option creates a new reference rather than modifying the
original.
Change the platform of an image
Since version 0.19.2 (June 2024), mutate can change the platform of an image
with the --set-platform option. This is useful when an image was built with an
incorrect or missing platform.
The example needs a starting image. The first command creates it in the local
registry by copying the amd64 variant of alpine:3.21 under the name
myapp:1.0; the second makes a copy of it whose platform is set.
crane copy alpine:3.21 localhost:5000/myapp:1.0 --platform linux/amd64# 2026/11/04 08:35:36 Copying from alpine:3.21 to localhost:5000/myapp:1.0# 2026/11/04 08:35:38 pushed blob: sha256:f439434f9e67...# 2026/11/04 08:35:38 pushed blob: sha256:759efa4719c9...# 2026/11/04 08:35:38 localhost:5000/myapp:1.0: digest: sha256:f765c92e5953... size: 1022
crane mutate localhost:5000/myapp:1.0 \ --set-platform linux/amd64 \ -t localhost:5000/myapp:1.0-amd64# 2026/11/04 08:35:38 existing blob: sha256:759efa4719c9...# 2026/11/04 08:35:38 pushed blob: sha256:85d99b6c5ada...# 2026/11/04 08:35:38 localhost:5000/myapp:1.0-amd64: digest: sha256:3003e0f462cf... size: 898To check, compare the two digests: the new image has sha256:3003e0f462cf…
while the source has sha256:f765c92e5953…, because mutate rewrites the
configuration and publishes a new manifest. The platform of alpine:3.21 was
already linux/amd64: the example shows the gesture, not a real fix. To read
back the stored platform, run the following command.
crane config localhost:5000/myapp:1.0-amd64 | jq '{os, architecture}'# {# "os": "linux",# "architecture": "amd64"# }Export an image to tar
The export command downloads all the layers of an image and assembles them in
a tar archive. It is useful for archiving, for transfer to a disconnected
(air-gapped) environment, or for analyzing the content.
crane export localhost:5000/monserveurweb:0.1 monserveurweb.tarThe archive contains the complete file system of the image. You can explore
it with tar -tf monserveurweb.tar or extract it for inspection.
Get a specific layer
For in-depth debugging, you can extract a particular layer. Each layer
corresponds to a Dockerfile instruction (RUN, COPY, ADD). This lets you see
exactly what each step added or changed. The layer's digest goes into a
variable, which saves you from copying it by hand: here, .layers[1] is the
second layer, the one from the RUN instruction that installs nginx.
# Get the digest of the second layerDIGEST=$(crane manifest localhost:5000/monserveurweb:0.1 | jq -r '.layers[1].digest')
# Extract this layercrane blob localhost:5000/monserveurweb:0.1@"$DIGEST" > layer.tar.gz
# Explore the content of the layertar -tzf layer.tar.gz | head -20Rebase onto a new base image
rebase rebuilds an image with a new base image, without a Dockerfile. The
--old_base option designates the exact base the image started from (here
the FROM of the test Dockerfile, pinned by digest) and --new_base the base
to substitute for it. Here, alpine:3.21 designates today's tag: the rebase
therefore replaces the layers of the pinned base with those of this tag.
crane rebase localhost:5000/monserveurweb:0.1 \ --old_base alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \ --new_base alpine:3.21 \ --tag localhost:5000/monserveurweb:rebasedTo check the rebase, list the layers of the new image and compare them with those of the original image:
crane manifest localhost:5000/monserveurweb:rebased | jq -r '.layers[].digest[0:19]'# sha256:759efa4719c9# sha256:c173c577271e# sha256:38d15c98fef5# sha256:4f4fb700ef54Only the first layer, the base one, changes: it went from sha256:897d797d2723
to sha256:759efa4719c9. The other three are those of the original manifest.
How do you integrate Crane into a CI/CD pipeline?
Crane is particularly well suited to CI/CD pipelines for several reasons:
- Crane works without a Docker daemon, therefore without root privileges.
- It installs in a few seconds: a single binary, with no dependency to install alongside.
- It copies from one registry to another without intermediate storage on disk.
- It scripts easily: simple commands, predictable outputs, standard exit codes.
The example below shows how to integrate Crane into a workflow to automatically copy validated images to a production registry.
GitHub Actions example
A promotion pipeline copies the image that was tested, not an image that
happens to carry the same tag. The build job therefore reads the digest of the
index it has just pushed to ghcr.io, the tests run on that digest, and the
workflow below receives it as an input to copy source@digest to production. A
tag can be pushed again between the tests and the copy; a digest cannot.
This workflow is a reusable workflow: another workflow, the build one, calls it
(hence workflow_call) with needs.build.outputs.digest. This wiring is not
shown here. The caller must run on a push: tags event, because
github.ref_name serves as the production tag; the job checks this with an
if: guard. A called workflow can only reduce the GITHUB_TOKEN permissions it
receives from the caller, never extend them (GitHub documentation,
consulted on 2026-11-04): the calling job must therefore grant packages: read
itself.
ghcr.io/${{ github.repository }} requires a lowercase repository name. In a
reusable workflow, github.repository comes from the caller and no input
carries this name: as it stands, this workflow therefore only suits a lowercase
owner and repository. For a name with capital letters, you need to add the
conversion SOURCE_IMAGE="${SOURCE_IMAGE,,}" at the top of the run:, which
was neither scanned nor replayed. Without it, Docker refuses this name with
repository name (Demo/Crane-Demo) must be lowercase, and crane copy to
localhost:5000/Demo/Crane-Demo:1 fails with could not parse reference.
Several security details there are deliberate and worth reusing as they are. The
workflow starts from permissions: {} and grants packages: read only to the
job that needs it. Secrets and data coming from the repository go through an
env: block instead of being interpolated directly in run:, which closes the
command injection path. The production credentials live in a protected
environment (a GitHub environment, whose secrets can only be read by the job
that declares it) rather than at repository level. The downloaded binary is
verified by checksum before being installed. Finally, the passwords go through
--password-stdin, and the job compares the digests on both sides before
concluding.
name: Promote image to production registry
on: workflow_call: inputs: digest: description: "Digest de l'index testé, relevé par le job de build (sha256:...)" required: true type: string
# No permission by default: each job asks for the minimumpermissions: {}
# One promotion at a time per tagconcurrency: group: promote-image-${{ github.ref }} cancel-in-progress: false
jobs: promote: name: Promouvoir l'image vers la production runs-on: ubuntu-24.04 timeout-minutes: 15 # Protected environment: the production secrets only exist for this job environment: production if: startsWith(github.ref, 'refs/tags/') permissions: packages: read # read-only on ghcr.io: the source image is already published there steps: - name: Install Crane env: VERSION: v0.22.1 ARCHIVE: go-containerregistry_Linux_x86_64.tar.gz run: | set -euo pipefail BASE="https://github.com/google/go-containerregistry/releases/download/${VERSION}" curl -fsSL -o "$ARCHIVE" "${BASE}/${ARCHIVE}" curl -fsSL -o checksums.txt "${BASE}/checksums.txt" grep " ${ARCHIVE}$" checksums.txt | sha256sum --check - tar -xzf "$ARCHIVE" crane sudo install -m 0755 crane /usr/local/bin/crane
- name: Promote image by digest env: GHCR_USER: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} PROD_REGISTRY: ${{ vars.PROD_REGISTRY }} PROD_USER: ${{ secrets.PROD_USER }} PROD_PASSWORD: ${{ secrets.PROD_PASSWORD }} SOURCE_IMAGE: ghcr.io/${{ github.repository }}@${{ inputs.digest }} TARGET_IMAGE: ${{ vars.PROD_REGISTRY }}/monapp:${{ github.ref_name }} TESTED_DIGEST: ${{ inputs.digest }} run: | set -euo pipefail printf '%s' "$GHCR_TOKEN" | crane auth login ghcr.io -u "$GHCR_USER" --password-stdin printf '%s' "$PROD_PASSWORD" | crane auth login "$PROD_REGISTRY" -u "$PROD_USER" --password-stdin crane copy "$SOURCE_IMAGE" "$TARGET_IMAGE" SOURCE_DIGEST="$(crane digest "$SOURCE_IMAGE")" TARGET_DIGEST="$(crane digest "$TARGET_IMAGE")" echo "source: $SOURCE_DIGEST" echo "production: $TARGET_DIGEST" test "$SOURCE_DIGEST" = "$TARGET_DIGEST" test "$TARGET_DIGEST" = "$TESTED_DIGEST"This workflow was not executed, but it was passed as published, if: guard
included, through four workflow scanners (tools that read the YAML without
running it), all launched without network and pinned by digest. actionlint
1.7.12, zizmor 1.32.0 (--offline, pedantic persona) and poutine 1.1.6 report
no defect. With no uses: action, there is no commit SHA to pin.
The fourth, plumber, calls for a more careful reading. Without a GitHub token,
it runs in degraded mode: it only analyzes the content of the workflow, without
querying GitHub. It reads 1 workflow, evaluates 13 controls, all displayed at
100%, and skips one, disabled in its default configuration. Seven of these
100% check nothing. Branch protection read no branch. The controls on images
(pinning), on actions (pinning, archived repositories, known CVEs), on security
jobs and on reusable workflow calls have nothing to analyze, since the workflow
contains none. The other six cover its real content: injection in scripts,
dangerous triggers, declared permissions, absence of write-all, of
Docker-in-Docker and of debug trace. The image used is
getplumber/plumber@sha256:aed1dd17…, which does not expose a version
(vdev).
The two final test commands make the job fail if the copy did not keep the
digest, or if this digest is not the one the build job passed. The copy of the
index digest is explained, and measured, in
BuildKit cache in CI and image promotion between registries.
What should you do when Crane fails?
Common problems and solutions
Two lines of this table do not signal a Crane failure, and they are what costs
the most time. An UNSUPPORTED on a crane delete is not an error on your
part: a registry started with REGISTRY_STORAGE_DELETE_ENABLED=false refuses
deletions (the registry:3.1.2 image allows them by default). And the
certificate error on an internal registry comes from the certificate
authority, not from Crane.
| Symptom | Likely cause | Solution |
|---|---|---|
unauthorized | Credentials missing, expired, or without rights on this repository | Regenerate the token, run crane auth login again, then check access with crane ls REGISTRY/REPOSITORY |
manifest unknown | Tag does not exist | Check with crane ls |
UNSUPPORTED on crane delete | Deletion disabled on the registry | Restart the registry without REGISTRY_STORAGE_DELETE_ENABLED=false |
connection refused | Registry not reachable | Check the URL and the network |
certificate signed by unknown authority | Certificate authority unknown to the machine (internal authority, self-signed certificate) | Add the authority to the system or runner trust store; as a last resort, and in development only, --insecure |
Diagnostic commands
These four commands are run in order, from the coarsest to the most precise: first the connection, then the existence of the repository, then that of the tag, then the content of the manifest. This progression keeps you from hunting for a manifest error when the blockage actually comes from the network or from authentication.
# Check connectivity to a registry, without writing anythingcrane catalog localhost:5000
# List the tags of a repositorycrane ls localhost:5000/monserveurweb
# Check that a tag existscrane digest localhost:5000/monserveurweb:0.1# sha256:7abf7ac6187ccccb869c42692647544ca66eb35b49348f32c46cfbc9bac21fa5
# See the details of an error, here on a tag that does not existcrane manifest localhost:5000/monserveurweb:nope 2>&1# Error: fetching manifest localhost:5000/monserveurweb:nope: GET http://localhost:5000/v2/monserveurweb/manifests/nope: MANIFEST_UNKNOWN: manifest unknown; unknown tag=nopeThese commands change nothing. The same request on a closed port fails with
connection refused, which points to the network and not to the manifest:
crane ls localhost:5999/x# Error: reading tags for localhost:5999/x: Get "https://localhost:5999/v2/": dial tcp [::1]:5999: connect: connection refused; Get "http://localhost:5999/v2/": dial tcp [::1]:5999: connect: connection refusedSome public registries disable the catalog: in that case, use crane ls on a
specific repository. Do not test a connection with crane auth login: the
command writes the credentials to ~/.docker/config.json without contacting the
registry. It is crane ls that proves access works.
What best practices apply to Crane?
Security
The last point of the list is the most structuring. A tag is a mutable
pointer: the same monapp:1.2.3 can designate different content tomorrow if
someone pushes it again. A digest is computed from the content, so it cannot
change without changing name. It is the only way to guarantee that what you
tested is what actually runs.
- Reserve
--insecurefor development: never in production. - Store credentials in the CI/CD secrets, never in the code, and pass them through
--password-stdin. - Deploy by digest rather than by tag for critical deployments.
Performance
The gain of crane copy comes from a protocol detail: it queries the
destination registry to find out which layers it already has and transfers only
the missing ones, without ever writing to the local disk. A docker pull
followed by a docker push first downloads the whole image into the daemon's
storage, which you have to plan for on the runner, and can lose platforms of a
multi-platform image.
- Copy with
crane copyrather than withdocker pullthendocker pushfor transfers between registries. - Keep
flattenfor a specific need: it barely reduces the size and loses layer sharing with the base image. - Parallelize the copies in your pipelines if you have several images.
Organization
These three habits serve the same goal: being able to answer the question "which
image exactly is running in production?" six months after an incident. Regular
cleanup is not only a matter of storage cost: a registry cluttered with hundreds
of obsolete tags makes diagnosis much slower, and pushes teams to redeploy
latest out of convenience.
- Version images with semantic tags (
1.2.3, notlatest). - Document the digests of the images deployed in production.
- Regularly clean up the old images of your registries.
How do you clean up after the tests?
The tests leave four things on the machine: the registry container and its
images, the images pulled by Docker, the exported files, and the credentials
that crane auth login wrote to ~/.docker/config.json. The commands below
remove them. They were replayed as is with Docker 29.9.2 and Crane 0.22.1,
without error; the -v option of docker rm also deletes the registry's
anonymous volume, and crane auth logout answers logged out via followed by
the path of config.json.
docker rm -fv registrydocker rmi localhost:5000/monserveurweb:0.1 \ localhost:5000/nginx:1.27.3-alpine \ localhost:5000/nginx:1.27.3-alpine-flat \ registry:3.1.2@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3rm -f layer.tar.gz monserveurweb.tarcrane auth logout localhost:5000If you also ran the "Docker image" tab, two crane images pulled by
docker run remain, as well as the lab's Dockerfile and index.html files.
These commands were replayed without error, after pulling the two images: no
go-containerregistry image remains afterwards.
docker rmi \ gcr.io/go-containerregistry/crane:v0.22.1@sha256:1f968817b95790bed063f71175aa6b8ff879fa17064020415f3e18bb6e6a36e1 \ gcr.io/go-containerregistry/crane/debug:v0.22.1@sha256:e78770b31258a3846f878036d9c1f63fbe4c871f9f56990bf77fd95c013e3c1brm -f Dockerfile index.htmlThe lab's docker build also leaves build cache (14.5 MB measured) that these
commands do not remove: docker builder prune -f empties it, along with any
other unused build cache on the machine. If you installed the binary for the
test, sudo rm /usr/local/bin/crane removes it. Then open
~/.docker/config.json to check that the localhost:5000 entry has gone from
the auths section.
Key points
- Crane works without Docker: ideal for restricted CI/CD environments
crane copykeeps the index and its digest, whereas pull/tag/push can lose platforms and change the digest; it also copies only the missing layers. The--platformoption changes this: it pushes only one manifest, so the digest of the target is no longer that of the index- The manifest holds all the metadata of an image (layers, size, digest)
flattenmerges the layers into one: the size gain is small (20.5 MB against 20.3 MB) and layer sharing with the base image is lost- Digests are immutable: prefer them to tags for reproducibility, and
promote
source@digest, never a tag crane auth loginwrites the credentials without contacting the registry: pass the secret through--password-stdinand check access withcrane ls- Crane is part of the go-containerregistry project (repository
google/go-containerregistry): pin its version, for examplev0.22.1
Next steps
More information
Crane moves fast and its reference documentation lives in the repository itself, not on a separate site. The second link is the one to keep at hand: it lists every subcommand with its exact options, and it is the source of truth when an option cited here does not behave as expected on your version.
- go-containerregistry on GitHub, The project that contains Crane
- Crane documentation, Command reference
- Crane releases, Download the binaries
FAQ: frequently asked questions
Crane vs Skopeo
Crane and Skopeo are two complementary tools:| Aspect | Crane | Skopeo |
|---|---|---|
| Publisher | Red Hat | |
| Strength | Direct copy of an index, inspection | Multiple formats |
| Transports | Registries, tar archives and OCI folders | docker-daemon, oci-archive... |
| Typical CI/CD use | Image promotion and inspection | Migrations between transport formats |
Installing Crane
The recommended method is the release binary, whose checksum you verify before extracting it:VERSION=v0.22.1
ARCHIVE=go-containerregistry_Linux_x86_64.tar.gz
BASE="https://github.com/google/go-containerregistry/releases/download/${VERSION}"
curl -fsSL -o "$ARCHIVE" "${BASE}/${ARCHIVE}"
curl -fsSL -o checksums.txt "${BASE}/checksums.txt"
grep " ${ARCHIVE}$" checksums.txt | sha256sum --check -
tar -xzf "$ARCHIVE" crane
sudo install -m 0755 crane /usr/local/bin/crane
The output of the check must end with OK, then crane version displays 0.22.1.Three other methods exist, not replayed for this page:- asdf-vm:
asdf plugin add crane && asdf install crane 0.22.1 - Arch Linux:
pacman -S crane - Homebrew:
brew install crane
Copying between registries
crane copy alpine:3.21@sha256:ee16d92f96db08d9d2d07da48555a27f3a69d8f0759021dbc601195ed0d2b5bd localhost:5000/alpine:3.21
# Limit to one architecture
crane copy alpine:3.21@sha256:ee16d92f96db08d9d2d07da48555a27f3a69d8f0759021dbc601195ed0d2b5bd localhost:5000/alpine:3.21 --platform linux/amd64
During the copy:- Crane reads the layers in the source registry and sends them to the target, without the Docker daemon
- Nothing is written to the local disk; the bytes still transit through the machine that runs Crane
- Only the layers missing from the target are transferred
- Without
--platform, the whole index is copied and its digest is kept; with--platform, only the manifest of the platform is pushed, so the target digest is not the index digest (measured with Crane 0.22.1 on alpine:3.21: index ee16d92f…, target f765c92e…)
crane copy can write sha256-<digest> fallback tags on a registry without a referrers API, such as registry:3.1.2. The copies in the Crane guide use --platform, which pushes only one manifest; the referrers case is measured in the page BuildKit cache in CI and image promotion between registries.Flattening layers
crane flatten localhost:5000/nginx:1.27.3-alpine -t localhost:5000/nginx:1.27.3-alpine-flat
# Check the result
crane manifest localhost:5000/nginx:1.27.3-alpine-flat | jq '.layers | length'
# 1
The original image had 8 layers.Here is what flatten changes:- The size gain is small: 20.5 MB against 20.3 MB to transfer
- Layer sharing with the base image is lost, so each update retransfers the whole image
- The old history is copied into the
commentfield of the new entry:flattendoes not hide it
flatten is therefore not a way to slim down an image: keep it for a specific need.Inspecting layers
# Full manifest
crane manifest localhost:5000/monserveurweb:0.1 | jq
# Number of layers
crane manifest localhost:5000/monserveurweb:0.1 | jq '.layers | length'
# Configuration and history
crane config localhost:5000/monserveurweb:0.1 | jq
# SHA256 digest
crane digest localhost:5000/monserveurweb:0.1
These commands assume a single-platform image, like the one in the page's lab. On a multi-platform image, crane manifest returns an index, which has no layers field: you have to read the manifest of one platform.Working without Docker
Crane talks directly to registries through the OCI HTTP API.Crane has four advantages:- No need for the Docker daemon
- Works in unprivileged containers
- Ideal for restricted CI/CD
- A single binary to install
# Works on a server without Docker
crane ls alpine
Authentication
crane auth login stores the credentials in ~/.docker/config.json, compatible with Docker, and does not contact the registry. A successful login therefore proves nothing: check access with crane ls.# The secret goes through standard input, never in the -p option
printf '%s' "$REGISTRY_PASSWORD" | crane auth login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
# Check access
crane ls "$REGISTRY/monapp"
# Artifact Registry with gcloud
gcloud auth print-access-token | crane auth login europe-docker.pkg.dev -u oauth2accesstoken --password-stdin
The --password-stdin form was replayed with Crane 0.22.1 on a local registry (logged in via, then logged out via with crane auth logout); the gcloud form was not. Crane displays WARNING! Your credentials are stored unencrypted: configure a credential helper to make it go away.Changing metadata
# Add labels
crane mutate localhost:5000/monserveurweb:0.1 \
--label version=0.1 \
--label maintainer=equipe-plateforme \
-t localhost:5000/monserveurweb:0.1-labeled
# Change the platform (v0.19.2+)
crane mutate localhost:5000/myapp:1.0 --set-platform linux/amd64 -t localhost:5000/myapp:1.0-amd64
The -t option creates a new reference rather than modifying the original. The second example assumes a myapp:1.0 image in the local registry, created by crane copy alpine:3.21 localhost:5000/myapp:1.0 --platform linux/amd64.GitHub Actions
The full workflow, with its minimal permissions, its secrets passed throughenv: and its copy by digest, is in the "GitHub Actions example" section of the page. It is a reusable workflow: the build workflow calls it on a tag, passing it the digest of the tested image. The installation there verifies the checksum of the binary:- name: Install Crane
env:
VERSION: v0.22.1
ARCHIVE: go-containerregistry_Linux_x86_64.tar.gz
run: |
set -euo pipefail
BASE="https://github.com/google/go-containerregistry/releases/download/${VERSION}"
curl -fsSL -o "$ARCHIVE" "${BASE}/${ARCHIVE}"
curl -fsSL -o checksums.txt "${BASE}/checksums.txt"
grep " ${ARCHIVE}$" checksums.txt | sha256sum --check -
tar -xzf "$ARCHIVE" crane
sudo install -m 0755 crane /usr/local/bin/crane
SHA256 digest
crane digest alpine:3.21
# sha256:ee16d92f96db08d9d2d07da48555a27f3a69d8f0759021dbc601195ed0d2b5bd
This digest is that of the alpine:3.21 index read on 2026-11-04: the tag moves, your result will be different.The digest has three benefits:- Immutable (unlike tags)
- Guarantees reproducibility
- Prevents tag replacement attacks
# Reference by digest
crane copy alpine:3.21@sha256:ee16d92f96db08d9d2d07da48555a27f3a69d8f0759021dbc601195ed0d2b5bd localhost:5000/alpine:3.21