Applying a retention policy
Delete old backup artifacts under a configured policy, having first confirmed exactly which artifacts that policy selects.
When to use this
Use this when you are enforcing a policy by hand: after writing or narrowing one, when a storage backend is filling up, or when you want to see what the automatic sweep will do before it does it.
Do not use it to explore what a policy means. sentinel retention preview tells you what will be
deleted, not why the rules combine the way they do; Retention explains
that. If you want long-horizon calendar tiers rather than a simple "keep the last N", start at
Configuring GFS retention tiers and come back here to apply it.
sentinel retention apply removes backup artifacts from storage. There is no trash, no grace period,
and no undo. Sentinel will not stop you deleting the only usable restore point for a database.
Always run sentinel retention preview with the same --job first and read every line it prints.
Before you start
- A
retention:block on the job, or underdefaults:, with at least one ofkeep_last,keep_days, or a positivegfs:tier. A block containing none of those does nothing at all, and the job is skipped entirely by an all-jobs run. - Delete permission on the job's storage backend, not just read. Deletion is implemented for
local,s3,gcs, andazure; see the failure notes below forgoogle-drive. - A readable history database at
history_db_path. Retention evaluates the recorded execution history, never a directory listing, so an artifact with no history row is invisible to it and will never be deleted. - An understanding of how the two flat rules combine, because it is the opposite of what most people expect. Read step 1 before you write a policy.
Steps
1. Write the policy, knowing that the flat rules intersect
version: "1.0"
history_db_path: ./.sentinel/history.db
defaults:
storage:
type: local
local_path: ./backups
retention:
keep_last: 7
keep_days: 30
keep_last and keep_days narrow each otherTogether these mean "at most 7, and none older than 30 days". They do not mean "whichever keeps
more". Each rule contributes to a delete set, so a backup that either rule discards is deleted even
when the other rule would have kept it. With keep_last: 7, the eighth most recent backup is deleted
regardless of keep_days, even if it is an hour old.
This is issue #158. Sentinel's own README and
operator runbooks describe these two rules as a union; they are wrong. Size keep_last for the
retention you actually want, and treat keep_days as a further ceiling on age rather than as a
floor on coverage.
Only the flat family and the GFS family combine as a union of keeps. Adding a gfs: block can only
ever protect more; it can never make an existing keep_last delete more than it already did.
Two inheritance rules bite here. defaults.retention is inherited as a whole block, so a job
declaring retention: { keep_last: 14 } gets exactly that and loses the keep_days: 30 from
defaults. And a job whose only retention key is dry_run: true counts as declaring a policy, blocks
the inheritance, and then fails validation with a message that says nothing about inheritance:
backup 'app-postgres': keep_last, keep_days, or gfs is required when retention is enabled
2. Preview, scoped to one job
sentinel retention preview --config sentinel.yaml --job app-postgres
Each candidate is printed with its size and the rule that selected it:
retention preview for app-postgres
- ./backups/app-postgres_2026-06-11T02-00-00.sql (48210944 bytes) - exceeded keep_last
- ./backups/app-postgres_2026-05-28T02-00-00.sql (47993088 bytes) - exceeded keep_last, not retained by gfs
Preview reads history, computes candidates, prints them, and stops. It contacts no storage backend and modifies no database, so it is safe against production at any time.
Retention prints its report on stderr, not stdout. sentinel retention preview ... > report.txt
captures an empty file. Use 2>&1 > report.txt or ... > report.txt 2>&1 depending on which stream
you want.
Anything absent from the list is being kept by something. Two exclusions are applied before printing: the single most recent successful backup is always retained, and so is the full baseline of the chain that the most recent backup belongs to.
Always pass --job. Without it the summary uses the word "deleted" in preview mode too, so
sentinel retention preview --config sentinel.yaml prints total deleted: 3 backups while deleting
nothing whatsoever. Only the single-job form labels its output retention preview for <job>.
3. Apply, scoped to the same job
Once the preview matches your intent:
sentinel retention apply --config sentinel.yaml --job app-postgres
The same calculation runs, then each candidate's artifact is deleted from storage one at a time, and only if every artifact deletion succeeded are the matching history rows removed in a single transaction. That ordering is deliberate: a history row pointing at a missing file is recoverable, whereas a deleted row pointing at a surviving file leaves an artifact nothing will ever clean up again.
sentinel retention apply --dry-run is the same code path as preview, with the header reading
retention apply for <job> instead.
dry_run key in YAML is not a safety netretention.dry_run: true parses, validates, and participates in inheritance, and then is never
consulted by the deletion path. Dry-run is decided entirely by the subcommand and the --dry-run
flag. A job configured with keep_last: 7 and dry_run: true is really deleted by
sentinel retention apply, and by the automatic post-scheduled-backup sweep, which passes false
unconditionally.
This is issue #157. Delete the key from your
configuration so nobody trusts it, and use sentinel retention preview instead.
4. Let the scheduler take over
After each successful scheduled backup, Sentinel evaluates that one job's policy in real deleting
mode, so a policy stays enforced without anyone running a command. A one-shot
sentinel backup --config never triggers it. Retention failures there are warnings and do not turn a
successful backup into a failed one, which also means a repeatedly failing sweep is easy to miss.
Verify
Confirm the artifact count dropped and that the backend is still healthy:
sentinel storage status --config sentinel.yaml --output text
Confirm the history agrees with storage, since the two can diverge if a deletion failed partway:
sentinel monitor list --config sentinel.yaml --job app-postgres --last 90d
Every remaining row should name a file that still exists. Then re-run the preview: on a policy that has just been applied it should report nothing further to delete.
Finally, if the job produces incremental chains, confirm you did not cut through one. This is the failure that stays invisible until a restore. The check is named after a configured restore job rather than the backup job, and takes it as a positional argument:
sentinel restore validate-chain app-postgres-restore --config sentinel.yaml
If it goes wrong
retention delete not supported for storage type 'google-drive'. Deletion is implemented for
local, S3, GCS, and Azure only. A Google Drive job computes candidates normally and fails at the
deletion step, leaving both the artifacts and the history rows in place. Worse, preview gives no
hint: it returns before storage is ever consulted, so it lists candidates for a backend that cannot
delete them. This is issue #169. Prune Google
Drive artifacts outside Sentinel.
An all-jobs run reported errors and still exited 0. Without --job, per-job failures are
collected, summarised as a single retention completed with errors line on stderr, and then
discarded; the command returns success. A cron entry wrapping it will never alert. This is
issue #168. Run one job at a time in automation,
where a failure does propagate as a non-zero exit, or grep the stderr for that line.
Artifacts are gone but history still lists them. One deletion in the batch failed, so the history
transaction was skipped for the entire job, including the artifacts that were removed. Fix the
underlying permission or path problem and re-run retention apply. On local storage deletion is
idempotent, so an already-removed file does not fail the second attempt and the rows clear once every
candidate succeeds.
Manifests and sidecars are still there. Retention deletes the artifact path recorded in history
and nothing else, so <artifact>.manifest.json survives, along with engine side artifacts such as an
archived binary log tarball or an oplog archive. They are small but they accumulate, and a stranded
manifest makes the storage listing misleading. This is
issue #159. Remove them out of band.
A restore fails on a chain whose artifacts all appear to be present. Retention removed the
baseline of a completed chain while its incrementals survived. Only the baseline of the chain
currently being extended is protected. Keep keep_last comfortably larger than
incremental_backup.max_chain_depth + 1 so a chain ages out as a unit.
A job is never swept. An all-jobs run skips any job whose policy would not act, which includes an
absent, empty, or all-zero retention: block. Confirm the job's own block, remembering that any
positive retention key on the job stops defaults.retention being inherited at all.
Related
- Retention: how candidates are calculated, the safety exclusions, and the order of operations.
- Configuring GFS retention tiers: calendar tiers for long-horizon policies.
- Checking a storage backend: confirming reachability and permissions before deleting.
- Inspecting monitor history: the records retention evaluates.
- Incremental backup with WAL: why a chain baseline matters, made concrete.
sentinel retentionreference: every flag onpreviewandapply.- Configuration reference: the full
retention:block.
{/* sources: internal/domain/retention/policy.go, internal/domain/retention/types.go, internal/cli/retention.go, internal/cli/retention_helpers.go, internal/cli/retention_cleaner.go, internal/cli/backup.go, internal/adapters/monitor/retention.go, internal/config/types.go, internal/config/loader.go, internal/config/validator.go, docs/runbooks/apply-retention.md */}