Keeping self-hosted registry storage growth in check
docker system prune only cleans up locally; the growth of a self-hosted Docker registry with thousands of pushed images is completely unaffected by it. Registry-side garbage collection and thoughtful retention policies are the actual lever against runaway storage consumption.
Table of Contents
- 1. Why docker system prune does not help here
- 2. How a registry stores images internally
- 3. Garbage collection in Docker Distribution in detail
- 4. GC performance and runtime on large registries
- 5. Setting up retention policies for old tags
- 6. Lifecycle policies with cloud registries
- 7. The special case of untagged manifests
- 8. Monitoring storage growth
- 9. Best practices for sustainable registry operations
- 10. Summary
- 11. FAQ
1. Why docker system prune does not help here
A widespread misconception is that docker system prune or docker image prune also reduces the storage used on the registry server. In reality, these commands act exclusively on the local machine or runner where they are executed, cleaning up unused images, stopped containers, and orphaned layers there. The storage of a self-hosted registry, for example based on Docker Distribution, Harbor, or GitLab Container Registry, remains completely unaffected, because the registry is a standalone service with its own storage backend, usually an object store like S3 or a local filesystem.
In an active CI/CD pipeline with several builds a day, each tagged with unique Git SHA or date tags as described in the previous article, a registry without its own cleanup strategy keeps growing continuously, even though the vast majority of these images are never pulled again after a short time. Without a countermeasure, this leads over time to sharply rising storage costs, longer backup times, and in extreme cases to fully saturated storage volumes that can bring registry operations to a complete halt.
2. How a registry stores images internally
A Docker registry does not store images as monolithic files but as a set of content-addressed blobs corresponding to the individual layers and the manifest file, plus a mapping table that maps tags to manifest digests. Multiple tags, or even multiple different images, can share the same blob, for example a common base layer like php:8.4-fpm-alpine, so the registry efficiently occupies storage for identical content only once, regardless of how many tags ultimately point at it.
This exact structure is what makes deletion complicated: simply removing a tag usually only deletes the mapping in the manifest index, not the underlying blobs, because at the moment a tag is deleted the registry cannot readily know whether another tag or another image still references the same blob. This is exactly where actual garbage collection comes in, determining in a separate pass all blobs still referenced and physically removing from disk only the blobs that are genuinely orphaned.
3. Garbage collection in Docker Distribution in detail
The free reference implementation Docker Distribution, on which many self-hosted registries are built, comes with a built-in garbage collector invoked via the registry garbage-collect command inside the registry container. The process consists of two phases: first a mark pass runs, which starting from all still-existing tags recursively marks every referenced manifest and layer digest, followed by a sweep pass that physically deletes all unmarked blobs.
It is important that the registry be run in read-only mode during the garbage collection run, unless the newer version with --dry-run and delete-marker support is used, since otherwise images pushed concurrently could be incorrectly marked as orphaned and deleted. In production setups, the GC run is therefore usually executed during a maintenance window or with the newer delete-marker strategy, which allows safe deletion even while the registry keeps running.
# Garbage collection in Docker Distribution (classic workflow)
docker exec registry bin/registry garbage-collect \
--dry-run /etc/docker/registry/config.yml
# After reviewing the dry run, actually delete
docker exec registry bin/registry garbage-collect \
/etc/docker/registry/config.yml
4. GC performance and runtime on large registries
On a small internal registry with a few hundred images, a garbage collection run usually takes only seconds to a few minutes, but on large registries with tens of thousands of repositories and millions of blobs, the mark pass, which must resolve and recursively walk every manifest individually, can take several hours. If GC runs in read-only mode during that time, it effectively means a multi-hour outage of all push operations, which is hardly acceptable for active CI/CD landscapes.
As a countermeasure, larger deployments usually rely on the delete-marker strategy, which initially only records deletions as a marker and runs the actual physical sweep in a separate, clearly scheduled maintenance window, as well as on an object storage backend such as S3 instead of a local filesystem, since object storage APIs support parallel, batched access to millions of blobs far more efficiently than a single local filesystem with a correspondingly high number of inodes.
5. Setting up retention policies for old tags
Garbage collection alone only physically deletes tags that were already removed; it does not decide which tags should be removed in the first place. That job belongs to a retention policy, defining rules such as: keep the last ten tags per repository, delete tags older than ninety days except the last five SemVer releases, or keep all tags starting with a protected prefix like release-. Without such a policy, the number of tags keeps growing indefinitely, even if the underlying blobs are partially shared through deduplication.
In registries such as Harbor or GitLab Container Registry, retention policies can be defined declaratively via the web UI or a YAML configuration and run automatically on a fixed schedule, usually daily or weekly. With a plain Docker Distribution registry without additional software, however, this logic must be implemented as a script that queries all tags of a repository via the registry HTTP API, applies the rules, and deletes surplus tags before the actual garbage collection run is started afterward.
# Harbor tag retention rule (example configuration)
rules:
- repoMatches: "mironsoft/app"
tagMatches: "sha-*"
retention: "keep the most recently pushed 20 tags"
- repoMatches: "mironsoft/app"
tagMatches: "release-*"
retention: "keep forever"
6. Lifecycle policies with cloud registries
Cloud registries such as AWS ECR, Google Artifact Registry, or Azure Container Registry come with their own lifecycle policy mechanisms that conceptually fulfill the same task as Harbor's retention policies, though configured in a provider-specific way. In ECR, for example, a lifecycle policy is defined as a JSON rule set with priorities that, say, automatically removes untagged images after a certain number of days and limits the number of stored images per tag prefix.
The decisive advantage of these managed solutions is that the actual garbage collection, meaning physically freeing storage after the deletion rules are applied, is carried out automatically by the cloud provider without any manual maintenance effort. With self-hosted registries, on the other hand, the operator must orchestrate both the retention policy and the actual garbage collection run themselves, usually via a cron job or as a periodic CI pipeline step separate from the actual application pipelines.
# Applying an AWS ECR lifecycle policy
aws ecr put-lifecycle-policy \
--repository-name mironsoft/app \
--lifecycle-policy-text '{
"rules": [{
"rulePriority": 1,
"description": "Remove untagged images after 7 days",
"selection": {"tagStatus": "untagged", "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 7},
"action": {"type": "expire"}
}]
}'
7. The special case of untagged manifests
A frequently overlooked detail is that overwriting a tag, for example when app:buildcache is pushed anew on every CI run as described in the article on BuildKit remote cache, does not automatically delete the previous manifest but leaves it in the registry as a so-called untagged or dangling manifest until it is explicitly removed or recognized as orphaned by garbage collection. With frequently changing cache tags or CI tags, this can accumulate a substantial amount of untagged manifests that are still referenced, or only actually removable after a GC run.
For this case, most registry APIs offer an endpoint to list all manifest digests of a repository and compare them against the set of currently assigned tags, so untagged manifests can be specifically identified and explicitly removed via a DELETE request before the next garbage collection run. This explicit cleanup speeds up the subsequent GC run, since fewer manifests need to be considered in the mark pass, and makes overall storage growth more predictable.
8. Monitoring storage growth
Without monitoring, storage growth usually goes unnoticed until the underlying volume actually fills up and pushes start failing, which in an active CI/CD pipeline can lead to a complete outage of all deploys. It therefore makes sense to regularly capture the storage occupied by the registry backend, for example via CloudWatch metrics with ECR, the storage endpoint in Harbor, or a custom script that measures the size of the underlying S3 bucket or filesystem and exports it as a metric to Prometheus or an equivalent system.
In addition to raw storage consumption, it is worth watching the number of tags per repository and their growth rate over time, since a sudden spike can indicate a faulty CI configuration, for example a pipeline that accidentally generates a new unique tag on every run instead of reusing a cache tag. An alert when a threshold for storage consumption or tag count is exceeded gives the operations team enough lead time to adjust retention policies before things become critical.
9. Best practices for sustainable registry operations
In practice, a combination of several measures works well: a clear tagging strategy as described in the previous article that distinguishes between release tags meant to be kept permanently and short-lived CI tags, an automated retention policy that removes short-lived tags after a defined period, a regular, ideally weekly garbage collection run during a maintenance window or with delete-marker support during live operation, and continuous monitoring of storage consumption with alerting on anomalies.
Anyone who plans these building blocks from the start, rather than retrofitting them only after a storage volume has already filled up, avoids not only unnecessary operating costs but also acute registry outages that in the worst case can block an entire team's deployment capability. The table below compares the key cleanup mechanisms and their respective role.
| Mechanism | What gets removed | Trigger | Typical tool |
|---|---|---|---|
| docker system prune | Local, unused images/containers | Manual on host/runner | Docker CLI |
| Retention policy | Surplus or stale tags | Schedule or rule set | Harbor, ECR, GitLab Registry |
| Garbage collection | Physically orphaned blobs | After tag deletion, periodic | registry garbage-collect, cloud GC |
| Manual manifest cleanup | Untagged, dangling manifests | Ad hoc or before a GC run | Registry HTTP API, custom script |
Mironsoft
Container infrastructure, CI pipelines and deployment automation
Docker setups that hold up across the team and in production?
We review existing Dockerfiles and Compose stacks for security gaps, bloated images and fragile build pipelines, then build a container infrastructure that builds fast, runs securely and stays understandable across the team.
Dockerfile Review
Systematically optimizing multi-stage builds, layer caching and image size.
Security Audit
Hardening container isolation, secrets handling and image scanning against real attack surfaces.
CI/CD Integration
Building build pipelines, registries and deployment strategies for reproducible releases.
10. Summary
Registry Garbage Collection: Key Takeaways
Core problem
docker system prune only acts locally; the registry's own storage growth is unaffected by it.
Two steps needed
First a retention policy decides which tags go, then garbage collection physically frees the storage.
Cloud vs. self-hosted
Cloud registries automate GC; self-hosted registries need their own cron jobs for it.
Monitoring matters
Watch storage consumption and tag counts to catch misconfigurations early.