Cloud Storage Lifecycle Rules

An upload bucket only ever grows: every abandoned transfer, every superseded derivative and every β€œtemporary” staging key stays on the bill until something deletes it, and nobody has time to run that cleanup by hand. Lifecycle rules are the declarative engine that does it for you β€” but they run asynchronously, round dates in ways that surprise people, and charge minimum storage durations that can make a badly-timed transition more expensive than doing nothing at all.

This page is the mechanism-level reference for the lifecycle layer of backend validation and cloud storage architecture. If you want a ready-made configuration for a staging prefix, go straight to setting up S3 lifecycle rules for temporary uploads; if your bill is inflated by parts that never became objects, read expiring incomplete multipart uploads automatically. What follows is everything underneath both of those: how the evaluation pass works, what each key actually does, and the eight ways a correct-looking rule set still costs you money or deletes the wrong thing.

Prerequisites

  • [ ] Node 20+ with ESM enabled ("type": "module" in package.json) β€” every snippet below uses top-level await.
  • [ ] @aws-sdk/client-s3 3.600+, or AWS CLI v2.15+ for the shell examples.
  • [ ] An IAM principal holding s3:GetLifecycleConfiguration and s3:PutLifecycleConfiguration on the bucket ARN, plus s3:GetBucketVersioning so you can tell whether the noncurrent keys apply.
  • [ ] A bucket where you already know the access pattern β€” 30 days of S3 Storage Lens or storage class analysis data, not a guess. Tiering blind is how you pay retrieval fees on hot objects.
  • [ ] Versioning state confirmed (Enabled, Suspended, or never enabled). The correct rule set is materially different for each, and getting it wrong is the single most common cause of β€œmy expiration rule did nothing”.
  • [ ] A test bucket in the same region and account. Lifecycle rules are bucket-scoped and there is no dry-run API β€” the canary in the verification section is the closest thing you get.

How it works

A lifecycle configuration is not a scheduler you drive. It is a declarative document attached to the bucket; the storage service walks the bucket on its own cadence and applies whatever the document says. Understanding that gap between eligible and executed explains nearly every confusing lifecycle bug.

The daily evaluation pass

S3 evaluates lifecycle rules against a bucket at least once every 24 hours, with the evaluation window anchored to UTC midnight. When an object becomes eligible during a pass, the action is queued rather than performed inline: AWS documents that the deletion or transition can take up to 48 hours to complete after the object becomes eligible. For a bucket with tens of millions of keys the practical spread is wider still β€” the first objects in a large batch move within an hour of the window opening, the last ones can trail by a day and a half.

Two consequences matter operationally. First, ListObjectsV2 is not a reliable oracle for β€œdid the rule fire?” in the first 48 hours; a key can be past its expiration date and still listed. Second, billing does not wait for the bytes to disappear. Storage charges stop on the date the object becomes eligible for expiration, and the new storage class’s rate applies from the date the object becomes eligible for transition, regardless of when the physical move lands. Your cost graph moves before your object count does, which is exactly the opposite of what most people assume when they watch a rollout.

Object lifecycle timeline from upload to expiration A single object ages along a timeline: created at day zero, transitioned to STANDARD_IA at day 30, to Glacier Instant Retrieval at day 90, and expired at day 365. Below, three chips list the minimum billed duration of each destination class. One object, four billing states STANDARD STANDARD_IA GLACIER_IR Day 0 Day 30 Day 90 Day 365 PutObject Transition Transition Expiration Minimum billed duration β€” delete or re-transition sooner and you still pay the full term: STANDARD_IA β€” 30 days GLACIER_IR β€” 90 days DEEP_ARCHIVE β€” 180 days
The transition dates you choose are only half the picture β€” each destination class carries a minimum billed duration that keeps charging after the object is gone.

How the age of an object is computed

Days is not measured from the object’s creation timestamp. S3 rounds the creation time up to the next UTC midnight, then adds Days, and the object becomes eligible at that boundary. An object written at 2026-03-01T23:50:00Z under a rule with Expiration.Days: 1 is eligible from 2026-03-03T00:00:00Z β€” 24 hours and 10 minutes later. The same rule applied to an object written at 2026-03-01T00:05:00Z gives eligibility at 2026-03-02T00:00:00Z, just under 24 hours.

So Days: 1 means β€œsomewhere between 24 and 48 hours, plus up to 48 hours of execution lag”. The effective worst case is close to four days. If you are expiring staging objects that a transcoding job still needs, size the value against that worst case, not the nominal one β€” the same arithmetic that governs the abort window in post-upload media transcoding pipelines.

Expiration.Date behaves differently: it is an absolute ISO-8601 instant that must be UTC midnight, and S3 rejects anything else with InvalidRequest: 'Date' must be at midnight GMT. A rule with a Date in the past fires on the next evaluation pass for every matching object at once β€” which is a fine way to run a one-off purge and a spectacular way to delete a bucket by typo.

Rules are a union, not a priority list

There is no Priority field in an S3 lifecycle configuration, and rules are not evaluated top-down until one matches. Every rule whose filter matches an object contributes its actions, and the engine takes the union. Where two matching rules specify the same kind of action, the one with the earliest effective date wins. And where an expiration and a transition would both fire on the same day for the same object, expiration wins β€” there is no point paying a transition request for a byte you are about to delete.

Matrix showing how two overlapping lifecycle rules combine Four sample object keys are checked against a prefix rule expiring uploads after 30 days and a tag rule expiring temp objects after 2 days. The effective action column shows the earliest matching expiration winning in each case. Rules combine as a union: the earliest expiration wins Object key Rule A prefix uploads/ = 30d Rule B tag temp=1 = 2d Effective action uploads/a.mp4 matches no match delete on day 30 uploads/b.mp4 temp=1 matches matches delete on day 2 final/c.mp4 no match no match kept indefinitely final/d.mp4 temp=1 no match matches delete on day 2 A stray tag on a permanent key is enough to delete it β€” the prefix in Rule A never gets a chance to protect it.
Row four is the bug people ship: a tag-scoped rule reaches outside the staging prefix entirely, because filters are additive across rules rather than scoped by them.

The practical rule that falls out of this: never mix prefix-scoped and tag-scoped expiration rules in the same bucket unless every tag rule also carries the prefix in an And block. A tag applied for an unrelated reason β€” say a temp=1 marker your quarantine bucket pattern sets during scanning β€” becomes a delete instruction for anything that carries it.

What a transition actually costs

Transitions are not free moves. Each object transitioned costs a lifecycle transition request: roughly $0.01 per 1,000 objects into STANDARD_IA, One Zone-IA or GLACIER_IR, and $0.05 per 1,000 into Glacier Flexible Retrieval or Deep Archive (us-east-1 list price). Ten million thumbnails going to Deep Archive is $500 in request charges before a single byte of savings lands.

Then there are the minimum billed durations shown in the first diagram, and the per-object overheads: Glacier classes add 32 KB of archive-class storage for the index plus 8 KB of STANDARD-class storage for the name and metadata of every object. A 20 KB thumbnail archived to Deep Archive is billed as roughly 52 KB across two classes and costs more than leaving it in STANDARD.

Cumulative storage cost per terabyte over twelve months under three lifecycle policies A line chart comparing cumulative cost per terabyte for keeping everything in STANDARD, transitioning to STANDARD_IA at 30 days, and transitioning to STANDARD_IA at 30 days then Glacier Instant Retrieval at 90 days. After twelve months the three policies cost 276, 161 and 84 dollars respectively. Cumulative cost per TB, us-east-1 list price $300 $200 $100 $0 0 3 6 9 12 months since upload STANDARD throughout β€” $276/yr STANDARD_IA at day 30 β€” $161/yr plus GLACIER_IR at day 90 β€” $84/yr
Two transitions cut the annual bill by 70%, but the curves only separate after month three β€” a policy applied to data you delete at day 45 saves nothing and costs two request charges.

The chart assumes 1 TB written once and held: $0.023/GB-month for STANDARD, $0.0125 for STANDARD_IA and $0.004 for GLACIER_IR. It deliberately excludes retrieval fees, because those are the variable that decides whether tiering is a win. STANDARD_IA charges $0.01/GB retrieved and GLACIER_IR $0.03/GB. If 5% of that terabyte is read back each month from GLACIER_IR, you add $1.50/month in retrieval β€” still cheaper, but if 40% is read back the archive policy loses outright. Get the access distribution from Storage Lens before committing, and remember that objects served through a CDN look cold at origin even when they are extremely hot at the edge.

Step-by-step implementation

The workflow below treats the lifecycle configuration as a single deployable artefact: measured, typed, diffed against what is live, and rolled out to a canary prefix first.

Step 1 β€” Measure the access pattern before choosing dates

Storage class analysis is the only free source of β€œhow much of this prefix is still being read at day N”. Enable it on the prefix you intend to tier, then wait β€” the first useful output appears after about 30 days and the recommendation stabilises at 90.

aws s3api put-bucket-analytics-configuration \
  --bucket media-uploads \
  --id derivatives-access \
  --analytics-configuration '{
    "Id": "derivatives-access",
    "Filter": { "Prefix": "derivatives/" },
    "StorageClassAnalysis": {
      "DataExport": {
        "OutputSchemaVersion": "V_1",
        "Destination": {
          "S3BucketDestination": {
            "Format": "CSV",
            "Bucket": "arn:aws:s3:::media-analytics",
            "Prefix": "storage-class-analysis/"
          }
        }
      }
    }
  }'

The exported CSV carries one row per age bucket with ObjectAgeForSIATransition, StorageMB and DataRetrievedMB. The date to pick is the first age bucket where DataRetrievedMB / StorageMB drops below about 1% per month β€” below that, the IA per-GB saving dominates the retrieval fee.

Step 2 β€” Model the rule set in TypeScript

Hand-written lifecycle JSON is where MalformedXML comes from. Build it from typed helpers so the illegal combinations cannot be expressed.

// lifecycle-policy.ts
import type { LifecycleRule, Transition } from "@aws-sdk/client-s3";

const DAYS = (n: number): number => {
  if (!Number.isInteger(n) || n < 1) throw new RangeError(`Days must be a positive integer, got ${n}`);
  return n;
};

/** Minimum billed duration per destination class, in days. */
const MIN_DURATION: Record<string, number> = {
  STANDARD_IA: 30,
  ONEZONE_IA: 30,
  GLACIER_IR: 90,
  GLACIER: 90,
  DEEP_ARCHIVE: 180,
};

function assertTransitionsAreEconomic(id: string, transitions: Transition[], expireDays?: number): void {
  const sorted = [...transitions].sort((a, b) => (a.Days ?? 0) - (b.Days ?? 0));
  for (let i = 0; i < sorted.length; i += 1) {
    const current = sorted[i];
    const cls = current.StorageClass ?? "";
    const start = current.Days ?? 0;
    const end = sorted[i + 1]?.Days ?? expireDays;
    if (end === undefined) continue;
    const held = end - start;
    const minimum = MIN_DURATION[cls] ?? 0;
    if (held < minimum) {
      throw new Error(
        `Rule ${id}: object sits in ${cls} for ${held} days but is billed for ${minimum}. ` +
        `Move the next action to day ${start + minimum} or drop this transition.`,
      );
    }
  }
}

export function stagingRule(prefix: string, expireDays: number): LifecycleRule {
  return {
    ID: `expire-${prefix.replace(/\W+/g, "-")}`,
    Status: "Enabled",
    Filter: { Prefix: prefix },
    Expiration: { Days: DAYS(expireDays) },
    AbortIncompleteMultipartUpload: { DaysAfterInitiation: DAYS(Math.max(1, expireDays)) },
  };
}

export function tieringRule(
  id: string,
  prefix: string,
  transitions: Array<{ days: number; storageClass: keyof typeof MIN_DURATION }>,
  expireDays?: number,
): LifecycleRule {
  const mapped: Transition[] = transitions.map((t) => ({
    Days: DAYS(t.days),
    StorageClass: t.storageClass,
  }));
  assertTransitionsAreEconomic(id, mapped, expireDays);
  return {
    ID: id,
    Status: "Enabled",
    // ObjectSizeGreaterThan skips the small objects that Glacier overhead would make more expensive.
    Filter: { And: { Prefix: prefix, ObjectSizeGreaterThan: 131072 } },
    Transitions: mapped,
    ...(expireDays === undefined ? {} : { Expiration: { Days: DAYS(expireDays) } }),
    NoncurrentVersionExpiration: { NoncurrentDays: 30, NewerNoncurrentVersions: 2 },
  };
}

export const POLICY: LifecycleRule[] = [
  stagingRule("staging/", 2),
  tieringRule(
    "tier-originals",
    "originals/",
    [
      { days: 30, storageClass: "STANDARD_IA" },
      { days: 120, storageClass: "GLACIER_IR" },
    ],
    365,
  ),
];

assertTransitionsAreEconomic is the part that pays for itself. Ask it for STANDARD_IA at day 30 followed by GLACIER_IR at day 45 and it refuses:

Error: Rule tier-originals: object sits in STANDARD_IA for 15 days but is billed for 30.
Move the next action to day 60 or drop this transition.

S3 itself would accept that configuration and quietly bill you twice.

Step 3 β€” Deploy with a read-modify-write guard

PutBucketLifecycleConfiguration replaces the entire configuration. There is no partial update, no append, and no conditional header. If two teams own rules in the same bucket, the second deploy silently erases the first. Always read the live document, merge by rule ID, and print the diff.

// deploy-lifecycle.ts
import {
  S3Client,
  GetBucketLifecycleConfigurationCommand,
  PutBucketLifecycleConfigurationCommand,
  type LifecycleRule,
} from "@aws-sdk/client-s3";
import { POLICY } from "./lifecycle-policy.js";

const BUCKET = process.env.BUCKET ?? "media-uploads";
const client = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1", maxAttempts: 5 });

async function readLive(bucket: string): Promise<LifecycleRule[]> {
  try {
    const res = await client.send(new GetBucketLifecycleConfigurationCommand({ Bucket: bucket }));
    return res.Rules ?? [];
  } catch (error) {
    const name = (error as { name?: string }).name;
    if (name === "NoSuchLifecycleConfiguration") return [];
    throw error;
  }
}

function merge(live: LifecycleRule[], desired: LifecycleRule[]): LifecycleRule[] {
  const byId = new Map(live.map((rule) => [rule.ID ?? "", rule]));
  for (const rule of desired) byId.set(rule.ID ?? "", rule);
  const merged = [...byId.values()];
  if (merged.length > 1000) throw new Error(`${merged.length} rules β€” S3 allows at most 1000 per bucket`);
  return merged;
}

const live = await readLive(BUCKET);
const next = merge(live, POLICY);

const liveById = new Map(live.map((r) => [r.ID ?? "", JSON.stringify(r)]));
for (const rule of next) {
  const before = liveById.get(rule.ID ?? "");
  const after = JSON.stringify(rule);
  if (before === undefined) console.log(`+ ${rule.ID}`);
  else if (before !== after) console.log(`~ ${rule.ID}`);
}

if (process.env.APPLY !== "1") {
  console.log(`dry run β€” ${next.length} rule(s) would be written to ${BUCKET}. Re-run with APPLY=1.`);
} else {
  await client.send(
    new PutBucketLifecycleConfigurationCommand({
      Bucket: BUCKET,
      LifecycleConfiguration: { Rules: next },
    }),
  );
  console.log(`applied ${next.length} rule(s) to ${BUCKET}`);
}

A first run against a bucket that already has an unrelated rule prints:

+ expire-staging-
+ tier-originals
dry run β€” 3 rule(s) would be written to media-uploads. Re-run with APPLY=1.

Three, not two β€” the pre-existing rule survived the merge. That line is the whole point of the script.

Step 4 β€” Roll out behind a canary prefix

Before pointing a rule at originals/, point an identical rule at canary/originals/ with the same dates divided by ten, and write a handful of objects there. You get real evidence of eligibility, execution lag and storage-class change within a couple of days rather than a couple of months, and if the filter is wrong you lose four test objects instead of the archive.

# Write a canary object, then confirm nothing else in the bucket matched the new rule.
aws s3api put-object --bucket media-uploads --key canary/originals/probe.bin \
  --body /dev/null --tagging 'lifecycle-canary=1'

aws s3api list-objects-v2 --bucket media-uploads --prefix canary/ \
  --query 'Contents[].{Key:Key,Size:Size,Class:StorageClass}' --output table

Configuration reference

Every key S3 accepts inside a single rule, with the behaviour that actually matters. GCS and Azure equivalents follow in the cross-cloud section.

Key Type Default Effect
ID string ≀ 255 chars generated Stable identity for merges. Change it and you create a second rule rather than editing the first.
Status Enabled | Disabled required Disabled keeps the rule in the document but stops evaluation β€” the safe way to pause a rollout.
Filter.Prefix string "" (whole bucket) Literal prefix match, no wildcards, no regex. logs matches logs-old/x as well as logs/x.
Filter.Tag {Key, Value} none Matches object tags, not metadata. Tags are mutable after upload, so this filter is a live control surface β€” and a live footgun.
Filter.ObjectSizeGreaterThan integer bytes none Exclusive. Use 131072 to skip objects below the 128 KB transition floor.
Filter.ObjectSizeLessThan integer bytes none Exclusive. Pair with a size-greater filter inside And to build a size band.
Filter.And object none Required to combine two or more conditions. A rule with both Prefix and Tag at the top level is rejected as MalformedXML.
Expiration.Days positive integer none Days after creation, rounded up to UTC midnight. On a versioned bucket this creates a delete marker instead of deleting bytes.
Expiration.Date ISO-8601 at 00:00:00Z none Absolute cut-off. Rejected unless the time is exactly UTC midnight.
Expiration.ExpiredObjectDeleteMarker boolean false Removes delete markers whose every noncurrent version is already gone. Mutually exclusive with Days/Date in the same rule.
Transitions[].Days positive integer none Must increase monotonically with the class’s coldness β€” you cannot transition GLACIER back to STANDARD_IA.
Transitions[].StorageClass enum none STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, GLACIER_IR, GLACIER, DEEP_ARCHIVE.
NoncurrentVersionTransitions[].NoncurrentDays positive integer none Counted from the moment the version became noncurrent, not from its creation.
NoncurrentVersionExpiration.NoncurrentDays positive integer none Permanently deletes old versions. Without it, versioning turns every overwrite into a permanent cost.
NoncurrentVersionExpiration.NewerNoncurrentVersions 1–100 none Retains this many recent noncurrent versions regardless of age. Combine with NoncurrentDays for β€œkeep 2 versions or 30 days, whichever is more”.
AbortIncompleteMultipartUpload.DaysAfterInitiation positive integer none Cannot be combined with a Tag filter β€” parts have no tags. Covered in depth on expiring incomplete multipart uploads.

Bucket-level limits worth knowing: 1,000 rules per bucket, and the whole configuration must be under 20 KB of XML on the wire. Both are reachable if you generate one rule per tenant β€” use tags and one rule instead.

Edge cases and gotchas

Versioning turns expiration into a tombstone

On a versioning-enabled bucket, Expiration.Days does not delete anything. It writes a delete marker: a zero-byte object that becomes the current version and hides the real one. The bytes stay, at full price, forever, unless a NoncurrentVersionExpiration rule also fires. Teams enable versioning for safety, ship an expiration rule, watch the object count go down in the console β€” and discover six months later that BucketSizeBytes never moved.

State machine for an object in a versioning-enabled bucket A current version becomes noncurrent when overwritten, can be transitioned to Glacier while noncurrent, and is only permanently deleted by a NoncurrentVersionExpiration rule. A plain delete creates a zero-byte delete marker that itself needs ExpiredObjectDeleteMarker to be cleaned up. Where the bytes actually go on a versioned bucket overwrite (PutObject) NoncurrentDays: 30 Current version billed at STANDARD Noncurrent version still billed in full Noncurrent, Glacier 90-day minimum DELETE with no versionId, or Expiration NoncurrentVersionExpiration same rule Delete marker 0 bytes, hides the key Bytes actually gone billing stops here
Only the bottom-right state stops the meter. Everything else is still charged, including the delete marker path most teams believe is a deletion.

The correct versioned rule set is three actions, not one: Expiration.Days to retire the current version, NoncurrentVersionExpiration with both NoncurrentDays and NewerNoncurrentVersions to clear the history, and a second rule carrying ExpiredObjectDeleteMarker: true to sweep up the tombstones once their versions are gone. The last one cannot live in the same rule as an Expiration.Days β€” S3 returns InvalidRequest: Found mutually exclusive 'ExpiredObjectDeleteMarker' and 'Days'.

The 128 KB transition floor

S3 applies a default filter that refuses to transition objects smaller than 128 KB to STANDARD_IA or ONEZONE_IA, because the IA classes bill a 128 KB minimum object size regardless of actual bytes. You can override it with an explicit ObjectSizeLessThan filter, and you almost never should: a 20 KB object in STANDARD_IA costs the same as a 128 KB one, so you would be paying 6.4Γ— the stored bytes for a 46% per-GB discount.

Glacier classes have no such guard rail, which is worse. Nothing stops you archiving ten million thumbnails, and nothing warns you that the 32 KB archive index plus 8 KB metadata overhead makes each one more expensive than it was. If your bucket holds derivatives from an image derivative pipeline, put ObjectSizeGreaterThan: 131072 on every archival rule and regenerate the small ones on demand instead.

Object Lock silently outranks expiration

A bucket with Object Lock in COMPLIANCE mode will accept your expiration rule, evaluate it, and then decline to delete anything still under retention β€” with no error surfaced anywhere except the absence of a deletion. The rule is not disabled and the console shows it as active. The only signal is that object counts do not drop. If you run WORM retention for regulated media, set expiration windows strictly longer than the longest Retain-Until-Date you ever apply, and assert that invariant in the same deploy script that writes the rules.

Prefix filters are literal, and / is not special

Filter.Prefix: "logs" matches logs/2026/x.json, logs-archive/x.json and logsomething.txt. There is no delimiter semantics in a lifecycle filter β€” it is a byte-prefix comparison. Always include the trailing slash. This is the same trap that catches people writing IAM resource ARNs, and it is the difference between expiring a folder and expiring three.

Replication and lifecycle disagree by design

If you replicate a bucket to a second region, lifecycle rules do not replicate with it. The destination bucket keeps its own configuration, and by default that is none β€” so the region you added for durability quietly becomes the region with unbounded growth. Worse, a transition on the source does not propagate: replication copies objects, not storage-class changes made after the copy. Deploy the same policy artefact to both buckets from the same script, and be aware that a DELETE produced by an expiration rule is never replicated at all, by explicit S3 design, so the destination retains objects the source has expired.

Intelligent-Tiering versus hand-cut transitions

INTELLIGENT_TIERING charges a monitoring fee of $0.0025 per 1,000 objects per month and then moves data between access tiers for you with no retrieval charges on the frequent/infrequent tiers. For 10 million objects that is $25/month before any storage. It wins when access is unpredictable and objects are large; hand-cut transitions win when the access pattern is a known decay curve, which upload-derived media usually is. Note that Intelligent-Tiering also applies a 128 KB rule: smaller objects are stored in the frequent access tier permanently and are never monitored or charged the monitoring fee.

Rule changes do not backfill instantly

Editing a configuration re-evaluates every object at the next pass, not immediately. Tightening a rule from 90 days to 30 makes every object older than 30 days eligible at once, and S3 will churn through the backlog over the following 48 hours. On a large bucket that shows up as a step change in the deletion metrics and, if downstream systems watch S3 events, as a burst of notifications big enough to trip a queue consumer. Coordinate with whatever consumes those events β€” the same concern applies to the index described in metadata indexing and search, which will start returning rows for objects that no longer exist.

Aggressive abort windows break slow uploads

AbortIncompleteMultipartUpload.DaysAfterInitiation: 1 looks tidy and will destroy an in-flight transfer from a mobile client on a bad connection. Size it against the p99 wall-clock duration between CreateMultipartUpload and CompleteMultipartUpload, plus the UTC-midnight rounding, plus a day of slack β€” which for consumer video uploads is usually 7, not 1. The trade-offs are quantified in best practices for handling 500MB file uploads.

Verification

There is no dry-run endpoint, so verification is: assert the deployed document, observe an actual transition, and wire an alarm to the events.

1. Assert the live configuration matches intent. Run this in CI after every deploy; it fails loudly if someone edits the bucket in the console.

// verify-lifecycle.ts
import { S3Client, GetBucketLifecycleConfigurationCommand } from "@aws-sdk/client-s3";
import { POLICY } from "./lifecycle-policy.js";
import assert from "node:assert/strict";

const client = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const { Rules = [] } = await client.send(
  new GetBucketLifecycleConfigurationCommand({ Bucket: process.env.BUCKET ?? "media-uploads" }),
);

const live = new Map(Rules.map((rule) => [rule.ID ?? "", rule]));
for (const expected of POLICY) {
  const actual = live.get(expected.ID ?? "");
  assert.ok(actual, `rule ${expected.ID} is missing from the bucket`);
  assert.equal(actual.Status, "Enabled", `rule ${expected.ID} is not Enabled`);
  assert.deepEqual(actual.Filter, expected.Filter, `rule ${expected.ID} filter drifted`);
  assert.deepEqual(actual.Expiration, expected.Expiration, `rule ${expected.ID} expiration drifted`);
}
console.log(`verified ${POLICY.length} rule(s); bucket carries ${Rules.length} in total`);

2. Watch a single object cross a boundary. HeadObject reports the current class; STANDARD objects omit the header entirely, which is itself the assertion.

aws s3api head-object --bucket media-uploads --key canary/originals/probe.bin \
  --query '{Class:StorageClass,Modified:LastModified,Restore:Restore}'
{
    "Class": "GLACIER_IR",
    "Modified": "2026-04-18T09:12:44+00:00",
    "Restore": null
}

3. Subscribe to the lifecycle events. S3 emits s3:LifecycleTransition, s3:LifecycleExpiration:Delete and s3:LifecycleExpiration:DeleteMarkerCreated. Turning them on gives you an auditable record of every automated deletion and a metric you can alarm on when the count goes to zero (rule broken) or spikes 100Γ— (filter too broad).

aws s3api put-bucket-notification-configuration --bucket media-uploads \
  --notification-configuration '{
    "QueueConfigurations": [{
      "Id": "lifecycle-audit",
      "QueueArn": "arn:aws:sqs:us-east-1:111122223333:lifecycle-audit",
      "Events": ["s3:LifecycleExpiration:*", "s3:LifecycleTransition"]
    }]
  }'

The same queue is a convenient hook for keeping a database in step with reality β€” the row-level counterpart of the columns described in storing image dimensions and duration metadata.

4. Reconcile against S3 Inventory. For a bucket over a few million keys, the daily inventory report with the StorageClass and IsLatest fields enabled is the only affordable way to answer β€œhow many objects are still in the wrong class?”. Query it with Athena, compare the count against what the policy predicts, and alert on a gap greater than one day’s churn.

Cross-cloud equivalents

The concept is universal; the vocabulary and the sharp edges are not. If you are still choosing, the trade-offs are compared in S3 vs GCS vs Azure Blob for media uploads.

Capability S3 Google Cloud Storage Azure Blob Storage
Document name Lifecycle configuration Lifecycle management config Management policy
Rule limit 1,000 per bucket 100 per bucket 100 rules, 10 prefixes each
Filter surface prefix, tag, object size prefix, suffix, age, class, matchesPattern prefix, blob index tag
Age semantics days from creation, rounded to UTC midnight age in days, evaluated once per day daysAfterCreationGreaterThan, evaluated once per day
Version handling NoncurrentVersion* actions isLive + numNewerVersions conditions snapshot and version sub-policies
Abandoned multipart AbortIncompleteMultipartUpload AbortIncompleteMultipartUpload no lifecycle action; uncommitted blocks expire after 7 days automatically
Delete protection Object Lock overrides expiration retention policy overrides deletion immutability policy overrides deletion

GCS conditions inside one rule are ANDed, and there is no equivalent of S3’s And wrapper because it is implicit β€” which also means you cannot express OR within a rule and must write two.

// gcs-lifecycle.mjs β€” @google-cloud/storage 7.x
import { Storage } from "@google-cloud/storage";

const storage = new Storage();
const bucket = storage.bucket(process.env.GCS_BUCKET ?? "media-uploads");

await bucket.setMetadata({
  lifecycle: {
    rule: [
      {
        action: { type: "SetStorageClass", storageClass: "NEARLINE" },
        condition: { age: 30, matchesPrefix: ["originals/"], matchesStorageClass: ["STANDARD"] },
      },
      {
        action: { type: "Delete" },
        condition: { age: 2, matchesPrefix: ["staging/"] },
      },
      {
        // Keep the two most recent archived generations, delete anything older.
        action: { type: "Delete" },
        condition: { isLive: false, numNewerVersions: 2, daysSinceNoncurrentTime: 30 },
      },
      { action: { type: "AbortIncompleteMultipartUpload" }, condition: { age: 7 } },
    ],
  },
});

const [metadata] = await bucket.getMetadata();
console.log(`applied ${metadata.lifecycle?.rule?.length ?? 0} GCS lifecycle rule(s)`);

setMetadata on the lifecycle field is a full replacement exactly as on S3, so the read-merge-write discipline from step 3 applies unchanged. One GCS-specific trap: matchesStorageClass is evaluated against the class the object is in now, so omitting it on a SetStorageClass rule creates a rule that keeps matching objects it has already moved. It is a no-op rather than a charge, but it makes the audit log unreadable.

Azure expresses the same thing as a JSON management policy with tierToCool, tierToArchive and delete actions under baseBlob, snapshot and version. Its distinguishing feature is daysAfterLastAccessTimeGreaterThan, which requires last-access tracking to be enabled on the account and gives you genuine access-based tiering without the Intelligent-Tiering monitoring fee β€” at the cost of a write amplification on every read.

Where lifecycle rules fit the upload pipeline

Lifecycle rules are the last stage of a chain that starts in the browser. A file arrives through a signed request issued by your S3 presigned URL workflows, lands in a staging prefix under the direct-to-cloud upload patterns you chose, gets validated and scanned, is promoted to a permanent prefix by a copy, and leaves behind exactly the debris lifecycle rules exist to remove: the staging original, the abandoned parts, the superseded versions and the derivatives nobody requested again.

That framing gives you the design rule. A lifecycle policy should encode the same retention decision your promotion step already makes, expressed in prefixes. If promotion means β€œcopy to originals/ and tag state=live”, then staging expiry is safe at two days and needs no coordination. If promotion means β€œflip a database column and leave the object where it is”, lifecycle rules cannot tell live data from debris and you will eventually delete something real. Prefix separation is not tidiness; it is the only vocabulary the lifecycle engine speaks.

The one place to be deliberately conservative is abuse traffic. A flood of junk uploads is best absorbed at the door by upload rate limiting and abuse protection rather than mopped up by a one-day expiration rule β€” lifecycle deletion is cheap ($0 for the delete itself) but the storage between upload and the next evaluation pass is not, and a rule aggressive enough to clean up an attack fast is aggressive enough to eat legitimate slow uploads.

Frequently Asked Questions

Why has nothing happened 24 hours after I applied my rule?

Eligibility and execution are separate. The object becomes eligible at the UTC midnight boundary after creation plus Days, and S3 then has up to 48 hours to actually perform the action. Check HeadObject for a changed StorageClass before assuming the rule is wrong, and confirm the filter with a literal prefix comparison β€” a missing trailing slash is the most common cause.

Do I get charged for the deletions a lifecycle rule performs?

Expiration DELETE requests are free in S3, and so are the deletes GCS and Azure lifecycle rules issue. Transitions are not: you pay a per-object lifecycle transition request of about $0.01 per 1,000 into the IA classes and $0.05 per 1,000 into Glacier Flexible or Deep Archive, which dominates the arithmetic when you have many small objects.

Can a lifecycle rule move an object back to a warmer storage class?

No. Transitions are one-way down the coldness ordering, and S3 rejects a configuration that tries to go backwards. To warm an object you must restore it (for the Glacier classes) or issue a CopyObject onto itself with the target StorageClass header, which counts as a new PUT and resets the object’s age β€” and therefore its position in every other lifecycle rule.

How do lifecycle rules interact with a CDN in front of the bucket?

Badly, unless you plan for it. Objects served from a CDN edge cache look cold at the origin, so storage class analysis will recommend archiving content that is in fact extremely popular. Use CDN-side request logs rather than origin access data to build the decay curve, and never archive anything an origin-fetch on a cache miss must serve synchronously β€” GLACIER_IR is fine at single-digit milliseconds, Glacier Flexible is minutes to hours and will time out the request.

Is Intelligent-Tiering a replacement for writing transition rules?

For unpredictable access on objects comfortably above 128 KB, yes β€” it removes retrieval fees on the frequent and infrequent tiers and adapts on its own. For upload-derived media, where access decays predictably within weeks, hand-cut transitions are usually cheaper because you avoid the $0.0025 per 1,000 objects monthly monitoring charge, which on ten million objects is $300 a year before any storage at all.