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.

Useful Lifetime vs Default Retention by Artifact Class Three rows compare how long each artifact class is actually useful with how long a default ninety-day retention keeps it. Debug output is useful for about one day but stored for ninety. Deployable bundles are useful for about thirty days and stored for ninety. Compliance records are needed for a year but expire at ninety days. USEFUL LIFETIME (solid) vs DEFAULT 90-DAY RETENTION (outline) Debug output traces, videos, logs useful ~1 day β€” 89 days of pure waste Deployable bundle rollback candidate useful ~30 days β€” roughly right Compliance record provenance, SBOM needed 365 days β€” expires too early, silently day 0 day 45 day 90 One number cannot be right for all three β€” which is why a single default is both expensive and unsafe.

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 STANDARD

Then 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.

Classifying a New Artifact Starting from a new artifact, three questions route it. If a deploy or rollback could need it, store it durably in the release bucket with tiered lifecycle. If an auditor could request it, store it in archival storage for the required period. Otherwise it is debug output and gets one-day retention in the artifact store. new artifact could a rollback ever need it? yes release bucket, keyed by commit tier at 30 days, expire at 400 β€” never the artifact store alone no could an auditor ask for it? yes archival tier, stated period no debug output retention-days: 1 Every upload lands in exactly one branch. An upload with no explicit retention has skipped the question.

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 table

Run 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.

Stored Volume Before and After Tiered Retention A line chart over six months. Under a single ninety-day default, stored artifact volume climbs steadily from 60 to 400 gigabytes. Tiered retention is applied at month three, after which the volume falls and flattens at about 40 gigabytes. 0 100 GB 250 GB 400 GB projected without change tiered retention applied stable β‰ˆ 40 GB β€” one release cycle plus a day of traces M1 M2 M3 M4 M6 The decline is not deletion of anything anyone wanted β€” it is the backlog of expired debug output draining away.

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.

← Back to Artifact Management Strategies for Frontend Builds