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:
# Fedora / RHEL / CentOSsudo dnf install skopeo
# Debian / Ubuntusudo apt install skopeo
# Archsudo pacman -S skopeoOn macOS via Homebrew:
brew install skopeoOr 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.
skopeo inspect docker://ghcr.io/your-org/your-app:latestYou 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?
skopeo list-tags docker://ghcr.io/your-org/your-appThat’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:
skopeo inspect \ --creds username:token \ docker://registry.example.com/myapp:v1.2.3Or 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:
skopeo copy \ docker://source-registry.example.com/myapp:v1.2.3 \ docker://prod-registry.example.com/myapp:v1.2.3Staging to prod, different credentials on each side? No problem:
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:latestSkopeo 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:
| Transport | Example | Use case |
|---|---|---|
docker:// | docker://ghcr.io/org/app:tag | Any live registry |
oci: | oci:./myapp-oci-dir | OCI image layout on disk |
docker-archive: | docker-archive:./myapp.tar | docker save tarball |
containers-storage: | containers-storage:localhost/myapp:tag | Podman / buildah local storage |
dir: | dir:./myapp-dir | Raw directory format (debug use) |
So you can do things like:
# Save an image to a tarball for offline transferskopeo 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 boxskopeo copy \ docker-archive:/tmp/myapp-v2.tar \ containers-storage:localhost/myapp:v2.0.0That 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:
skopeo login registry.example.comOr generate the file programmatically in CI:
skopeo login \ --username "$REGISTRY_USER" \ --password "$REGISTRY_TOKEN" \ ghcr.ioIn CI pipelines, it’s cleaner to drop a temp authfile and point at it explicitly:
# Create a temp auth file for this runcat > /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:v1Syncing 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:
skopeo sync \ --src docker \ --dest docker \ ghcr.io/my-org \ internal-registry.corp.example.com/mirrorOr use a YAML manifest to control exactly which images and tags get synced:
ghcr.io: images: my-org/api-gateway: - v1.4.0 - v1.4.1 - latest my-org/worker: - v2.0.0registry.k8s.io: images: pause: - "3.9" coredns/coredns: - v1.11.1skopeo sync \ --src yaml \ --dest docker \ sync-manifest.yml \ internal-registry.corp.example.com/mirrorThis 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:
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 }}:latestNo 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):
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:
skopeo inspect \ --raw \ docker://ghcr.io/org/app:v1.2.3The --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:
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.0Without --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:
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:
skopeo delete \ --creds "$USER:$TOKEN" \ docker://registry.example.com/myapp:old-feature-branchThis 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:
# 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}" doneYes, 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:
skopeo inspect: metadata without pulling layers, list tags without touching the local storeskopeo copy: registry-to-registry, registry-to-archive, archive-to-storage, any combo, with--allfor multi-archskopeo sync: bulk replication with a YAML manifest, built for air-gapped setupsskopeo delete: tag cleanup without a UI or a pull request to the registry teamskopeo login: standard auth.json credential management that works across the whole containers/ ecosystem
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.