Skopeo lets you inspect, copy and delete container images directly on registries, without downloading them or starting a daemon. In 10 minutes, you will know how to transfer an image between Docker Hub and a private registry, build a local mirror, and prepare images for an air-gapped environment. To publish or pull an image with Docker, see Publish and pull an image in a controlled way.
What you will learn
- Inspect a remote image: read metadata, tags and digest without downloading
- Copy between registries: transfer images between Docker Hub, Quay.io (in French), Harbor
- Sync a mirror: replicate several images with a YAML file
- Handle the air gap: export and import images for disconnected environments
In which context?
As soon as a team manages container images beyond a simple docker pull on a developer workstation, Skopeo becomes indispensable. Here are the concrete situations where it steps in:
- CI/CD pipeline: copy a freshly built image to a production registry without installing Docker on the runner
- Corporate mirror: sync a subset of public images to an internal registry (Harbor, Nexus) to control the versions that get deployed
- Air-gapped environment: export images on a connected workstation, carry them over a physical medium, then import them into an isolated registry
- Security audit: check the metadata, digests and tags of an image before deploying it, without downloading it
- Registry migration: move hundreds of images from one Docker Hub to a private registry with a single
synccommand - Multi-architecture: copy multi-arch images (amd64 + arm64) between registries while preserving the manifests
What Skopeo can do
Skopeo covers four main use cases for managing container images:
| Feature | Command | Use case |
|---|---|---|
| Inspection | skopeo inspect | Check the metadata, architecture and layers before downloading |
| Copy | skopeo copy | Transfer an image between registries or to a local file |
| Synchronization | skopeo sync | Replicate several images/tags to a private mirror |
| Deletion | skopeo delete | Delete an image from a remote registry |
Supported transports
Skopeo supports several source/destination formats (called "transports"):
| Transport | Format | Description |
|---|---|---|
docker:// | docker://registry/image:tag | Docker registry (Docker Hub, Quay.io, Harbor, etc.) |
dir: | dir:/local/path | Local folder with the layers and the manifest |
docker-archive: | docker-archive:/path/image.tar | Archive of a single image in the docker-save(1) format, decompressed layers |
oci: | oci:/path:tag | Folder in the OCI format |
oci-archive: | oci-archive:/path/image.tar | Archive in the OCI format, compressed layers kept |
containers-storage: | containers-storage:image:tag | Local Podman/Buildah storage |
Installing Skopeo
sudo apt update && sudo apt install -y skopeosudo dnf install -y skopeobrew install skopeoUnder WSL with Ubuntu:
sudo apt update && sudo apt install -y skopeoCheck the installation:
skopeo --versionExpected result (the version may vary):
skopeo version 1.22.2commit: 267465e170820673de25149378284fb352daa65eInspect a remote image
The skopeo inspect command lets you read the metadata of an image without downloading it. Handy for checking the architecture, the creation date or the environment variables before deploying.
Display the full metadata
skopeo inspect docker://docker.io/library/nginx:1.27.3Excerpt of the result:
{ "Name": "docker.io/library/nginx", "Digest": "sha256:bc2f6a7c8ddbccf55bdb19659ce3b0a92ca6559e86d42677a5a02ef6bda2fcef", "RepoTags": ["1", "1-alpine", "1.27", "1.27.3", "..."], "Architecture": "amd64", "Os": "linux", "Created": "2024-11-26T18:42:08Z", "Env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "NGINX_VERSION=1.27.3", "NJS_VERSION=0.8.7" ]}The key pieces of information returned:
- Digest: unique SHA256 fingerprint of the image (guarantees integrity)
- RepoTags: all the tags available for this image
- Architecture/Os: target platform (amd64, arm64, linux, windows)
- Created: build date of the image
- Env: environment variables defined in the image
Extract a specific piece of information with jq
To display only some fields, combine it with jq:
skopeo inspect docker://docker.io/library/nginx:1.27.3 | jq '{Name, Digest, Architecture, Os, Created}'Result:
{ "Name": "docker.io/library/nginx", "Digest": "sha256:bc2f6a7c8ddbccf55bdb19659ce3b0a92ca6559e86d42677a5a02ef6bda2fcef", "Architecture": "amd64", "Os": "linux", "Created": "2024-11-26T18:42:08Z"}List the available tags
To know all the published versions of an image:
skopeo list-tags docker://docker.io/library/nginxFilter the tags with jq (here, the 1.27.x versions):
skopeo list-tags docker://docker.io/library/nginx | jq '.Tags | map(select(startswith("1.27"))) | .[:10]'Result:
[ "1.27", "1.27-alpine", "1.27-alpine-otel", "1.27-alpine-perl", "1.27-alpine-slim", "1.27-alpine3.19", "1.27-alpine3.19-otel", "1.27-alpine3.19-perl", "1.27-alpine3.19-slim", "1.27-alpine3.20"]Inspection with authentication
If the registry requires authentication:
skopeo inspect --creds user:password docker://registry.example.com/my-image:1.0.0Or with an authentication file (Docker format):
skopeo inspect --authfile ~/.docker/config.json docker://registry.example.com/my-image:1.0.0Copy images between registries
The skopeo copy command transfers an image from a source to a destination. The transfer happens directly between the registries, your machine does not store the image.
Basic copy between two registries
skopeo copy docker://docker.io/library/alpine:3.20.6 docker://registry.example.com/alpine:3.20.6The alpine:3.20.6 image is copied from Docker Hub to your private registry.
Copy with authentication
When the source and/or the destination require credentials:
skopeo copy \ --src-creds source_user:source_password \ --dest-creds dest_user:dest_password \ docker://quay.io/my-org/my-app:2.1.0 \ docker://registry.example.com/my-app:2.1.0Copy to a local folder
To extract the layers and the manifest into a folder:
skopeo copy docker://docker.io/library/alpine:3.20.6 dir:/tmp/alpine-3.20.6Content of the created folder:
/tmp/alpine-3.20.6/├── 0a9a5dfd008f... # Compressed layer├── ff221270b9fb... # Image configuration├── manifest.json # Manifest describing the image└── version # Format versionCopy to a Docker archive
To create a .tar file compatible with docker load, give the archive the name that the image will carry once loaded, after the path of the file:
skopeo copy docker://docker.io/library/alpine:3.20.6 docker-archive:/tmp/alpine-3.20.6.tar:docker.io/library/alpine:3.20.6Without this destination name, docker load succeeds but only prints Loaded image ID: sha256:…: the image is loaded without a name or tag, and you can only run it by its identifier.
Copy to an OCI archive
OCI format, compatible with podman load and OCI runtimes. The destination name follows the same principle as for docker-archive:: without it, docker load also gives a plain Loaded image ID: sha256:….
skopeo copy docker://docker.io/library/alpine:3.20.6 oci-archive:/tmp/alpine-3.20.6-oci.tar:docker.io/library/alpine:3.20.6Convert between Docker and OCI formats
From Docker to OCI:
skopeo copy docker://docker.io/library/nginx:1.27.3 oci:nginx-oci:1.27.3From OCI to a Docker archive:
skopeo copy oci:nginx-oci:1.27.3 docker-archive:nginx.tar:docker.io/library/nginx:1.27.3Useful options for copying
| Option | Description |
|---|---|
--all | Copies all the architectures of a multi-arch image |
--preserve-digests | Keeps the original digests (useful for signatures) |
--retry-times 3 | Number of attempts on network failure |
--retry-delay 5s | Delay between attempts (v1.18.0+) |
--digestfile /tmp/digest.txt | Writes the digest of the copied image to a file |
Example with reliability options:
skopeo copy \ --retry-times 3 \ --retry-delay 5s \ --digestfile /tmp/nginx-digest.txt \ docker://docker.io/library/nginx:1.27.3 \ docker://registry.example.com/nginx:1.27.3Air-gapped (disconnected) environments
In environments cut off from the Internet, Skopeo lets you export then import images without a network connection.
Full air-gap workflow
-
On a connected machine: export the image to an
oci-archive:archive, which keeps the layers compressed. Foralpine:3.24.3, it weighs 3.86 MB against 8.7 MB asdocker-archive:.Terminal window skopeo copy docker://docker.io/library/nginx:1.27.3 oci-archive:/media/usb/nginx-1.27.3.tar:docker.io/library/nginx:1.27.3 -
Transfer the archive to the air-gapped environment (USB stick, disk, etc.)
-
On the air-gapped machine: import the image into the private registry
Terminal window skopeo copy oci-archive:/media/usb/nginx-1.27.3.tar docker://internal-registry.local/nginx:1.27.3This command assumes that
internal-registry.localserves trusted HTTPS on port 443: Skopeo verifies the registry's certificate. For a test registry over HTTP, for example on port 5000, give the port and disable TLS verification of the destination:Terminal window skopeo copy --dest-tls-verify=false oci-archive:/media/usb/nginx-1.27.3.tar docker://internal-registry.local:5000/nginx:1.27.3
If the air-gapped machine loads the archive into a Docker daemon with docker load rather than into a registry, the loaded image carries the digest of the manifest of the copied platform, not that of the index: a FROM pinned on the index digest does not resolve there. The lesson Export an image without a daemon with Skopeo shows it and gives the remedy.
Sync several images
The skopeo sync command replicates several images or tags in a single operation. Ideal for keeping a private mirror up to date.
Syncing a whole repository
Sync all the tags of an image:
skopeo sync --src docker --dest docker docker.io/library/alpine registry.example.comThis command copies all the tags of alpine to your registry.
Selective sync with a YAML file
For fine control, define the images in a YAML file:
# Source registry: docker.iodocker.io: images: # All alpine versions >= 3.19 alpine: - "3.19" - "3.20" - "3.20.6" # Specific nginx versions nginx: - "1.27.3" - "1.27.3-alpine" tls-verify: true
# Source registry: quay.ioquay.io: images: prometheus/prometheus: - "v2.54.1" - "v2.55.0" tls-verify: trueRun it:
skopeo sync --src yaml --dest docker sync.yaml registry.example.com/mirror/Useful sync options
| Option | Description |
|---|---|
--dry-run | Simulates the synchronization without copying |
--keep-going | Continues even if an image fails |
--scoped | Prefixes the images with their source path |
--all | Syncs all the architectures |
Example with a dry run to check before running:
skopeo sync --src yaml --dest docker --dry-run sync.yaml registry.example.com/mirror/Create a local mirror (folder)
To store the images locally before transferring them:
skopeo sync --src docker --dest dir docker.io/library/alpine /backup/images/Then push the local mirror to a registry:
skopeo sync --src dir --dest docker /backup/images/ registry.example.comDelete a remote image
The skopeo delete command deletes an image directly on the registry:
skopeo delete docker://registry.example.com/my-app:1.0.0-obsoleteWith authentication:
skopeo delete --creds admin:secret docker://registry.example.com/test-image:devCompare two images
To check whether two images are identical (same content), compare their digests:
# Digest of the image on Docker Hubskopeo inspect docker://docker.io/library/nginx:1.27.3 | jq -r '.Digest'
# Digest of the image on your registryskopeo inspect docker://registry.example.com/nginx:1.27.3 | jq -r '.Digest'If both digests are identical, the images are strictly equivalent (same binary content).
Troubleshooting
Error "unauthorized: authentication required"
The registry requires authentication. Solutions:
# Option 1: credentials on the command lineskopeo inspect --creds user:password docker://registry.example.com/image:tag
# Option 2: authentication fileskopeo login registry.example.comskopeo inspect docker://registry.example.com/image:tagError "certificate signed by unknown authority"
The TLS certificate of the registry is not recognized. Solutions:
# Option 1: disable TLS verification (not recommended in production)skopeo inspect --tls-verify=false docker://registry.example.com/image:tag
# Option 2: specify the certificates folderskopeo inspect --cert-dir /etc/docker/certs.d/registry.example.com docker://registry.example.com/image:tagError "manifest unknown" or "not found"
The image or the tag does not exist. Check:
- The exact name of the image and the tag
- That you have read permissions
- List the available tags:
skopeo list-tags docker://registry/image
Timeout on large transfers
For large images, increase the number of attempts:
skopeo copy --retry-times 5 --retry-delay 10s docker://source/image:tag docker://dest/image:tagKey points
- Skopeo works without a daemon: unlike Docker, it needs no background service
- Direct transfer: images go from registry to registry without intermediate storage
- Pin versions: always use precise tags (e.g.
1.27.3) rather thanlatest - Several formats: Skopeo converts between Docker, OCI, archives and folders
- Air-gap ready: export/import through archives for disconnected environments
Next steps
More information
FAQ: frequently asked questions
Definition
Skopeo is a command-line tool created by Red Hat that handles container images directly on registries, without a Docker daemon.Key advantages
| Advantage | Description |
|---|---|
| No daemon | No Docker or Podman process required |
| Direct transfer | Images go from registry to registry without local storage |
| Multi-transport | Supports docker://, oci:, dir:, docker-archive: |
| Lightweight | Standalone binary, ideal for CI/CD |
| Air-gap ready | Export/import through archives for disconnected environments |
Recommended use cases
- Registry migrations: Docker Hub → Harbor, Quay.io → ECR
- Private mirrors: sync public images to an internal registry
- Air gap: export images to a USB stick for isolated environments
- CI/CD: copy images between registries without a daemon
- Inspection: check metadata before deployment
Quick example
# Inspect without downloading
skopeo inspect docker://docker.io/library/nginx:1.27.3
# Copy between registries
skopeo copy docker://docker.io/nginx:1.27.3 docker://registry.local/nginx:1.27.3
Comparison
| Criterion | Docker pull/push | Skopeo copy |
|---|---|---|
| Daemon required | Yes (root) | No |
| Local storage | Yes, downloads then pushes | No, direct transfer |
| Privileges | Root or docker group | Regular user |
| Migration speed | Slow (2 transfers) | Fast (1 transfer) |
| Bandwidth | Uses 2x | Uses 1x |
Docker workflow (classic)
# Step 1: download locally
docker pull docker.io/nginx:1.27.3
# Step 2: retag
docker tag nginx:1.27.3 registry.local/nginx:1.27.3
# Step 3: push
docker push registry.local/nginx:1.27.3
# → 3 commands, temporary local storage
Skopeo workflow (optimized)
# Direct transfer in 1 command
skopeo copy docker://docker.io/nginx:1.27.3 docker://registry.local/nginx:1.27.3
# → 1 command, no local storage
When to use Skopeo?
- Mass migration: copy 100+ images quickly
- CI/CD without a daemon: no Docker socket to mount
- Automation scripts: lightweight, scriptable binary
- Rootless environment: no privileges needed
When to stay with Docker?
- Local development: team habits
- Builds + push: docker build + docker push built in
Basic command
skopeo inspect docker://docker.io/library/nginx:1.27.3
Information returned
| Field | Description |
|---|---|
| Name | Full name of the image |
| Digest | SHA256 fingerprint (guarantees integrity) |
| RepoTags | All available tags |
| Architecture | amd64, arm64, etc. |
| Os | linux, windows |
| Created | Build date |
| Env | Environment variables |
Filter with jq
# Essential information
skopeo inspect docker://nginx:1.27.3 | jq '{Name, Digest, Architecture, Os, Created}'
# Digest only
skopeo inspect docker://nginx:1.27.3 | jq -r '.Digest'
# Environment variables
skopeo inspect docker://nginx:1.27.3 | jq '.Env'
List the available tags
skopeo list-tags docker://docker.io/library/nginx
# Filter 1.27.x versions
skopeo list-tags docker://nginx | jq '.Tags | map(select(startswith("1.27")))'
With authentication
# Inline credentials
skopeo inspect --creds user:password docker://registry.private.com/app:1.0.0
# Authentication file
skopeo inspect --authfile ~/.docker/config.json docker://registry.private.com/app:1.0.0
Full workflow
Step 1: Export on the connected machine
skopeo copy docker://docker.io/library/nginx:1.27.3 oci-archive:/media/usb/nginx-1.27.3.tar:docker.io/library/nginx:1.27.3
The oci-archive: format keeps the layers compressed: for alpine:3.24.3, the archive weighs 3.86 MB against 8.7 MB as docker-archive:. The name placed after the path of the file (docker.io/library/nginx:1.27.3) is the name the image will carry once loaded. Without it, docker load only prints Loaded image ID: sha256:… and the image cannot be run by its name.For a multi-architecture image, add --all with an oci-archive: archive (a docker-archive: archive carries only one image):skopeo copy --all docker://docker.io/library/nginx:1.27.3 oci-archive:/media/usb/nginx-1.27.3-multiarch.tar
This archive embeds all the platforms of the index: about 566 MB for nginx:1.27.3, against about 72 MB for the amd64 variant alone.Step 2: Transfer the archive
- USB stick
- External disk
- DVD burning
- Secure transfer (SCP over an isolated network)
Step 3: Import on the air-gapped machine
skopeo copy oci-archive:/media/usb/nginx-1.27.3.tar docker://internal-registry.local/nginx:1.27.3
This command assumes that internal-registry.local serves trusted HTTPS on port 443. For a test registry over HTTP, give the port and add --dest-tls-verify=false.Export alternatives
# Archive that docker load can load, with its name
skopeo copy docker://docker.io/library/nginx:1.27.3 docker-archive:/media/usb/nginx-1.27.3.tar:docker.io/library/nginx:1.27.3
# Local folder (layers and manifest)
skopeo copy docker://docker.io/library/nginx:1.27.3 dir:/media/usb/nginx-1.27.3/
Sync several images
# sync.yaml
docker.io:
images:
nginx:
- "1.27.3"
- "1.27.3-alpine"
alpine:
- "3.20.6"
# Export to a folder
skopeo sync --src yaml --dest dir sync.yaml /media/usb/images/
# Import from a folder
skopeo sync --src dir --dest docker /media/usb/images/ internal-registry.local
Check integrity
# Compare digests before/after
skopeo inspect docker://nginx:1.27.3 | jq -r '.Digest'
skopeo inspect docker://internal-registry.local/nginx:1.27.3 | jq -r '.Digest'
# Must be identical
YAML configuration file
# sync.yaml
# Source registry: Docker Hub
docker.io:
images:
# Specific versions
nginx:
- "1.27.3"
- "1.27.3-alpine"
alpine:
- "3.19"
- "3.20"
- "3.20.6"
# Image with all versions (dangerous)
busybox: []
tls-verify: true
# Source registry: Quay.io
quay.io:
images:
prometheus/prometheus:
- "v2.54.1"
- "v2.55.0"
coreos/etcd:
- "v3.5.17"
tls-verify: true
Running it
# Sync to a private registry
skopeo sync --src yaml --dest docker sync.yaml registry.local/mirror/
# Simulation (dry run)
skopeo sync --src yaml --dest docker --dry-run sync.yaml registry.local/mirror/
# Continue despite errors
skopeo sync --src yaml --dest docker --keep-going sync.yaml registry.local/mirror/
Useful options
| Option | Description |
|---|---|
--dry-run |
Simulates without copying |
--keep-going |
Continues if an image fails |
--scoped |
Prefixes with the source path (docker.io/library/nginx) |
--all |
All architectures |
--preserve-digests |
Keeps original digests |
Syncing a whole repository
# All the tags of an image
skopeo sync --src docker --dest docker docker.io/library/alpine registry.local
Create a local mirror (folder)
# Download to disk
skopeo sync --src yaml --dest dir sync.yaml /backup/images/
# Push from disk to a registry
skopeo sync --src dir --dest docker /backup/images/ registry.local
Automation (cron)
#!/bin/bash
# /usr/local/bin/sync-mirror.sh
set -e
LOG_FILE="/var/log/skopeo-sync.log"
SYNC_FILE="/etc/skopeo/sync.yaml"
REGISTRY="registry.local/mirror/"
echo "$(date) - Starting sync" >> $LOG_FILE
skopeo sync --src yaml --dest docker --keep-going $SYNC_FILE $REGISTRY >> $LOG_FILE 2>&1
echo "$(date) - Sync completed" >> $LOG_FILE
# Crontab (daily sync at 2 am)
0 2 * * * /usr/local/bin/sync-mirror.sh
Error "unauthorized: authentication required"
Solution 1: Credentials on the command line
skopeo inspect --creds user:password docker://registry.private.com/app:1.0.0
skopeo copy --src-creds user:pass --dest-creds user:pass \
docker://source/image:tag docker://dest/image:tag
Solution 2: Prior login
# Login (creates ~/.docker/config.json or the XDG file)
skopeo login registry.private.com
Username: admin
Password: ****
# The following commands use the token
skopeo inspect docker://registry.private.com/app:1.0.0
Solution 3: Authentication file
# Use the existing Docker config
skopeo inspect --authfile ~/.docker/config.json docker://registry.private.com/app:1.0.0
# Or a custom file
skopeo inspect --authfile /etc/skopeo/auth.json docker://registry.private.com/app:1.0.0
Specific registries
GitHub Container Registry (GHCR)
# Create a PAT with scopes: read:packages, write:packages
# https://github.com/settings/tokens
echo $GITHUB_TOKEN | skopeo login ghcr.io -u $GITHUB_USER --password-stdin
skopeo copy docker://ghcr.io/me/app:1.0.0 dir:/tmp/app
Docker Hub
# Create an Access Token: https://hub.docker.com/settings/security
echo $DOCKER_TOKEN | skopeo login docker.io -u $DOCKER_USER --password-stdin
AWS ECR
# AWS token (valid for 12h)
aws ecr get-login-password | skopeo login --username AWS --password-stdin 123456789.dkr.ecr.eu-west-1.amazonaws.com
Troubleshooting
# Check the auth file
cat ~/.docker/config.json | jq
# Test the connection
skopeo login --get-login registry.private.com
# Log out if the token has expired
skopeo logout registry.private.com
skopeo login registry.private.com
Error "certificate signed by unknown authority"
Solution 1: Disable TLS verification (testing only)
# Warning: not recommended in production
skopeo inspect --tls-verify=false docker://registry.local/app:1.0.0
skopeo copy --src-tls-verify=false --dest-tls-verify=false \
docker://registry.local/app:1.0.0 dir:/tmp/app
Solution 2: Add the CA certificate
# Create the folder for the registry
sudo mkdir -p /etc/docker/certs.d/registry.local/
# Copy the CA certificate
sudo cp /path/to/ca.crt /etc/docker/certs.d/registry.local/ca.crt
# Use with Skopeo
skopeo inspect --cert-dir /etc/docker/certs.d/registry.local docker://registry.local/app:1.0.0
Solution 3: Add the CA to the system
# Ubuntu/Debian
sudo cp ca.crt /usr/local/share/ca-certificates/registry-local.crt
sudo update-ca-certificates
# RHEL/Fedora
sudo cp ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust
# Skopeo now uses the system CA
skopeo inspect docker://registry.local/app:1.0.0
Permanent configuration
# /etc/containers/registries.conf.d/registry-local.conf
[[registry]]
location = "registry.local"
insecure = false
[[registry.mirror]]
location = "registry.local"
insecure = false
Troubleshooting
# Check the registry certificate
openssl s_client -connect registry.local:443 -showcerts
# Download the certificate
echo | openssl s_client -connect registry.local:443 2>/dev/null | openssl x509 -out /tmp/registry.crt
# Check the chain
openssl verify -CAfile /tmp/ca.crt /tmp/registry.crt
List of transports
| Transport | Format | Description |
|---|---|---|
docker:// |
docker://registry/image:tag |
Docker/OCI registries (Docker Hub, GHCR, Quay, Harbor, ECR) |
dir: |
dir:/local/path |
Folder with layers and manifest |
docker-archive: |
docker-archive:/path/image.tar |
Tar archive in the docker save format |
oci: |
oci:/path:tag |
OCI layout folder |
oci-archive: |
oci-archive:/path/image.tar |
Tar archive in the OCI format |
containers-storage: |
containers-storage:image:tag |
Local Podman/Buildah storage |
docker-daemon: |
docker-daemon:image:tag |
Local Docker daemon |
Usage examples
# Registry → local folder
skopeo copy docker://nginx:1.27.3 dir:/tmp/nginx
# Registry → Docker archive
skopeo copy docker://nginx:1.27.3 docker-archive:/tmp/nginx.tar:docker.io/library/nginx:1.27.3
# Registry → OCI archive
skopeo copy docker://nginx:1.27.3 oci-archive:/tmp/nginx-oci.tar:docker.io/library/nginx:1.27.3
# Archive → private registry
skopeo copy docker-archive:/tmp/nginx.tar docker://registry.local/nginx:1.27.3
# Registry → local Docker daemon
skopeo copy docker://nginx:1.27.3 docker-daemon:nginx:1.27.3
# Podman storage → registry
skopeo copy containers-storage:myapp:1.0.0 docker://registry.local/myapp:1.0.0
Converting between formats
# Docker registry → OCI folder
skopeo copy docker://nginx:1.27.3 oci:nginx-oci:1.27.3
# OCI folder → Docker archive
skopeo copy oci:nginx-oci:1.27.3 docker-archive:nginx.tar
# Docker archive → OCI archive
skopeo copy docker-archive:nginx.tar oci-archive:nginx-oci.tar
Inspecting different transports
# Remote registry
skopeo inspect docker://nginx:1.27.3
# Local folder
skopeo inspect dir:/tmp/nginx
# Archive
skopeo inspect docker-archive:/tmp/nginx.tar
Principle
The digest (SHA256 fingerprint) uniquely identifies the content of an image. Two images with the same digest have exactly the same binary content.Simple comparison
# Digest of the source image
skopeo inspect docker://docker.io/library/nginx:1.27.3 | jq -r '.Digest'
# sha256:bc2f6a7c8ddbccf55bdb19659ce3b0a92ca6559e86d42677a5a02ef6bda2fcef
# Digest of the destination image
skopeo inspect docker://registry.local/nginx:1.27.3 | jq -r '.Digest'
# sha256:bc2f6a7c8ddbccf55bdb19659ce3b0a92ca6559e86d42677a5a02ef6bda2fcef
# → Identical = identical images
Comparison script
#!/bin/bash
SOURCE="docker://docker.io/library/nginx:1.27.3"
DEST="docker://registry.local/nginx:1.27.3"
DIGEST_SRC=$(skopeo inspect $SOURCE | jq -r '.Digest')
DIGEST_DST=$(skopeo inspect $DEST | jq -r '.Digest')
if [ "$DIGEST_SRC" = "$DIGEST_DST" ]; then
echo "OK: identical images"
echo " Digest: $DIGEST_SRC"
else
echo "Difference: different images"
echo " Source: $DIGEST_SRC"
echo " Dest: $DIGEST_DST"
fi
Compare several fields
# Detailed comparison
skopeo inspect docker://source/image:tag > /tmp/source.json
skopeo inspect docker://dest/image:tag > /tmp/dest.json
diff <(jq -S . /tmp/source.json) <(jq -S . /tmp/dest.json)
Check integrity after a copy
# Copy with a digestfile
skopeo copy --digestfile /tmp/digest.txt docker://nginx:1.27.3 docker://registry.local/nginx:1.27.3
# Check
cat /tmp/digest.txt
# sha256:bc2f6a7c8ddbccf55bdb19659ce3b0a92ca6559e86d42677a5a02ef6bda2fcef
skopeo inspect docker://registry.local/nginx:1.27.3 | jq -r '.Digest'
# Must match
Deletion command
skopeo delete docker://registry.local/app:1.0.0-obsolete
With authentication
skopeo delete --creds admin:password docker://registry.local/app:1.0.0
# Or after login
skopeo login registry.local
skopeo delete docker://registry.local/app:1.0.0
Limits by registry
| Registry | API deletion | Notes |
|---|---|---|
| Harbor | Yes | Requires the maintainer or admin role |
| Quay.io | Yes | Setting enabled by default |
| Docker Hub | No | Use the web interface |
| GHCR | Yes | Requires a PAT with delete:packages |
| ECR | Yes | Through the API or skopeo delete |
| GCR | Yes | Requires appropriate permissions |
Good practices
# 1. Check that the image exists
skopeo inspect docker://registry.local/app:1.0.0
# 2. Check that it is no longer in use
# (check Kubernetes deployments, docker-compose, etc.)
# 3. Delete
skopeo delete docker://registry.local/app:1.0.0
# 4. Check the deletion
skopeo inspect docker://registry.local/app:1.0.0
# Error: manifest unknown
Cleanup script (use with care)
#!/bin/bash
REGISTRY="registry.local"
IMAGE="app"
KEEP_LAST=5 # Keep the last 5 tags
# List all tags
TAGS=$(skopeo list-tags docker://$REGISTRY/$IMAGE | jq -r '.Tags[]' | sort -V)
# Delete the old ones (keep the last 5)
echo "$TAGS" | head -n -$KEEP_LAST | while read tag; do
echo "Deleting $REGISTRY/$IMAGE:$tag"
skopeo delete docker://$REGISTRY/$IMAGE:$tag
done
Warning
Warning: the deletion is irreversible. Always:- Check that the image is no longer in production
- Keep a backup if needed
- Test on a dev environment first