Skip to main content

Configure Retention Policies

Set up automated cleanup of old container images using CEL-based retention policies.

Prerequisites​

  • Angos running
  • update_pull_time = true if using pull-based retention (image.last_pulled_at, top_pulled); prune refuses to start when such rules are configured without it, since pull times would never be recorded and actively pulled images would be deleted

How Retention Works​

Retention policies define which images to keep. Images not matching any rule are eligible for deletion when running angos prune.

Protected manifests are pinned by a parent and reclaimed with it rather than judged on their own:

  • Child manifests of a multi-platform index, while the index resolves
  • Referrers (signatures, SBOMs, scan reports) of a subject, while the subject resolves, except a scan report a newer angos report has superseded

An image is judged by the rules whatever refers to it. Once it is deleted, its referrers have no subject left and are judged as untagged content in the same run. A scan report angos attached that a newer one has superseded is judged as untagged content while its image lives, so a report refresh leaves as much report history as the rules keep.

Retention subjects are tagged manifests, untagged (orphan) manifests, and grant-only blobs. A grant-only blob is one a namespace uploaded whose manifest never landed (a lost replication race, a dead-lettered push, or an abandoned client); it is evaluated like any untagged content, with no tag and pushed_at set to the upload time, once it is past prune's -u in-flight window. A time-based rule such as image.pushed_at > now() - days(7) therefore also bounds how long stranded uploads linger. With no policies configured, untagged manifests and grant-only blobs are both retained.


Basic Configuration​

Global Policy​

[global]
update_pull_time = true # Required for pull-based retention

[global.retention_policy]
rules = [
'image.tag == "latest"',
'image.pushed_at > now() - days(30)'
]

Repository Policy​

[repository."production".retention_policy]
rules = [
'image.tag == "latest"',
'image.pushed_at > now() - days(90)',
'top_pushed(20)'
]

Common Patterns​

Keep Tagged, Delete Untagged​

rules = [
'image.tag != null'
]

Time-Based Retention​

rules = [
'image.pushed_at > now() - days(30)', # Keep 30 days
'image.last_pulled_at > now() - days(7)' # Or pulled within 7 days
]

Top-n Retention​

rules = [
'top_pushed(10)', # Keep 10 most recently pushed tags
'top_pulled(5)' # Keep 5 most recently pulled tags
]

top_pushed and top_pulled rank tags, so untagged manifests and grant-only blobs never match them. Each ranking lists the tags carrying the time it orders by: top_pulled(5) keeps up to five pulled tags, not five tags regardless, and a tag with no recorded push time is outside top_pushed. With only count-based rules, everything untagged is deleted, including the revisions its own tag deletions orphan; that is what lets a top-n policy reclaim storage. Add a time-based rule such as image.pushed_at > now() - days(7) if recent untagged content must survive.

Per-Namespace Retention​

A global policy covers every namespace; image.namespace lets one rule set treat them differently:

[global.retention_policy]
rules = [
'image.tag == "latest"',
'image.namespace.startsWith("prod/") && image.pushed_at > now() - days(90)',
'!image.namespace.startsWith("prod/") && image.pushed_at > now() - days(7)'
]

Semantic Version Tags​

rules = [
'image.tag != null && image.tag.matches("^v?[0-9]+\\.[0-9]+\\.[0-9]+$")'
]

Combined Rules​

A manifest is KEPT if ANY rule matches (rules are OR'd):

rules = [
# Always keep latest
'image.tag == "latest"',

# Keep release tags forever
'image.tag != null && image.tag.matches("^v[0-9]+\\.[0-9]+\\.[0-9]+$")',

# Keep other tags for 30 days
'image.tag != null && image.pushed_at > now() - days(30)',

# Keep untagged for 7 days
'image.pushed_at > now() - days(7)',

# Keep top 10 most pulled
'top_pulled(10)'
]

Rule Functions:

  • top_pushed(n) - Keep the n most recently pushed tags
  • top_pulled(n) - Keep the n most recently pulled tags

Environment-Specific Policies​

Development: Aggressive Cleanup​

[repository."dev".retention_policy]
rules = [
'image.tag == "latest"',
'image.pushed_at > now() - days(7)',
'top_pushed(5)'
]

Staging: Moderate Retention​

[repository."staging".retention_policy]
rules = [
'image.tag == "latest"',
'image.pushed_at > now() - days(14)',
'top_pushed(10)'
]

Production: Conservative​

[repository."production".retention_policy]
rules = [
'image.tag == "latest"',
'image.pushed_at > now() - days(90)',
'image.tag != null && image.tag.matches("^v[0-9]+\\.")',
'top_pushed(50)'
]

Global + Repository Policies​

When both are defined, a manifest is kept if either policy matches:

# Global baseline: keep everything for at least 7 days
[global.retention_policy]
rules = [
'image.pushed_at > now() - days(7)'
]

# Repo-specific: extend for production
[repository."production".retention_policy]
rules = [
'image.pushed_at > now() - days(365)'
]

Result: Production images kept for 365 days, others for 7 days.


Enforcing Retention Policies​

Retention policies are enforced by the prune command, which also reclaims aged upload-lifecycle leftovers within its -u window (default 1h; see Storage Maintenance). Retention deletions take the registry's standard delete path:

  • They emit manifest.delete / tag.delete webhook events whose actor carries internal = "prune", so subscribers can tell retention apart from client deletes.
  • The manifest's blob bytes are reclaimed immediately once unreferenced (no separate scrub pass needed for pruned content).
  • On repositories with replication, the deletion is mirrored only to downstreams marked prune = true (authoritative one-way mirrors); additive downstreams keep their copies. Mirror deletions are enqueued on the durable job queue and drained by the running server or angos worker, so they complete asynchronously after the prune run exits.
# Preview what would be deleted
./angos -c config.toml prune --dry-run

# Enforce retention policies
./angos -c config.toml prune

Scheduled Enforcement​

Systemd timer:

Create /etc/systemd/system/registry-prune.service:

[Unit]
Description=Registry retention enforcement

[Service]
Type=oneshot
ExecStart=/usr/bin/angos -c /etc/registry/config.toml prune

Create /etc/systemd/system/registry-prune.timer, then enable it with systemctl enable --now registry-prune.timer:

[Unit]
Description=Daily registry retention enforcement

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

Kubernetes CronJob:

apiVersion: batch/v1
kind: CronJob
metadata:
name: registry-retention
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: prune
image: ghcr.io/project-angos/angos:latest
args: ["-c", "/config/config.toml", "prune"]
volumeMounts:
- name: config
mountPath: /config
readOnly: true
volumes:
- name: config
secret:
secretName: registry-config
restartPolicy: OnFailure

Verification​

Check What Would Be Deleted​

RUST_LOG=info ./angos prune --dry-run

List Current Manifests​

curl http://localhost:8000/v2/myrepo/myimage/_angos/revisions/list | jq

Misconfigured Rules and Fail-Open Semantics​

Retention policies are fail-open: when a rule behaves unexpectedly, the manifest is retained rather than deleted. This is a deliberate safety choice: it is safer to keep a manifest that should have been deleted than to silently delete one that should have been kept.

SituationResultLog level
Rule returns truemanifest retaineddebug
Rule returns falsenext rule evaluated(none)
Rule returns a non-boolean (misconfiguration)manifest retainedwarn
Rule fails to evaluate (e.g., reference to undefined variable)manifest retainedwarn
No rule returns truemanifest eligible for deletion(none)

When a misconfigured rule triggers the fail-open path, Angos emits a warn-level log line that includes the rule index (1-based) and the unexpected value or error. Grep your logs for lines containing "treating as 'retain' (fail-open)" to surface any misconfigured rules.

WARN retention rule 2 returned non-boolean value: Int(42); treating as 'retain' (fail-open)
WARN retention rule 3 evaluation failed: no such key: nonexistent_var; treating as 'retain' (fail-open)

Fix the offending rule and re-run prune --dry-run to verify the corrected behaviour before running without --dry-run.


Troubleshooting​

Images not being deleted:

  • Check if they match any retention rule
  • Check if they're protected (index child or referrer of a live subject)
  • Verify prune command is running

Pull time not tracked:

  • Enable update_pull_time = true in global config
  • Pull times are only tracked after enabling

Rules not matching:

  • Use debug logging: RUST_LOG=angos::command::prune=debug
  • Check that image.tag is null for untagged manifests

Reference​