Setting Artifact Retention Policies to Control Storage Costs
Your artifact storage bill grew fivefold in a year, nobody changed anything deliberately, and the largest contributor turns out to be Playwright traces from pull requests closed six weeks ago. Retention is one of the few CI costs that is pure waste β the bytes are not making anything faster, safer, or more debuggable after the first day.
When to use this pattern
- Artifact storage is a visible line item, or you have hit a repository storage quota.
- Every workflow uploads with the default retention because nobody ever set one.
- You cannot currently say which workflow is responsible for most of the stored bytes.
Prerequisites
Not all artifacts are the same thing
The mistake behind almost every oversized artifact bill is a single retention number applied to categories with wildly different lifetimes. A trace file is read within hours or never; a signed release bundle may be needed for a rollback months later; an audit record has a retention period someone else decided.
Note the third row: a single default is not only wasteful, it is also under-retentive for the artifacts that matter most, and that failure is silent until someone needs the record and finds it gone.
Complete working example
# .github/workflows/ci.yml β explicit retention per artifact class.
name: CI
on: [pull_request, push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx playwright test --reporter=blob
# Class 1 β debugging output. Read within hours of the run or never.
- uses: actions/upload-artifact@v4
if: always()
with:
name: traces-${{ github.run_id }}
path: blob-report/
retention-days: 1
# Compression costs a few seconds and typically halves trace size.
compression-level: 9
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci --ignore-scripts && npm run build
- run: tar -czf dist.tar.gz dist/
# Class 2 β deployable bundle. In transit only; the durable copy goes to S3 below.
- uses: actions/upload-artifact@v4
with:
name: bundle
path: dist.tar.gz
retention-days: 7
# Class 3 β the durable, versioned home for anything that might be rolled back to.
- run: |
aws s3 cp dist.tar.gz \
"s3://shop-releases/${GITHUB_REF_NAME}/${GITHUB_SHA}.tar.gz" \
--storage-class STANDARDThen make the object store expire what the workflow no longer manages:
{
"Rules": [
{
"ID": "release-bundles-tier-then-expire",
"Filter": { "Prefix": "main/" },
"Status": "Enabled",
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_IR" }
],
"Expiration": { "Days": 400 }
},
{
"ID": "preview-bundles-expire-fast",
"Filter": { "Prefix": "preview/" },
"Status": "Enabled",
"Expiration": { "Days": 7 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
}
]
}Step-by-step walkthrough
Lower the repository default first. It is the single highest-leverage change, because it applies to every workflow including the ones nobody remembers writing. Setting the Actions retention default to 7 days immediately stops the accumulation, and any artifact that genuinely needs longer will announce itself when someone cannot find it.
Name the class in the retention, not in a comment. retention-days: 1 next to a trace upload is self-documenting; a policy document nobody reads is not. Reviewers can then see, in the diff, when a new upload has no explicit retention.
Move durable copies out of the artifact store. Artifact stores are designed for job-to-job hand-off, with automatic expiry and per-repository quotas. A release you might roll back to belongs in an object store with lifecycle rules and a storage class you chose, not in a hand-off buffer that quietly deletes things.
Tier before you expire. Infrequent-access and archival storage classes cost a fraction of standard storage, and rollback candidates older than a month are, by definition, infrequently accessed. Tiering at 30 days and expiring at 400 typically cuts the release-bucket bill by 60β70% while keeping a full year of rollback targets.
Classifying a new artifact takes about ten seconds if you ask the questions in the right order, and the answer determines both the retention and where the durable copy belongs.
Abort incomplete multipart uploads. Failed large uploads leave parts behind that are billed as storage and invisible in the consoleβs object listing. A one-day abort rule removes a cost category most teams do not know they have.
Verification
# 1. Where are the bytes? Largest artifacts across recent runs.
gh api -X GET /repos/acme/shop/actions/artifacts --paginate \
--jq '.artifacts[] | [.size_in_bytes, .name, .expires_at] | @tsv' \
| sort -rn | head -20 | awk '{printf "%6.1f MB %-30s %s\n", $1/1048576, $2, $3}'
# 2. Total currently stored, in gigabytes.
gh api -X GET /repos/acme/shop/actions/artifacts --paginate \
--jq '[.artifacts[].size_in_bytes] | add' | awk '{printf "%.2f GB\n", $1/1073741824}'
# 3. The lifecycle rules are actually applied to the bucket.
aws s3api get-bucket-lifecycle-configuration --bucket shop-releases \
--query 'Rules[].{id:ID,prefix:Filter.Prefix,expire:Expiration.Days}' --output tableRun the first command before and a fortnight after the change. A healthy result is a total that stops growing and a top-20 list dominated by release bundles rather than traces. If traces still lead, some workflow is uploading without an explicit retention-days and inheriting the old default.
Common pitfalls
Expiring the artifact you roll back to. If the only copy of a releasable bundle lives in the artifact store with a 7-day retention, a rollback attempted on day eight fails. Keep the durable copy in the object store, keyed by commit, as in the example above β and cross-check the retention against the rollback window used by automated rollback triggers.
Deleting preview assets that live sessions still need. Preview environments have their own teardown story; expiring their bundles on a fixed clock can pull assets out from under an open browser tab. Coordinate with automating preview environment teardown rather than adding a second, independent timer.
Measuring cost instead of bytes. Storage pricing changes and tiering muddies the signal; stored bytes per workflow does not. Track the byte total so a regression is attributable to the pull request that introduced it.
Uploading uncompressed traces and videos. Playwright video and trace output compresses extremely well, and compression-level: 9 costs a few seconds on a job that has already run for minutes. Teams routinely halve their debug-artifact volume with that one line before touching retention at all.
Assuming a lifecycle rule applies retroactively the moment you save it. Object stores evaluate lifecycle rules asynchronously, typically once a day, so the bucket will not shrink immediately and the first instinct is to conclude the rule is wrong. Check again after 48 hours before changing anything.
Related
- Artifact Management Strategies for Frontend Builds β the parent guide on producing and promoting build output.
- Versioning Frontend Build Artifacts with Content Hashes β why previous releases must stay reachable for a while.
- Tracking CI/CD Compute Costs for Platform Teams β the compute half of the same budget.
- CI/CD Pipeline Architecture & Fundamentals β the section overview.
β Back to Artifact Management Strategies for Frontend Builds