image-cleanup
GitLab Container Registry retains every push by default. Repos that publish on every main commit (catalog ci-* images, app image rebuilds) accumulate one image per push indefinitely. The cleanup policy keeps the registry footprint bounded:
- Keep all release/promoted-tag images (
vX.Y.Z,X.Y.Z, with optional pre-release/build suffixes) and their cosign signatures. - Keep the last N short-sha builds (default: 10).
- Drop everything older.
The policy is configured at the project level via GitLab's UI or API.
Enabled is not sufficient. A cleanup policy with enabled: true
and a name_regex_keep that doesn't match the shape of the tags the
project actually publishes is worse than no policy: it looks
conformant, GitLab runs it on schedule, and it deletes the images the
project meant to keep. enabled == true alone cannot catch this class
of bug; only checking the regex against real tags can. See
./check.sh for the incident this generalizes from.
The estate template
^(latest|v?\d+\.\d+\.\d+([-.][\w.-]+)?|sha256-[a-f0-9]{64}(\.(sig|att))?)$
Applied as name_regex_keep across 14 projects as of 2026-08, widened
2026-09-28 for the referrers-fallback shape below. Each alternation
exists for a specific, load-bearing reason:
v?: the estate is deliberately split between v-prefixed (v2.11.4) and bare (2.11.4) tag conventions across different projects. The?makes the pattern work for either without a per-project fork.([-.][\w.-]+)?: the promotion-suffix group. A tag isn't always a cleanX.Y.Z: promoted/repromoted builds carry suffixes like-2(v2.11.4-2, the caddy shape), and pre-release or build metadata looks like-rc.1or+build.5. Without this group, a keep-pattern anchored on bareX.Y.Zmatches the first promotion of a version and silently drops every repromotion after it, which is the exact caddy failure mode, since GitLab's cleanup job doesn't care that a tag is "basically a release," only whether the regex matches the literal string.sha256-[a-f0-9]{64}(\.(sig|att))?: cosign keyless-signing artifacts, both shapes cosign has used. Classic cosign publishes the signature and the SLSA attestation as their own sibling registry tags (sha256-<digest>.sig,sha256-<digest>.att), not annotations on the image tag. Current cosign instead publishes a single OCI 1.1 referrers-fallback tag — a baresha256-<digest>naming an image index whose entry carries the signature/attestation as an OCI artifact — whenever the registry doesn't serve the Referrers API, which is every GitLab registry in the estate. Neither shape looks like a version tag, so a keep-pattern written only against version-looking tags never matches either: they age out on the same schedule as short-sha builds, silently detaching signatures and attestations from images that are otherwise still kept. This is easy to miss in review because the image tag looks protected; the signature tag next to it is not. The digest group is{64}, not+: a sha256 hex digest is always exactly 64 characters, and nothing else in the estate's own pipelines tags an imagesha256-<hex>for any other reason, so anchoring the length keeps the alternation from ever protecting a tag that only happens to start withsha256-. othala's registry lost the referrers-fallback signature for v0.4.0, v0.5.0 and v0.6.0 to the narrower pattern before this widening; v0.6.1 and v0.6.2 (verified present, 2026-09-28) were next on its 7-day cleanup cadence.latest: the floating pointer tag, kept unconditionally soimage:latestalways resolves.
Adapting the template. When a project needs its own carve-outs,
add alternations rather than replacing the shape above: stable and
any project-specific keep-.* prefix convention (several cairn/
and immutable/ projects use keep-<label> to pin an image outside
the normal rotation) both compose cleanly inside the same
alternation group, e.g.:
^(latest|stable|keep-.*|v?\d+\.\d+\.\d+([-.][\w.-]+)?|sha256-[a-f0-9]{64}(\.(sig|att))?)$
What this standard checks
Generated from ./check.sh, in the order the check
runs them.
| assertion | target | pattern or value | note |
|---|---|---|---|
| applies at all, or stops the run | *Containerfile* or *Dockerfile* or .gitlab-ci.yml:gitlab\.com/dunn\.dev/pipeline/container-image@ |
a repo that pushes no image has an empty registry, so a cleanup policy has nothing to keep or delete; governs projects that push images to the container registry | |
| GitLab setting reads | container_expiration_policy.enabled |
true |
with no policy running, every short-sha build stays in the registry forever |
| registry cleanup covers live tags | container_expiration_policy.name_regex_keep |
matches every release and cosign tag, and not every tag | a keep-regex that matches no live tag deletes the images production is running |
Two shapes sit outside the coverage assertion rather than failing it. A
policy deliberately left enabled: false skips coverage entirely, an
operator decision answered by a deviation against the enabled assertion
rather than by a red check. A repository holding exactly one tag (the
pinned vendor mirrors, 36 of them in dunn.dev/images) cannot
accumulate the garbage the assertion exists to catch.
How to satisfy
GitLab UI: Project → Deploy → Container Registry → Cleanup policy. Set: enabled, keep the N most recent tags, keep tags matching the estate template above, remove tags older than 30 days.
Or via API:
glab api -X PUT projects/${PROJECT_ID}/container_expiration_policy \
-f enabled=true \
-f keep_n=10 \
-f name_regex_keep='^(latest|v?\d+\.\d+\.\d+([-.][\w.-]+)?|sha256-[a-f0-9]{64}(\.(sig|att))?)$' \
-f older_than=30d
After setting it, verify against the project's real tags before trusting it. A regex that looks right by inspection is exactly how the caddy incident happened:
glab api "projects/${PROJECT_ID}/registry/repositories" | \
jq -r '.[].id' | while read -r rid; do
glab api "projects/${PROJECT_ID}/registry/repositories/${rid}/tags" --paginate --output ndjson
done | jq -r '.name'
Every release-shaped tag and every sha256-<64hex> tag — bare or
.sig/.att-suffixed — in that output must match name_regex_keep,
or it will be pruned on the next cleanup run.
Deviations
A deviation is for a project with a registry that does not meet an
assertion; a repo that pushes no image reports N/A instead.
curl https://reference.dunn.dev/standards/image-cleanup/index.md
check conformance
just -f ~/gitlab.com/dunn.dev/reference/justfile check