Docker & ContainersXIII · RegistriesRetention
Retention policies — keeping your registry from filling the disk
What you'll learn
- Explain why deleting a tag frees no disk and what does
- Run Distribution garbage collection with the read-only window it requires
- Predict which images a retention rule will delete before it runs
- Recognise a corrupted manifest caused by a concurrent push during GC
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
A registry stores images; images are composed of layers; layers take disk. Without retention, the registry fills.
That much is obvious. What is not obvious — and what makes registry retention different from every other kind of cleanup — is that deleting things does not free space. There are two separate operations and conflating them is why registries run out of disk while their operators are confident they cleaned up.
Two operations, not one
Deleting a tag or a manifest removes a reference. It rewrites or removes a small link file in the naming tree. Disk usage does not change measurably.
Garbage collection reclaims bytes. It walks the naming tree, builds the set of blobs still reachable, and deletes every blob not in that set.
Nothing is freed until the second step runs. A team can delete a thousand tags, watch the UI empty out, and find the disk exactly as full as it was.
Deleting a manifest
Deletion is by digest only. The specification is explicit: “a manifest
can only be deleted by digest.” A successful delete returns 202 Accepted.
REGISTRY=registry.example.com
REPO=myorg/myapp
TAG=release-2026-06
ACCEPT='application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json'
DIGEST=$(curl -fsSI -H "Accept: $ACCEPT" \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
| awk 'tolower($1) == "docker-content-digest:" {print $2}' | tr -d '\r')
echo "$TAG -> $DIGEST"release-2026-06 -> sha256:0000000000000000000000000000000000000000000000000000000000000000Illustrative output
REGISTRY=registry.example.com
REPO=myorg/myapp
DIGEST=sha256:REPLACE_ME
curl -fsS -X DELETE -o /dev/null -w '%{http_code}\n' \
"https://$REGISTRY/v2/$REPO/manifests/$DIGEST"202Illustrative output
A 405 here means storage.delete.enabled is off. A 404 means the
digest was already gone. Only 202 means anything happened.
Distribution garbage collection
This is the step that frees disk, and it is the one with the sharp edge.
docker exec registry \
registry garbage-collect --dry-run /etc/distribution/config.ymlmyorg/myapp
myorg/myapp: marking manifest sha256:0000...0000
myorg/myapp: marking blob sha256:1111...1111
myorg/myapp: marking blob sha256:2222...2222
12 blobs marked, 47 blobs and 0 manifests eligible for deletion
blob eligible for deletion: sha256:3333...3333
blob eligible for deletion: sha256:4444...4444Illustrative output
The flags, from the upstream documentation:
| Flag | Effect |
|---|---|
--dry-run | Runs mark and sweep without deleting anything |
--delete-untagged | Also removes manifests no tag currently references |
--quiet | Suppresses output |
The config file path is a positional argument, not a flag. Omitting it is the most common way this command fails to run at all.
The read-only window
storage:
delete:
enabled: true
maintenance:
readonly:
enabled: trueThe equivalent environment variable is
REGISTRY_STORAGE_MAINTENANCE_READONLY_ENABLED=true, which is usually
the easier lever for a containerised registry because it means
restarting the container with one extra flag rather than templating a
config file.
- Announce the window. Pushes will fail; pulls will not. CI needs to know.
- Record the current disk usage so you can state afterwards what the run actually reclaimed.
- Collect the digests in use across the fleet if you intend to pass
--delete-untagged. - Restart the registry with readonly enabled, and confirm a test push is refused.
- **Run with
--dry-runfirst** and read the eligible-for-deletion list. If a digest you care about appears there, stop. - Run for real.
- Restart without readonly, and confirm a test push succeeds.
- Pull one known image end to end — not a manifest HEAD, a full pull — to prove blobs are intact.
Step 8 is the one that catches a bad run, and it is the one everybody skips because the GC output said success. A manifest fetch will pass against a registry whose blobs were destroyed; only a pull that transfers layers proves otherwise.
IMG=registry.example.com/myorg/myapp:release-2026-08
docker image rm "$IMG" 2>/dev/null || true
if docker pull "$IMG"; then
echo 'OK: manifest and all blobs present'
else
echo 'FAIL: registry is missing blobs for this image' >&2
fiOK: manifest and all blobs presentIllustrative output
Harbor: retention and GC are separate jobs
Harbor layers policy on top of the same underlying storage, and it keeps the same two-step separation — with one important difference worth knowing.
Tag retention rules are configured per project, in the UI or the
API, not in harbor.yml. Each rule keeps artefacts matching one of
five criteria:
- retain the most recently pushed N artefacts
- retain the most recently pulled N artefacts
- retain artefacts pushed within the last N days
- retain artefacts pulled within the last N days
- retain always
Up to 15 rules per project. Rules can be scoped by repository and tag patterns, and Harbor offers a dry run so you can see what a rule would delete before it deletes it. Use it. The “most recently pulled” and “pulled within N days” criteria are the ones that behave most like an operator’s intent, because they track use rather than age.
Garbage collection is a separate operation, and Harbor is explicit about why: “When you delete images from Harbor, space is not automatically freed up. You must run garbage collection to free up space by removing blobs that are no longer referenced by a manifest from the file system.”
The difference from raw Distribution is worth naming, because it changes your maintenance planning: Harbor states that it “runs garbage collection without interrupting your ability to continue use Harbor, for example you are able to push, pull, or delete artifacts while garbage collection is running.” You do not need the read-only window that Distribution requires.
Do not generalise that to Distribution. They are different implementations of the same idea, and the raw registry’s documentation says the opposite.
A retention policy that survives contact
KEEP:
- every digest currently running anywhere in the estate (collected, not assumed)
- the most recent 10 artefacts per repository
- anything pulled in the last 90 days
- anything tagged release-* (explicit, unbounded)
DELETE:
- everything else, monthly
RECLAIM:
- garbage collection, monthly, immediately after the retention pass,
with the registry read-only for the duration
VERIFY:
- a full docker pull of the current release, from a host with no
local copy, after every GC run
Four lines of intent, one schedule, one assertion. The unbounded
release-* rule is deliberate: an unbounded keep rule wastes disk,
which is recoverable, and a too-aggressive delete rule destroys an
artefact, which is not.
Knowledge check
Knowledge check · 5 questions
Q1. You delete six months of tags from a Distribution registry. Every request returns 202. `df` shows no change. Why?
Q2. Why must a Distribution registry be read-only while garbage collection runs?
Q3. Which are true of `--delete-untagged` on a registry serving digest-pinned deployments? Select all that apply.
Q4. Harbor requires the same read-only window as raw Distribution when running garbage collection.
Q5. The specific defect garbage collection can cause is a manifest that still resolves while its layer blobs are gone, so only a full pull — not a manifest HEAD — proves an image survived.
Passing score: 75%. Answers are checked in this browser.