Transitioning Media to Cheaper Storage Classes
Keep originals and derivatives in S3 Standard while they are being viewed, then let a lifecycle rule move originals — which are rarely read after processing — to Glacier Instant Retrieval after 30–90 days, and use Intelligent-Tiering for derivatives whose popularity you cannot predict. Filter rules by prefix or tag and by ObjectSizeGreaterThan of at least 128 KB, because small objects are billed as 128 KB in the infrequent-access classes and every transition is a paid request; and never transition objects you will delete within the class’s minimum storage duration, which is charged in full regardless.
Media libraries follow a steep access curve: an upload is viewed heavily for days, occasionally for weeks, and then almost never — but must stay available. Paying Standard prices for years of cold originals is the single largest avoidable cost in most upload-heavy products. This page belongs to cloud storage lifecycle rules in backend validation and cloud storage architecture. The equivalent rules for other providers are in lifecycle policies for GCS and Azure Blob; cleanup of temporary objects is in setting up S3 lifecycle rules for temporary uploads.
When to use this approach
- You store more than a few terabytes of user media and it keeps growing.
- Originals are kept for re-processing or download but served mostly via derivatives.
- Access logs or S3 Storage Lens show most bytes untouched after the first month.
Prerequisites
- An S3 bucket with a key layout that separates originals and derivatives (
originals/,derived/), or object tags that do. - Permission to put a bucket lifecycle configuration (
s3:PutLifecycleConfiguration). - Some access data — S3 Storage Class Analysis on the prefix for 30 days is enough to decide.
- Terraform 1.5+ with the AWS provider 5.x, or the AWS CLI.
The classes that matter for media
Implementation
Tag originals at upload time (or rely on the prefix), then define the rules. Terraform:
resource "aws_s3_bucket_lifecycle_configuration" "media" {
bucket = aws_s3_bucket.media.id
rule {
id = "originals-to-glacier-ir"
status = "Enabled"
filter {
and {
prefix = "originals/"
object_size_greater_than = 131072 # 128 KB: smaller objects cost more in IA/GIR
}
}
transition {
days = 60
storage_class = "GLACIER_IR"
}
noncurrent_version_transition {
noncurrent_days = 30
storage_class = "GLACIER_IR"
}
noncurrent_version_expiration {
noncurrent_days = 120 # past the 90-day minimum, so no early-delete fee
}
}
rule {
id = "derived-intelligent-tiering"
status = "Enabled"
filter {
and {
prefix = "derived/"
object_size_greater_than = 131072 # IT does not monitor objects under 128 KB anyway
}
}
transition {
days = 0 # immediately; IT has no retrieval fees
storage_class = "INTELLIGENT_TIERING"
}
}
rule {
id = "abort-incomplete-multipart"
status = "Enabled"
filter {}
abort_incomplete_multipart_upload { days_after_initiation = 3 }
}
}
# Opt-in archive tiers inside Intelligent-Tiering for derivatives nobody has touched in months.
resource "aws_s3_bucket_intelligent_tiering_configuration" "derived" {
bucket = aws_s3_bucket.media.id
name = "derived-archive"
filter { prefix = "derived/" }
tiering {
access_tier = "ARCHIVE_ACCESS"
days = 180
}
}
The break-even check, as a small script you run before enabling a rule:
// Prices in USD per GB-month / per 1,000 requests / per GB retrieved (us-east-1, check current pricing).
const P = { standard: 0.023, gir: 0.004, transitionPer1k: 0.02, girRetrievalPerGB: 0.03 };
export function monthlySavings(opts: { gb: number; objects: number; retrievedGBPerMonth: number; monthsKept: number }) {
const storageSaved = opts.gb * (P.standard - P.gir);
const retrievalCost = opts.retrievedGBPerMonth * P.girRetrievalPerGB;
const transitionOnce = (opts.objects / 1000) * P.transitionPer1k;
const net = storageSaved - retrievalCost - transitionOnce / opts.monthsKept;
return { storageSaved, retrievalCost, transitionAmortised: transitionOnce / opts.monthsKept, net };
}
console.log(monthlySavings({ gb: 50_000, objects: 20_000_000, retrievedGBPerMonth: 800, monthsKept: 36 }));
// { storageSaved: 950, retrievalCost: 24, transitionAmortised: 11.1, net: 914.9 } per month
Line-by-line on the decisions that matter
- Originals and derivatives in separate rules. They have different access patterns. Originals are read when a user downloads “original quality” or you re-process; derivatives are what players and pages fetch. A single rule for both either leaves originals too warm or pushes derivatives somewhere expensive to read.
object_size_greater_than = 131072. IA and Glacier IR bill anything smaller as 128 KB, and each transition costs a request fee. Thumbnails and small derivatives cost more after transition than before. The filter keeps them in Standard.- 60 days for originals. Long enough that re-processing after an upload (a new rendition, a failed job retried) has finished, and past most of the access curve. Storage Class Analysis tells you the right number for your data.
- Noncurrent versions. With versioning on, overwritten or deleted originals linger as noncurrent versions at full price. Transition and then expire them; expiring after 120 days avoids the early-deletion charge that Glacier IR’s 90-day minimum would otherwise add.
- Intelligent-Tiering for derivatives. Some derivatives go viral a year later. IT moves objects between frequent and infrequent tiers automatically with no retrieval fee, for a small monitoring fee per object; that makes it safe for data you cannot predict.
- Archive tier opt-in. IT’s archive tiers are cheap but require a restore before access. Only enable them where your application handles a restore delay — for derivatives you can regenerate, it is usually easier to delete and re-derive on demand.
How objects move over time
Measuring before and after
Turn on S3 Storage Class Analysis for each prefix and let it run for at least 30 days before choosing transition ages; it reports how much data is accessed at each age and recommends when infrequent access begins. After enabling rules, watch three things in Cost Explorer grouped by usage type: storage by class (should shift), Requests-Tier and transition request charges (a one-off spike), and retrieval charges (should stay small). A retrieval line that grows month on month means a rule moved data that is still warm; lengthen the transition age.
Watch application latency too. Glacier IR and IA serve first bytes in milliseconds, but through a CDN any extra origin latency is hidden only for cached objects. If your app re-processes originals in bulk — a new rendition for the whole library — plan the retrieval cost and run it once, rather than letting a background job trickle through the archive for months.
Designing keys and tags for tiering
Lifecycle filters can match a prefix, object tags and object size — nothing else. That makes key layout a cost decision made on the day you design uploads. Put the object’s role first in the key (originals/, derived/, temp/) rather than the tenant or date, so one rule per role covers the whole bucket. If you already key by tenant first (tenants/<id>/…), use object tags such as role=original set when the object is written; a presigned PUT can require the tag through the signed x-amz-tagging header, and server-side copies can set it directly.
Tags also let you tier by business value rather than age alone. A plan=free tag lets free-tier originals transition after 30 days while paid customers’ stay warm for 90, and a retain=legal tag can exclude held objects from expiration rules entirely. Keep the number of tag values small — each lifecycle configuration is limited to 1,000 rules, and every distinct combination you want to treat differently needs one.
Finally, write the reasoning down next to the rules. Transition ages chosen from a month of Storage Class Analysis look arbitrary a year later; a comment with the analysis date and the observed access curve saves the next person from undoing a good decision.
Configuration gotchas
Transition to IA or GIR fails silently for young objects. S3 requires objects to be at least 30 days old before moving to Standard-IA or One Zone-IA via lifecycle. Rules with days under 30 for those classes are rejected or never apply; Glacier IR and Intelligent-Tiering have no such floor.
Costs went up after enabling a rule. Usually small objects: millions of thumbnails moved and each now bills as 128 KB, plus transition requests. Add the size filter and let Intelligent-Tiering handle anything uncertain.
Deleting users’ data triggers early-deletion fees. Deleting a Glacier IR object before 90 days bills the remaining days. Account for it in your deletion policy, or transition later if many uploads are deleted in their first months.
Rules seem to do nothing for a day. Lifecycle rules run asynchronously, typically once a day; transitions for existing objects can take a day or two to appear.
Verification
aws s3api get-bucket-lifecycle-configuration --bucket media-prod | jq '.Rules[] | {ID, Status, Filter, Transitions}'
aws s3api head-object --bucket media-prod --key originals/2026/07/abc.mov --query StorageClass
# "GLACIER_IR"
aws s3api list-objects-v2 --bucket media-prod --prefix originals/2026/ --query 'Contents[?StorageClass!=`GLACIER_IR`]|length(@)'
Frequently Asked Questions
Should everything go into Intelligent-Tiering?
It is a reasonable default for data you cannot predict, but for originals with a known cold pattern, a fixed transition to Glacier IR is cheaper because IT’s infrequent tiers are priced above Glacier IR and the monitoring fee adds up for millions of objects.
Does transitioning affect presigned URLs or CDN origins?
No. Instant-access classes serve GET requests normally; only Glacier Flexible, Deep Archive and IT’s archive tiers need a restore first.
What about Glacier Deep Archive for originals?
Only if you can tell users that original downloads take up to a day and you have a restore workflow. For most products, the savings over Glacier IR are not worth that experience.