Skip to content
Go back

Skopeo: Container Image Surgery Without a Daemon

By SumGuy 10 min read
Skopeo: Container Image Surgery Without a Daemon
Contents

You Don’t Need a Daemon for That

Here’s a thing that happens constantly in CI pipelines: someone needs to copy an image from a staging registry to production. Simple, right? So naturally, the pipeline spins up Docker-in-Docker, a full daemon running inside a container, with elevated privileges, a huge attack surface, and the existential dread of containers inside containers.

There’s a better way. It’s called skopeo, and it’s been sitting in the container toolbox for years while people kept reaching for the bloated option.

Skopeo is a command-line tool for working with container images and registries. It copies images between any combination of registries, OCI archives, Docker archives, and local container storage. It inspects image metadata without pulling the full image. It syncs entire registry namespaces. It doesn’t need a Docker daemon. It doesn’t need root. Your 2 AM self will appreciate it.


Installing Skopeo

Most Linux distros have it packaged:

Terminal window
# Fedora / RHEL / CentOS
sudo dnf install skopeo
# Debian / Ubuntu
sudo apt install skopeo
# Arch
sudo pacman -S skopeo

On macOS via Homebrew:

Terminal window
brew install skopeo

Or grab a static binary from the GitHub releases page if you want it in a minimal CI image without package managers.


Inspecting Images Without Pulling Them

The most underrated thing skopeo does: look at an image’s manifest and config without downloading a single layer.

Terminal window
skopeo inspect docker://ghcr.io/your-org/your-app:latest

You get JSON back with the architecture, layers, labels, environment variables, and the creation timestamp. No pull, no storage consumed, no daemon required.

Want to check what tags exist in a registry?

Terminal window
skopeo list-tags docker://ghcr.io/your-org/your-app

That’s it. You can script this, grep it, parse it with jq, whatever you need. Compare that to the Docker CLI workflow: docker pull, stare at progress bars, wait, then finally get what you wanted.

For private registries, pass credentials directly:

Terminal window
skopeo inspect \
--creds username:token \
docker://registry.example.com/myapp:v1.2.3

Or use --authfile to point at an auth.json (more on that in a minute).


Copying Images Between Registries

This is skopeo’s bread and butter. The skopeo copy command moves images between any supported transport without ever touching a local container runtime.

The basic form:

Terminal window
skopeo copy \
docker://source-registry.example.com/myapp:v1.2.3 \
docker://prod-registry.example.com/myapp:v1.2.3

Staging to prod, different credentials on each side? No problem:

Terminal window
skopeo copy \
--src-creds staging_user:staging_token \
--dest-creds prod_user:prod_token \
docker://staging.registry.io/myapp:latest \
docker://prod.registry.io/myapp:latest

Skopeo handles the authentication per-side, copies layer by layer, and only transfers what’s needed. You’re not rebuilding, you’re not re-tagging locally, you’re not pulling a 2 GB image onto your CI runner.

Transport combos that actually work

The docker:// prefix is just one of several transports. Here’s the full picture:

TransportExampleUse case
docker://docker://ghcr.io/org/app:tagAny live registry
oci:oci:./myapp-oci-dirOCI image layout on disk
docker-archive:docker-archive:./myapp.tardocker save tarball
containers-storage:containers-storage:localhost/myapp:tagPodman / buildah local storage
dir:dir:./myapp-dirRaw directory format (debug use)

So you can do things like:

Terminal window
# Save an image to a tarball for offline transfer
skopeo copy \
docker://ghcr.io/org/myapp:v2.0.0 \
docker-archive:/tmp/myapp-v2.tar
# Load that tarball into a Podman store on an air-gapped box
skopeo copy \
docker-archive:/tmp/myapp-v2.tar \
containers-storage:localhost/myapp:v2.0.0

That last pattern is huge for air-gapped environments. More on that below.


Authentication: The auth.json Pattern

Skopeo uses the same auth.json format as Podman and buildah, not Docker’s config.json (though it can read that too). The standard location is $XDG_RUNTIME_DIR/containers/auth.json or ~/.config/containers/auth.json.

You can log in with:

Terminal window
skopeo login registry.example.com

Or generate the file programmatically in CI:

Terminal window
skopeo login \
--username "$REGISTRY_USER" \
--password "$REGISTRY_TOKEN" \
ghcr.io

In CI pipelines, it’s cleaner to drop a temp authfile and point at it explicitly:

Terminal window
# Create a temp auth file for this run
cat > /tmp/auth.json <<EOF
{
"auths": {
"ghcr.io": { "auth": "$(echo -n "$GHCR_USER:$GHCR_TOKEN" | base64)" },
"registry.example.com": { "auth": "$(echo -n "$REG_USER:$REG_PASS" | base64)" }
}
}
EOF
skopeo copy \
--authfile /tmp/auth.json \
docker://ghcr.io/org/app:v1 \
docker://registry.example.com/app:v1

Syncing Registries for Air-Gapped Mirrors

Got an air-gapped environment or an internal mirror you need to keep updated? skopeo sync handles bulk image replication.

Sync everything under a prefix from one registry to another:

Terminal window
skopeo sync \
--src docker \
--dest docker \
ghcr.io/my-org \
internal-registry.corp.example.com/mirror

Or use a YAML manifest to control exactly which images and tags get synced:

sync-manifest.yml
ghcr.io:
images:
my-org/api-gateway:
- v1.4.0
- v1.4.1
- latest
my-org/worker:
- v2.0.0
registry.k8s.io:
images:
pause:
- "3.9"
coredns/coredns:
- v1.11.1
Terminal window
skopeo sync \
--src yaml \
--dest docker \
sync-manifest.yml \
internal-registry.corp.example.com/mirror

This is how you build a proper offline mirror: generate the manifest, sync to a directory archive, ship that archive to the air-gapped network, sync from the archive to the internal registry. No daemon required at any stage.


Image Promotion in CI Without Docker-in-Docker

Here’s the practical payoff. You’ve got a staging registry and a prod registry. When a build passes validation, you want to promote the exact image (not rebuild it) from staging to prod. This is how you do it without Docker-in-Docker:

.github/workflows/promote.yml
name: Promote Image to Production
on:
workflow_dispatch:
inputs:
image_tag:
description: "Tag to promote (e.g. v1.4.2)"
required: true
jobs:
promote:
runs-on: ubuntu-latest
steps:
- name: Install skopeo
run: |
sudo apt-get install -y skopeo
- name: Copy image from staging to prod
env:
STAGING_TOKEN: ${{ secrets.STAGING_REGISTRY_TOKEN }}
PROD_TOKEN: ${{ secrets.PROD_REGISTRY_TOKEN }}
run: |
skopeo copy \
--src-creds "${{ github.actor }}:${STAGING_TOKEN}" \
--dest-creds "${{ github.actor }}:${PROD_TOKEN}" \
docker://staging.ghcr.io/${{ github.repository }}:${{ inputs.image_tag }} \
docker://ghcr.io/${{ github.repository }}:${{ inputs.image_tag }}
- name: Also tag as latest in prod
env:
PROD_TOKEN: ${{ secrets.PROD_REGISTRY_TOKEN }}
run: |
skopeo copy \
--src-creds "${{ github.actor }}:${PROD_TOKEN}" \
--dest-creds "${{ github.actor }}:${PROD_TOKEN}" \
docker://ghcr.io/${{ github.repository }}:${{ inputs.image_tag }} \
docker://ghcr.io/${{ github.repository }}:latest

No docker login. No docker pull. No Docker socket mounted into the runner. No privileged containers. Just skopeo doing exactly what you asked, on a normal unprivileged runner.


TLS Gotchas

Self-signed certs on your internal registry? You’ve got two options, and only one of them is acceptable in production.

The acceptable option, install the CA cert on your runner and let skopeo trust it normally.

The “I’ll fix it later” option (only use in dev):

Terminal window
skopeo copy \
--src-tls-verify=false \
--dest-tls-verify=false \
docker://dev-registry.local/app:test \
docker://staging-registry.local/app:test

--tls-verify=false is available on inspect, copy, sync, and most other subcommands. Don’t ship that to production. Do ship it to your docker-compose.yml for local dev and add a comment so future you doesn’t wonder why it’s there.


Signature Inspection and Cosign Compatibility

If your org is signing images with Cosign (the Sigstore toolchain), you can inspect signatures without pulling the image:

Terminal window
skopeo inspect \
--raw \
docker://ghcr.io/org/app:v1.2.3

The --raw flag gives you the raw manifest JSON, which includes OCI annotations where Cosign attaches signature and attestation references. You can also check the referrers API (OCI 1.1+) to see what attestations are attached to an image digest.

For full Cosign verification (checking the signature against a public key or keyless identity), you still need cosign verify. Skopeo complements Cosign, it doesn’t replace it. Think of skopeo as the transport layer and Cosign as the trust layer.


How It Compares: regclient and crane

If skopeo isn’t scratching your itch, two other daemonless tools cover similar ground:

crane (from github.com/google/go-containerregistry), Go binary, great for scripting, the crane mutate and crane append subcommands let you modify images in-place without rebuilding. Excellent for injecting labels or config changes post-build.

regclient (regctl), more feature-complete for registry administration, supports cross-repo blob mounting, has a richer tag management interface, and handles rate limiting more gracefully against Docker Hub.

Pick skopeo when you want a mature, well-packaged tool that’s already in your distro’s repos and integrates cleanly with the rest of the containers/ ecosystem (Podman, buildah). Pick crane when you’re writing Go tooling or need to mutate image metadata. Pick regclient when you’re doing heavy registry administration at scale.


Multi-Architecture Images

Here’s a thing that trips people up: skopeo copy by default copies the manifest that matches the host architecture. If you’re building multi-arch images (amd64 + arm64) and promoting them, you want to preserve the full manifest list.

Pass --all to copy every architecture in the manifest list:

Terminal window
skopeo copy \
--all \
--src-creds "$USER:$SRC_TOKEN" \
--dest-creds "$USER:$DEST_TOKEN" \
docker://staging.registry.io/myapp:v1.4.0 \
docker://prod.registry.io/myapp:v1.4.0

Without --all, you’ll promote one arch and silently drop the others. Your arm64 Raspberry Pi cluster will pull successfully and then your amd64 servers will get a very confusing error. Ask me how I know.

You can verify what architectures exist before and after:

Terminal window
skopeo inspect --raw docker://prod.registry.io/myapp:v1.4.0 | \
jq '.manifests[] | {arch: .platform.architecture, os: .platform.os, digest: .digest}'

Deleting Tags From a Registry

Most registries support tag deletion via the API, and skopeo can do it without pulling anything:

Terminal window
skopeo delete \
--creds "$USER:$TOKEN" \
docker://registry.example.com/myapp:old-feature-branch

This sends a DELETE to the registry manifest endpoint. Not all registries support it (Docker Hub has restrictions on free tier), but GCR, ECR, GHCR, and most self-hosted registries (Harbor, Nexus, Gitea) handle it fine.

Pair this with skopeo list-tags and you’ve got a janitor script to prune old branch tags:

Terminal window
# Delete all tags older than a certain prefix (be careful with this)
skopeo list-tags docker://registry.example.com/myapp | \
jq -r '.Tags[] | select(startswith("pr-"))' | \
while read tag; do
echo "Deleting: $tag"
skopeo delete \
--creds "$USER:$TOKEN" \
"docker://registry.example.com/myapp:${tag}"
done

Yes, this is a shell loop. No, it’s not fancy. Yes, it works, and it doesn’t require a Docker daemon, a registry UI, or a support ticket.


The Short Version

Skopeo does container image work that most teams route through a full Docker daemon out of habit, not necessity. You get:

Your image promotion CI step doesn’t need Docker-in-Docker. It doesn’t need elevated privileges. It doesn’t need a daemon running. It needs skopeo, a token for each registry, and about four lines of YAML.

That’s the kind of simplification that makes on-call pages less frequent and pipelines less fragile. Ship it.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Self-Hosted CAPTCHA Alternatives in 2026
Next Post
Meshtastic vs MeshCore in 2026

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts