Your Registry Is a Landfill. Fix It.
If you’ve been running containers for more than six months, you already know the feeling: open Harbor or ghcr.io, scroll the tag list, and discover 847 tags for a single service. Half of them are SHA digests from a CI run that failed at 2 AM on a Tuesday. The other half are latest, latest-2, latest-old, and whatever dev-test-final-FINAL3 is.
Skopeo is great, if you haven’t read that one yet, go do that first, it’s the prereq here, but it’s mostly a copy-and-inspect tool. It doesn’t have opinions about your tag retention policy. It won’t walk your registry and prune untagged manifests on a schedule. It won’t declaratively mirror three registries into one with a YAML file.
That’s where regclient comes in. It’s a Go-based toolkit with three binaries: regctl for interactive registry surgery, regbot for declarative lifecycle policies, and regsync for cross-registry mirroring. Think of Skopeo as the scalpel and regclient as the whole OR suite.
And yes, zero Docker daemon required. Just you, the OCI registry API, and a terminal.
The Three Tools
regctl, Interactive Registry Surgery
regctl is the CLI you’ll reach for when you need to inspect, copy, tag, or manipulate images right now. Install it:
# Linux amd64curl -L https://github.com/regclient/regclient/releases/latest/download/regctl-linux-amd64 \ -o /usr/local/bin/regctlchmod +x /usr/local/bin/regctl
# Or via Docker (no install needed)docker run --rm ghcr.io/regclient/regctl:latest --helpBasic inspection, no daemon, no pull, just the manifest:
# Inspect image config without pullingregctl image inspect ghcr.io/myorg/myapp:v1.2.3
# Get the digest for a tagregctl image digest ghcr.io/myorg/myapp:latest
# Copy between registriesregctl image copy ghcr.io/myorg/myapp:v1.2.3 registry.internal/myapp:v1.2.3
# List all tagsregctl tag ls ghcr.io/myorg/myappThe image inspect output is the full OCI config JSON, layers, env vars, entrypoints, labels. Same data you’d get from docker inspect after a pull, but without dragging gigabytes of layers onto your machine first.
Multi-arch manifest list inspection is where regctl gets genuinely useful:
# Inspect the manifest index (multi-arch)regctl manifest get ghcr.io/myorg/myapp:v1.2.3
# Show per-platform digestsregctl manifest get --format '{{range .Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}: {{.Digest}}\n{{end}}' \ ghcr.io/myorg/myapp:v1.2.3You get the full platform breakdown, linux/amd64, linux/arm64, linux/arm/v7, with individual digests. Try doing that with just docker manifest inspect and enjoy the wall of JSON you have to grep through.
Image Promotion in CI
Here’s a real CI pattern: build on every commit, promote to staging on merge to main, promote to prod on a semver tag. regclient handles the promotion step without rebuilding anything:
# Promote: copy the exact SHA from ci to staging (no rebuild)DIGEST=$(regctl image digest ghcr.io/myorg/myapp:${COMMIT_SHA})regctl image copy ghcr.io/myorg/myapp@${DIGEST} ghcr.io/myorg/myapp:staging
# Tag it for prod on releaseregctl image copy ghcr.io/myorg/myapp@${DIGEST} ghcr.io/myorg/myapp:${RELEASE_TAG}regctl image copy ghcr.io/myorg/myapp@${DIGEST} ghcr.io/myorg/myapp:latestThe key here is copying by digest, not by tag. You’re guaranteeing the exact same image bytes move from CI → staging → prod. No “it worked in staging” nonsense because the tag silently pointed somewhere else.
regbot, Scripted Lifecycle Policies
This is the one that makes the landfill problem go away. regbot takes a YAML config where each policy is a Lua script, and that scripting model is the thing to understand up front. There’s no magic action: keep count: 5 declarative shorthand; you write a little Lua that lists tags and decides what to delete. More verbose than a one-liner, but you can express literally any retention rule you can think of.
version: 1
defaults: parallel: 4 interval: 24h
creds: - registry: ghcr.io user: "{{ env \"GHCR_USER\" }}" pass: "{{ env \"GHCR_TOKEN\" }}"
scripts: - name: Prune myapp CI debris timeout: 10m script: | -- Keep anything tagged prod/staging/latest, delete the rest -- once it's older than the 5 most recent semver releases. local repo = "ghcr.io/myorg/myapp" local tags = tag.ls(repo) table.sort(tags) local kept = 0 for i = #tags, 1, -1 do local t = tags[i] if t == "prod" or t == "staging" or t == "latest" then -- always keep these elseif t:match("^v%d") and kept < 5 then kept = kept + 1 -- keep newest 5 semver tags else log("deleting " .. repo .. ":" .. t) tag.delete(repo .. ":" .. t) end endRun it:
# Dry run first — alwaysregbot once --dry-run -c regbot.yml
# Live runregbot once -c regbot.yml
# Or as a scheduled daemon (runs the schedule defined in YAML)regbot server -c regbot.ymlThe --dry-run flag deserves emphasis, it disables every write to the registry, so your tag.delete() calls just log what they would do. Run it at least once before you let regbot anywhere near prod. The “keep prod/staging/latest + keep the newest 5 semver tags” pattern above is the one you want for 90% of services, it keeps your important tags, keeps a small rollback window of versioned releases, and cleans up the CI debris automatically.
regsync, Cross-Registry Mirroring
regsync solves the problem of: “I want a local mirror of upstream images so my cluster doesn’t pound Docker Hub rate limits, and I want it to stay current automatically.”
version: 1
creds: - registry: registry.internal hostname: registry.internal tls: disabled
sync: - source: library/nginx target: registry.internal/mirror/nginx type: repository tags: allow: - "^1\\.2[0-9]" # Only nginx 1.20+ - "latest" deny: - ".*-alpine$" # Skip alpine variants (we use debian)
- source: ghcr.io/myorg/myapp target: registry.internal/prod/myapp type: repository tags: allow: - "^v[0-9]" # Only semver tagsRun it on a cron or as a daemon:
# One-shot syncregsync once -c regsync.yml
# Daemon mode — syncs on the schedule defined in the YAMLregsync server -c regsync.ymlThe tags.allow / tags.deny filters use Go regexes. They’re your rate-limit shield, you’re not pulling every tag from Docker Hub, just the ones your org actually uses.
Handling OCI Artifacts
Modern registries store more than just container images, OCI artifacts include Helm charts, SBOMs, Cosign signatures, and WASM modules. regctl handles them natively:
# List referrers (signatures, SBOMs) attached to an imageregctl artifact list ghcr.io/myorg/myapp:v1.2.3
# Copy image + all attached artifactsregctl image copy --referrers \ ghcr.io/myorg/myapp:v1.2.3 \ registry.internal/myapp:v1.2.3The --referrers flag is the key one. If you’re using Cosign to sign your images, a plain regctl image copy without --referrers will leave the signatures behind. Your Sigstore policy enforcement on the destination registry will fail, and you’ll spend 20 minutes convinced your signing workflow is broken when it’s actually just the copy step.
regclient vs Skopeo vs Crane
Honest comparison, no hype:
| Tool | Strengths | Weaknesses |
|---|---|---|
| Skopeo | Universal copy/inspect, great distros support | No lifecycle policies, no scheduling |
| regctl | Manifest surgery, multi-arch, OCI artifacts | More complex, less distro packaging |
| Crane (Google) | Minimal, fast, good Go library | No regbot/regsync equivalents |
Skopeo is the general-purpose utility. It’s in every package manager, it copies between virtually every registry format including docker-daemon and dir/. It’s the right default tool for “I need to copy this image once.”
regclient (regctl/regbot/regsync) is the registry operations toolkit. If you need lifecycle policies, scheduled mirroring, or you’re building registry automation workflows, it’s the better fit. The scriptable regbot and YAML-driven regsync configs are something Skopeo doesn’t have at all.
Crane (from Google’s go-containerregistry) sits in the middle, it’s a solid CLI and an even better Go library for building registry tooling. If you’re writing Go code that needs to talk to registries, crane / go-containerregistry is the library you want. As a standalone CLI it’s more minimal than either Skopeo or regclient.
Pick the one that fits the job. They’re not mutually exclusive, regbot for scheduled cleanup, Skopeo in your CI scripts for one-off copies, Crane if you’re writing registry automation in Go.
Authentication Setup
All three tools read credentials from the same config file:
# Log in (stores creds in ~/.config/regclient/config.json)regctl registry login ghcr.io
# Or set per-registry in configregctl registry set registry.internal --tls disabled
# Check what registries are configuredregctl registry configFor CI, pass creds via environment variables in the YAML ({{ env "GHCR_TOKEN" }} as shown in the regbot config above) or via --user/--pass flags. Don’t hardcode credentials in YAML files that get committed. Your future self and your security team will both thank you.
Pruning Untagged Manifests in Harbor
Harbor doesn’t automatically garbage-collect untagged manifests, you end up with a graveyard of anonymous SHA digests consuming storage. regbot can’t delete bytes directly (the registry API only exposes tags and manifests, not storage), but it’s great at the half you actually script: nuking the stale tags that keep those manifests alive. Drop the tags, then let Harbor’s GC reclaim the now-dangling manifests:
version: 1
creds: - registry: harbor.internal user: "{{ env \"HARBOR_USER\" }}" pass: "{{ env \"HARBOR_PASS\" }}"
scripts: - name: Prune stale dev tags timeout: 15m script: | -- Harbor leaves untagged manifests behind; regbot scripts work on -- tags, so the practical move is to drop the stale tags that point -- at them and let Harbor's GC reclaim the now-dangling manifests. local repo = "harbor.internal/project/myapp" for _, t in ipairs(tag.ls(repo)) do if t:match("^dev%-") then log("deleting " .. repo .. ":" .. t) tag.delete(repo .. ":" .. t) end endRun this nightly in a cron job (paired with Harbor’s own GC) and your storage growth goes from “buy more disks” to “actually manageable.”
The Bottom Line
If your registry tag list looks like a crime scene, regclient is the forensic cleanup crew. regctl for hands-on inspection and promotion, regbot for scripted retention policies that run themselves, regsync for mirroring that doesn’t hammer upstream rate limits.
Start with regbot once --dry-run pointed at your worst-offending repo. You’ll immediately see how many tags are deletable. Then automate it. Your 2 AM self, the one who can’t find which of the 400 CI tags is actually running in prod, will appreciate it.
The Docker daemon hasn’t been required for registry operations for years. Act like it.