Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français
Lire en français

Manage your container images with Crane

42 min read
Improve this page

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.

Crane sits between a source registry and a target registry: it reads the manifest of the source images and copies them to the target, and can also read the configuration, flatten layers, add tags, get the SHA256 digest and export to tar; it talks to Docker Hub, Artifact Registry, ECR, ACR, Harbor, Quay.io, GitLab Registry or a local registry.Source registryCraneTarget registryDocker Hub, Artifact Registry, ECR, ACR, Harbor, Quay.io, GitLab Registry, localhost:5000…alpine:3.21myapp:v1.2.3copy: copy between registriesmanifest: read metadataconfig: view the configurationflatten: merge layerstag: add tagsdigest: get the SHA256export: download as taralpine:3.21manifestcopy
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The table compares the most common operations with Docker and with Crane.

NeedWith DockerWith Crane
Copy an image between registriesdocker 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 imagedocker history (limited)crane manifest (detailed)
Get the SHA256 digestdocker inspectcrane digest
Export an image to tardocker savecrane export
Flatten the layersdocker 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.

Terminal window
# Pin a precise version rather than following "latest"
VERSION=v0.22.1
ARCHIVE=go-containerregistry_Linux_x86_64.tar.gz # other architectures: see the releases page
BASE="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 archive
grep " ${ARCHIVE}$" checksums.txt | sha256sum --check -
# go-containerregistry_Linux_x86_64.tar.gz: OK
tar -xzf "$ARCHIVE" crane
sudo install -m 0755 crane /usr/local/bin/crane
rm -f crane "$ARCHIVE" checksums.txt

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

Terminal window
crane version
# 0.22.1 with the release binary

How 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.

  1. Start a local registry

    Terminal window
    docker run -d -p 127.0.0.1:5000:5000 --name registry \
    registry:3.1.2@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3

    The registry will be reachable at localhost:5000.

  2. Create a test Dockerfile

    FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
    RUN apk --no-cache add nginx \
    && mkdir -p /var/www/html
    COPY index.html /var/www/html/
    WORKDIR /var/www/html
    EXPOSE 80
  3. Create the index.html file

    <!DOCTYPE html>
    <html>
    <body><h1>Test Crane</h1></body>
    </html>
  4. Build and push the image

    Terminal window
    docker build --provenance=false . -t localhost:5000/monserveurweb:0.1
    docker push localhost:5000/monserveurweb:0.1

    With Docker 29, docker build adds a provenance attestation by default and then pushes a manifest index instead of a single manifest. The --provenance=false option keeps a single manifest, the one the examples below describe. With the index, crane manifest has no layers field and crane rebase answers rebasing 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.

Terminal window
crane catalog localhost:5000
# monserveurweb

On 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.

Terminal window
crane ls localhost:5000/monserveurweb
# 0.1

This 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.

Terminal window
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).

Terminal window
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.

Terminal window
crane digest localhost:5000/monserveurweb:0.1
# sha256:7abf7ac6187ccccb869c42692647544ca66eb35b49348f32c46cfbc9bac21fa5

How 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.

Terminal window
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: 1022

This 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.

  1. It fetches the manifest of the source image (the "table of contents")
  2. For each layer, it checks whether it already exists in the destination
  3. It copies only the missing layers (saving bandwidth)
  4. 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.

Terminal window
crane catalog localhost:5000
# alpine
# monserveurweb
crane ls localhost:5000/alpine
# 3.21

What 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.

Terminal window
# Copy nginx to the local registry
crane copy nginx:1.27.3-alpine localhost:5000/nginx:1.27.3-alpine --platform linux/amd64
# Flatten the image
crane flatten localhost:5000/nginx:1.27.3-alpine \
-t localhost:5000/nginx:1.27.3-alpine-flat

The -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.

Terminal window
# Before: 8 layers
crane manifest localhost:5000/nginx:1.27.3-alpine | jq '.layers | length'
# 8
# After: a single layer
crane manifest localhost:5000/nginx:1.27.3-alpine-flat | jq '.layers | length'
# 1

To 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.

Terminal window
docker pull localhost:5000/nginx:1.27.3-alpine
docker pull localhost:5000/nginx:1.27.3-alpine-flat
docker 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.3MB

How 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.

Terminal window
crane tag localhost:5000/alpine:3.21 3.21.7

This 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.

Terminal window
crane ls localhost:5000/alpine
# 3.21
# 3.21.7

Validate 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.

Terminal window
crane validate --remote localhost:5000/monserveurweb:0.1
# PASS: localhost:5000/monserveurweb:0.1

Use 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.

Terminal window
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.json

The 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.

Terminal window
printf '%s' "$REGISTRY_PASSWORD" | crane auth login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
crane 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):

Terminal window
gcloud auth print-access-token | crane auth login europe-docker.pkg.dev -u oauth2accesstoken --password-stdin

What 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.

Terminal window
crane mutate localhost:5000/alpine:3.21 \
--label "maintainer=equipe-plateforme" \
--label "version=3.21" \
-t localhost:5000/alpine:3.21-labeled

Read the configuration of the new image back to check the labels: the following command displays the two labels maintainer and version.

Terminal window
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.

Terminal window
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: 898

To 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.

Terminal window
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.

Terminal window
crane export localhost:5000/monserveurweb:0.1 monserveurweb.tar

The 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.

Terminal window
# Get the digest of the second layer
DIGEST=$(crane manifest localhost:5000/monserveurweb:0.1 | jq -r '.layers[1].digest')
# Extract this layer
crane blob localhost:5000/monserveurweb:0.1@"$DIGEST" > layer.tar.gz
# Explore the content of the layer
tar -tzf layer.tar.gz | head -20

Rebase 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.

Terminal window
crane rebase localhost:5000/monserveurweb:0.1 \
--old_base alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
--new_base alpine:3.21 \
--tag localhost:5000/monserveurweb:rebased

To check the rebase, list the layers of the new image and compare them with those of the original image:

Terminal window
crane manifest localhost:5000/monserveurweb:rebased | jq -r '.layers[].digest[0:19]'
# sha256:759efa4719c9
# sha256:c173c577271e
# sha256:38d15c98fef5
# sha256:4f4fb700ef54

Only 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 minimum
permissions: {}
# One promotion at a time per tag
concurrency:
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.

SymptomLikely causeSolution
unauthorizedCredentials missing, expired, or without rights on this repositoryRegenerate the token, run crane auth login again, then check access with crane ls REGISTRY/REPOSITORY
manifest unknownTag does not existCheck with crane ls
UNSUPPORTED on crane deleteDeletion disabled on the registryRestart the registry without REGISTRY_STORAGE_DELETE_ENABLED=false
connection refusedRegistry not reachableCheck the URL and the network
certificate signed by unknown authorityCertificate 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.

Terminal window
# Check connectivity to a registry, without writing anything
crane catalog localhost:5000
# List the tags of a repository
crane ls localhost:5000/monserveurweb
# Check that a tag exists
crane digest localhost:5000/monserveurweb:0.1
# sha256:7abf7ac6187ccccb869c42692647544ca66eb35b49348f32c46cfbc9bac21fa5
# See the details of an error, here on a tag that does not exist
crane 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=nope

These commands change nothing. The same request on a closed port fails with connection refused, which points to the network and not to the manifest:

Terminal window
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 refused

Some 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 --insecure for 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 copy rather than with docker pull then docker push for transfers between registries.
  • Keep flatten for 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, not latest).
  • 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.

Terminal window
docker rm -fv registry
docker 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:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3
rm -f layer.tar.gz monserveurweb.tar
crane auth logout localhost:5000

If 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.

Terminal window
docker rmi \
gcr.io/go-containerregistry/crane:v0.22.1@sha256:1f968817b95790bed063f71175aa6b8ff879fa17064020415f3e18bb6e6a36e1 \
gcr.io/go-containerregistry/crane/debug:v0.22.1@sha256:e78770b31258a3846f878036d9c1f63fbe4c871f9f56990bf77fd95c013e3c1b
rm -f Dockerfile index.html

The 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

  1. Crane works without Docker: ideal for restricted CI/CD environments
  2. crane copy keeps the index and its digest, whereas pull/tag/push can lose platforms and change the digest; it also copies only the missing layers. The --platform option changes this: it pushes only one manifest, so the digest of the target is no longer that of the index
  3. The manifest holds all the metadata of an image (layers, size, digest)
  4. flatten merges 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
  5. Digests are immutable: prefer them to tags for reproducibility, and promote source@digest, never a tag
  6. crane auth login writes the credentials without contacting the registry: pass the secret through --password-stdin and check access with crane ls
  7. Crane is part of the go-containerregistry project (repository google/go-containerregistry): pin its version, for example v0.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.

FAQ: frequently asked questions

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn