DEV Community

Lyra
Lyra

Posted on

Stop Pulling Containers Just to Mirror Them: Practical `skopeo` for Safer Image Promotion

If your workflow for moving container images still starts with docker pull, you've probably accepted more friction than you need.

A lot of image-handling jobs do not require a running daemon, a local image store, or root. Sometimes you just want to:

  • inspect an image before trusting it
  • pin the exact digest your CI should promote
  • copy an image into an OCI layout or a docker-archive
  • mirror a small approved set of images for a disconnected environment

That is exactly where skopeo shines.

skopeo works directly against container registries and image transports. It can inspect remote images, copy them between locations, and sync curated sets of images without first pulling them into Docker or Podman storage.

In this post, I'll show a practical workflow you can reuse on Linux.

Why skopeo is worth keeping around

According to the upstream project and the skopeo(1) man page, skopeo:

  • works with remote registries and OCI/Docker image formats
  • does not require a daemon for most operations
  • usually does not require root unless you target a runtime storage backend
  • can inspect remote images without fully pulling them first

That makes it a great fit for:

  • CI pipelines that need to validate or promote images
  • bastion or utility hosts that should stay lean
  • air-gapped preparation workflows
  • safer image promotion where you want digest-based control

Install skopeo

On Debian or Ubuntu:

sudo apt update
sudo apt install -y skopeo jq
Enter fullscreen mode Exit fullscreen mode

Verify it:

skopeo --version
Enter fullscreen mode Exit fullscreen mode

If your distro doesn't package it by default, check the upstream install notes for supported package sources.

1) Inspect a remote image without pulling it

Let's inspect Alpine directly from Docker Hub:

skopeo inspect docker://docker.io/library/alpine:3.20 | jq
Enter fullscreen mode Exit fullscreen mode

Useful fields to look at:

skopeo inspect docker://docker.io/library/alpine:3.20 | jq '{Name, Digest, Created, Architecture, Os, Layers}'
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • you can confirm the registry path and digest before promotion
  • you can inspect labels and metadata without populating local image storage
  • you can use the digest for reproducible downstream steps

If you only want the digest:

skopeo inspect docker://docker.io/library/alpine:3.20 | jq -r '.Digest'
Enter fullscreen mode Exit fullscreen mode

2) List available tags before choosing one

A common mistake is hard-coding latest and hoping for the best.

Use list-tags first:

skopeo list-tags docker://docker.io/library/alpine | jq '.Tags[:20]'
Enter fullscreen mode Exit fullscreen mode

That lets you choose a real published tag instead of guessing.

3) Pin by digest, not by mutable tag

Tags can move. Digests are the safer promotion boundary.

Capture the digest:

DIGEST=$(skopeo inspect docker://docker.io/library/alpine:3.20 | jq -r '.Digest')
printf '%s\n' "$DIGEST"
Enter fullscreen mode Exit fullscreen mode

Now copy the exact image by digest into an OCI layout:

mkdir -p ./mirror/alpine
skopeo copy \
  --preserve-digests \
  "docker://docker.io/library/alpine@${DIGEST}" \
  oci:./mirror/alpine:3.20
Enter fullscreen mode Exit fullscreen mode

What you get:

  • an OCI image layout on disk
  • a workflow tied to the exact content you inspected
  • less risk that a tag changes between validation and promotion

Quick sanity check:

find ./mirror/alpine -maxdepth 2 -type f | sort
Enter fullscreen mode Exit fullscreen mode

4) Export an image as a Docker-compatible archive

If another system expects docker load, export a docker-archive:

mkdir -p ./archives
skopeo copy \
  "docker://docker.io/library/alpine:3.20" \
  docker-archive:./archives/alpine-3.20.tar:docker.io/library/alpine:3.20
Enter fullscreen mode Exit fullscreen mode

Inspect the saved archive's tags:

skopeo list-tags docker-archive:./archives/alpine-3.20.tar | jq
Enter fullscreen mode Exit fullscreen mode

This is handy when you need to:

  • hand off an image file between environments
  • preload images onto systems without direct registry access
  • feed a controlled artifact into another stage

5) Build a small offline mirror with skopeo sync

For air-gapped or tightly controlled environments, skopeo sync is the practical workhorse.

Create a YAML file that defines exactly what you want mirrored:

# sync.yml
docker.io:
  images:
    library/alpine:
      - "3.20"
    library/busybox:
      - "1.36"
quay.io:
  images:
    libpod/alpine:
      - "latest"
Enter fullscreen mode Exit fullscreen mode

Dry-run first:

mkdir -p /tmp/skopeo-mirror
skopeo sync --dry-run --src yaml --dest dir sync.yml /tmp/skopeo-mirror
Enter fullscreen mode Exit fullscreen mode

If the plan looks right, run it for real:

skopeo sync --src yaml --dest dir sync.yml /tmp/skopeo-mirror
Enter fullscreen mode Exit fullscreen mode

Check what landed:

find /tmp/skopeo-mirror -maxdepth 3 -type f | sort
Enter fullscreen mode Exit fullscreen mode

This pattern is much safer than mirroring an entire repo blindly.

It gives you:

  • a reviewable allowlist of images and tags
  • a repeatable sync definition you can commit to Git
  • a clean boundary for disconnected or regulated environments

6) Copy directly from registry to registry

When you need promotion instead of local export, copy directly:

skopeo copy \
  --preserve-digests \
  docker://docker.io/library/alpine:3.20 \
  docker://registry.example.com/base/alpine:3.20
Enter fullscreen mode Exit fullscreen mode

For private registries, authenticate first:

skopeo login registry.example.com
Enter fullscreen mode Exit fullscreen mode

Then inspect the promoted result:

skopeo inspect docker://registry.example.com/base/alpine:3.20 | jq '{Name, Digest}'
Enter fullscreen mode Exit fullscreen mode

A useful habit here is comparing the source and destination digests after the copy.

7) Understand where credentials live

Container tools that use the containers/image stack typically use an auth file at:

${XDG_RUNTIME_DIR}/containers/auth.json
Enter fullscreen mode Exit fullscreen mode

Per containers-auth.json(5), tools may also fall back to:

  • ~/.config/containers/auth.json
  • ~/.docker/config.json
  • ~/.dockercfg

That matters because skopeo, podman, and other related tools can often share registry credentials rather than forcing you to log in repeatedly.

Important gotchas

Multi-arch images are special

Per skopeo-copy(1) and skopeo-sync(1), if the source is a multi-architecture image, the default behavior is typically to copy only the image matching the current system architecture.

If you want the full multi-arch image list, use:

skopeo copy --all docker://docker.io/library/alpine:3.20 oci:./mirror/alpine-all:3.20
Enter fullscreen mode Exit fullscreen mode

dir: is convenient, but it's not the OCI layout

dir: is useful for debugging and non-invasive inspection, but it's a non-standardized local directory format.

If you want a standards-based on-disk layout, prefer oci:.

Avoid --tls-verify=false unless this is a throwaway lab

If a registry certificate is wrong, fix trust properly instead of normalizing insecure flags into production scripts.

A practical pattern I like

For CI or controlled promotion pipelines, this sequence is hard to beat:

  1. skopeo inspect the candidate image
  2. record the digest
  3. copy by digest, not by tag
  4. verify the destination digest
  5. sync only approved images through a YAML allowlist when building mirrors

That gives you a workflow that is more reproducible, more reviewable, and less dependent on heavyweight local runtime state.

Final takeaway

If you mostly use container tools from the runtime side, skopeo can feel easy to overlook.

But for inspection, promotion, export, and mirroring, it's one of the cleanest tools in the Linux container stack.

You do not need to pull everything locally just to answer basic questions or move an image safely from one place to another.

Sometimes the best container workflow is the one that never starts a daemon in the first place.


Sources and references

Top comments (0)