Skip to main content
RunBook Academy

Backup & DRX · Object Storage, Versioning and RetentionObject storage

Retention modes: governance, compliance, and what each promises

Advanced⏱ ~29 min🧪 Lab requiredmc

What you'll learn

  • Choose between governance and compliance retention from the adversary each one is designed to stop
  • Plan bucket creation around the measured fact that object lock cannot be added afterwards
  • Predict which identities a retained object version resists, and which it does not
  • Verify retention semantics against a specific endpoint instead of assuming they match AWS

Prerequisites

Practice

Verified against restic 0.19.1 · BorgBackup 1.4.5 · rclone 1.75.0 · MinIO (S3-compatible object storage) RELEASE.2025-09-07T16-13-09Z · OpenZFS 2.4.1 · LVM2 2.03.31(2) · btrfs-progs 6.17.1 · PostgreSQL 18.6 · pgBackRest 2.59.1 · Kubernetes (k3s) and etcd k3s v1.36.3+k3s1, etcd 3.7.1 · Velero 1.18.2 · Docker Engine 29.7.2 · Proxmox Backup Server (documentation only) 4.0.10-1 · Ubuntu (host baseline) 26.04 LTS · 2026-08-28

Not yet marked complete on this device.

Lifecycle rules quietly remove recovery points because somebody wrote a rule and nobody read it again. Retention is the instrument pointed the other way: it exists to refuse a removal that somebody very much wants. Both are a few words of bucket configuration, both are written in the same command, and the resemblance is where the trouble starts — because object lock offers two retention modes, they are configured almost identically, and the difference between them is not a matter of degree. One stops mistakes. The other stops an attacker who is holding your administrative credentials, and only that second promise is an answer to the question ransomware asks.

--with-lock is a decision the bucket only gets to make once

Before either mode can be discussed there is an architectural constraint that decides whether the discussion is available at all. Object lock is not a setting you turn on; it is a property the bucket either has from birth or never has. The capture below asked an ordinary bucket to accept a default COMPLIANCE retention, and then created a second bucket with the flag that enables locking at creation time.

Configuration changeasking an existing bucket to start locking objects
$ mc mb lab/rbdr-plain
  Bucket created successfully `lab/rbdr-plain`.
>>> exit code: 0

$ mc retention set --default COMPLIANCE 7d lab/rbdr-plain
 ...attempting to add locking to a bucket that was created without it:
mc: <ERROR> Unable to apply bucket lock configuration. Object Lock configuration cannot be enabled on existing buckets.
>>> exit code: 1

$ mc mb --with-lock lab/rbdr-immutable    (locking enabled at creation)
Bucket created successfully `lab/rbdr-immutable`.
>>> exit code: 0

Nothing was wrong with the first bucket. It was healthy, it was writable, and it simply lacked a property that is fixed at creation. The error is precise about it: the configuration cannot be enabled on existing buckets, exit code

  1. The identical command against a bucket created with --with-lock was accepted.

The consequence is not operational, it is architectural, and it is the kind that arrives at the worst time. A destination that has been collecting nightly backups for a year cannot be converted into one that resists deletion. There is no upgrade, no migration flag, and no administrative override. The only route from an ordinary bucket to a locked one is to create a new bucket and re-write the data into it, paying the full transfer and the full storage cost of both copies while the move is in flight. Teams discover this on the day after an incident, when the sentence “we should make the backup bucket resist deletion” turns out to describe a project rather than a configuration change.

So the sequence matters more than the setting:

ALIAS=lab
BUCKET=rbdr-backups

mc mb --with-lock "$ALIAS/$BUCKET"
mc retention set --default COMPLIANCE 7d "$ALIAS/$BUCKET"

The order is not stylistic. Reverse it and the second command has nothing to apply to; run the first without the flag and the second command is the one that fails. There is also a dependency underneath both: object lock is defined only for versioned buckets, and AWS documents that it works only in buckets that have versioning enabled. In the capture the locked bucket reported lab/rbdr-immutable versioning is enabled before any object was written.

What COMPLIANCE refused, and whom it refused

With the locked bucket in place the capture applied a default COMPLIANCE retention of three days and wrote a nightly backup object into it. The retention that ends up on the object is worth looking at directly, because it is per-version metadata rather than a bucket-level flag someone can flip.

Read-only / Safethe retention the server recorded on the object version
$ mc retention set --default COMPLIANCE 3d lab/rbdr-immutable
  Object locking 'COMPLIANCE' is configured for 3DAYS.
>>> exit code: 0

lab/rbdr-immutable versioning is enabled

--- retention actually recorded on the object ---
Name      : backup-0900.tar
Size      : 38 B   
VersionID : 133fd99f-1f98-41c0-9d08-95e6e2944157 
  X-Amz-Object-Lock-Retain-Until-Date: 2026-08-31T13:28:20.402Z 
  X-Amz-Object-Lock-Mode             : COMPLIANCE 

Two things were stamped onto the version at write time: a mode and a date. The object was written at 13:28:20 on 28 August and carries a retain-until date of 2026-08-31T13:28:20.402Z, three days later to the second. Nothing about that lives in a policy document or a job configuration; it is metadata on one version, and every version gets its own.

The interesting question is not whether that metadata exists but what it survives. The capture created a second identity called production, holding the built-in readwrite policy on the bucket — full read, write and delete. These are the credentials an attacker takes off a compromised application host, and they are the credentials your backup job legitimately needs. That identity issued an ordinary mc rm and got exit code 0, because an ordinary delete against a versioned bucket writes a zero-byte delete marker rather than destroying anything: the 38 B v1 PUT version was still listed underneath. So far the object could be made to look gone. Then the attempts moved to the version itself.

Data-loss riskthree attempts to destroy the COMPLIANCE-protected version
$ mc rm --versions --version-id 133fd99f-1f98-41c0-9d08-95e6e2944157 prod/rbdr-immutable/backup-0900.tar
  mc: <ERROR> Failed to remove `prod/rbdr-immutable/backup-0900.tar`. Object, 'backup-0900.tar (Version ID=133fd99f-1f98-41c0-9d08-95e6e2944157)' is WORM protected and cannot be overwritten
>>> exit code: 1

--- can the production identity force it? ---
$ mc rm --bypass --version-id ... prod/rbdr-immutable/backup-0900.tar
mc: <ERROR> Failed to remove `prod/rbdr-immutable/backup-0900.tar`. Object, 'backup-0900.tar (Version ID=133fd99f-1f98-41c0-9d08-95e6e2944157)' is WORM protected and cannot be overwritten
>>> exit code: 1

--- can the FULL ADMIN force it? ---
mc: <ERROR> Failed to remove `lab/rbdr-immutable/backup-0900.tar`. Object, 'backup-0900.tar (Version ID=133fd99f-1f98-41c0-9d08-95e6e2944157)' is WORM protected and cannot be overwritten
>>> exit code: 1

--- final state of the backup object ---
[2026-08-28 13:28:22 UTC]     0B STANDARD 4b3c593c-e8ad-444d-aa87-89e380a1fbae v2 DEL backup-0900.tar
[2026-08-28 13:28:20 UTC]    38B STANDARD 133fd99f-1f98-41c0-9d08-95e6e2944157 v1 PUT backup-0900.tar
>>> exit code: 0

Three refusals, three exit codes of 1, and the same sentence each time: is WORM protected and cannot be overwritten. The production identity could not remove the version. The production identity could not force it with --bypass. And the full administrator — the alias that created the bucket, created the users and wrote the retention policy — could not remove it either. The final listing shows both versions still present: the zero-byte v2 DEL marker on top, and the 38 B v1 PUT version intact beneath it.

That third refusal is the entire point. Every access-control mechanism most sysadmins carry in their heads has an administrator at the top who can override it, and the reflex when reading “protected” is to ask which role holds the override. Here there is no such role. The refusal was not “you lack a permission”; it was “this operation does not exist for this object until 2026-08-31T13:28:20.402Z”.

GOVERNANCE, the same three days, and one flag

The second mode is configured through the same subcommand, with one word changed, and against a bucket that also had locking enabled at creation. Same retention period, same object, same administrator.

Destructivea GOVERNANCE retention, with and without --bypass
$ mc retention set --default GOVERNANCE 3d lab/rbdr-governance
  Object locking 'GOVERNANCE' is configured for 3DAYS.
>>> exit code: 0
governance-protected version id: 1425b7eb-5124-4607-a19f-1ced4c55527c

$ mc rm --version-id ...        (no bypass flag)
mc: <ERROR> Failed to remove `lab/rbdr-governance/backup-0900.tar`. Object, 'backup-0900.tar (Version ID=1425b7eb-5124-4607-a19f-1ced4c55527c)' is WORM protected and cannot be overwritten
>>> exit code: 1

$ mc rm --bypass --version-id ...   (admin holding the bypass right)
Removed `lab/rbdr-governance/backup-0900.tar` (versionId=1425b7eb-5124-4607-a19f-1ced4c55527c).
>>> exit code: 0

--- did the governance-protected object survive? ---

>>> exit code: 0

The plain version delete was refused with the identical message and the identical exit code 1. Add one flag and the same command against the same version returned Removed ... and exit code 0, and the listing that followed was empty. The recovery point is gone — not marked deleted, not shadowed by a marker, gone — two days and twenty-three hours before its retain-until date.

Read side by side, the two captures say something a configuration diff never would. A reviewer comparing the two buckets sees GOVERNANCE 3DAYS against COMPLIANCE 3DAYS, concludes that both buckets are protected for three days, and is right about the words and wrong about the property. Under one of them a single administrative credential ends the protection at will. Under the other, nothing does.

Two promises, two threat models

Now the modes can be stated as what they actually are, which is two different security claims rather than two strengths of one claim.

Governance retention protects a recovery point against everyone who does not hold the bypass right. That is a real and useful control, and the population it defends against is large: the operator clearing space under pressure, the cleanup script with a wrong prefix, the runaway lifecycle rule, the colleague who meant to delete the staging bucket. Every one of those is an accident, and governance mode stops all of them. It also stays reversible, which is exactly what you want when the retention period itself might have been a mistake.

Compliance retention protects a recovery point against an adversary who holds administrative credentials. That is a different population with one member, and it is the member that matters when the incident is not a mistake. An attacker who has taken the administrator identity has, by definition, the bypass right; they have it precisely because they took the account that grants rights. A control that an administrator can lift is a control the intruder has already lifted. The measured result is the argument in one line: under governance the administrator’s --bypass returned exit code 0 and the object was gone, and under compliance the same administrator got exit code 1.

So the ransomware question — can an intruder with our credentials destroy our backups before we notice? — has exactly one of these two as an answer. Governance mode answers a different question honestly and well. It does not answer this one.

“S3-compatible” is a claim about the API, not about the semantics

Everything above was executed against MinIO RELEASE.2025-09-07T16-13-09Z with mc RELEASE.2025-08-13T08-35-41Z, and that provenance is part of the finding rather than a footnote to it. MinIO documents its object locking as feature and API compatible with AWS S3, and on the behaviours measured here it matched the documented AWS semantics: locking fixed at bucket creation, per-version retention metadata, a governance bypass gated by a permission, and no compliance override for anybody.

That agreement is not something to generalise from. “S3-compatible” is a statement about accepted requests and response shapes, and the things that matter for a backup destination live underneath it: whether the implementation offers compliance mode at all, whether it enforces the retain-until date on every path including lifecycle expiry, whether a retention period can be shortened by an operator, and what happens at the moment the date passes. The error strings differ even where the outcome agrees — MinIO refused with is WORM protected and cannot be overwritten and exit code 1, where AWS documents a 403 Forbidden on the same class of request — so a monitoring rule written against one endpoint’s text will not fire on another’s.

The remedy is to stop reading and start measuring. The capture reproduced in this lesson is five steps long: create a bucket without the flag and try to add locking, create one with the flag, write an object and read back its recorded mode and date, attempt the version delete as the writing identity and as the administrator with and without --bypass, then repeat the last step under the other mode. Run it against the endpoint you are actually going to trust, record the exit codes, and keep the transcript next to the design document. A backup destination whose retention semantics were assumed from a vendor’s compatibility page has not been verified; it has been hoped for.

What to take from this

  • mc retention set --default COMPLIANCE 7d against an ordinary bucket failed with Object Lock configuration cannot be enabled on existing buckets and exit code 1, while a bucket created with mc mb --with-lock accepted the same configuration. Locking is fixed at bucket creation.
  • Under a COMPLIANCE retention of 3DAYS, the object version carried X-Amz-Object-Lock-Mode : COMPLIANCE and X-Amz-Object-Lock-Retain-Until-Date: 2026-08-31T13:28:20.402Z. Retention is metadata on one version, not a bucket-wide switch.
  • The production identity holding the built-in readwrite policy could not remove that version (exit 1) and could not force it with --bypass (exit 1), and the full administrator got the same refusal at exit code 1.
  • Under GOVERNANCE with the same three-day period, the plain version delete was refused at exit code 1, and the administrator adding --bypass returned Removed ... at exit code 0 with nothing left in the listing.
  • Governance protects against mistakes; compliance protects against an adversary holding administrative credentials. Only the second is an answer to the ransomware question, because the intruder who took the administrator account took the bypass right with it.
  • These behaviours were measured on MinIO RELEASE.2025-09-07T16-13-09Z with mc RELEASE.2025-08-13T08-35-41Z. S3-compatible implementations do not all share identical semantics, and the five-step probe above is cheap to run against the endpoint you intend to trust.

Cross-course references

  • Secrets, PKI & Certificate Management for Infrastructure Engineers — Part XVIII (Incidents and Recovery) covers what an organisation does once a credential is known to be compromised, which is the moment this lesson’s distinction pays out: a governance-retained copy is worth exactly as much as the identities holding the bypass right are trustworthy, so the credential-compromise runbook and the retention mode have to be designed as one decision.
  • Ceph & Distributed Storage for Production Sysadmins — Part XLV (RADOS Gateway) is the other S3-compatible endpoint most readers of this course will operate, and it is the concrete reason for the closing warning here: the exit codes above were measured on MinIO, so an RGW backup destination has to be put through the same probe before the same promise is claimed for it.
  • Observability for Production Sysadmins — Part XVIII (Alerting Rules) supplies the mechanism for turning the refusals measured here into a signal. A WORM refusal at exit code 1 is not noise to suppress but the earliest evidence that something is trying to destroy recovery points, and it deserves an alert rule of its own.

Quiz

Knowledge check · 5 questions

  1. Q1. A bucket has held nightly backups for a year. The team now needs those backups to resist deletion by an attacker holding administrative credentials. What does the measured MinIO behaviour say about the next step?

  2. Q2. A COMPLIANCE retention still has three days to run. The full administrator runs `mc rm --bypass --version-id ...` against the protected version. What was measured?

  3. Q3. In the capture, the production identity ran an ordinary `mc rm` against the COMPLIANCE-protected object and got exit code 0, even though no recovery point was destroyed.

  4. Q4. Which of these statements are supported by what the MinIO capture actually measured? Select all that apply.

  5. Q5. A team proposes GOVERNANCE retention on the backup bucket and describes it as ransomware protection. State what governance retention achieves and what it does not, against an attacker who has taken the administrator credential.

Passing score: 75%. Answers are checked in this browser.