GitHub Actions artifact storage will quietly eat your budget
Nobody warns you that artifact storage in GitHub Actions accumulates forever by default. I learned this the hard way watching a client's bill climb for months.
Artifact storage in GitHub Actions is free until it isn't, and the transition happens gradually enough that you don't notice until you're staring at a GitHub invoice that's crept up three months in a row with no obvious cause.
I hit this with a client running a mid-sized Laravel monorepo. Solid team, good test coverage, deploying a few times a week. Nobody had set retention policies on artifacts. After about eighteen months of CI, they were sitting on 40+ GB of accumulated build artifacts, test reports, and compiled frontend assets. At GitHub's pricing that's real money, and the number was growing every single day.
What artifact storage actually is (and isn't)
When you upload something with actions/upload-artifact, GitHub stores that file or directory in blob storage attached to the workflow run. The default retention period is 90 days for public repos and 90 days for private repos - unless your organization admin has changed it, which most haven't. Ninety days sounds reasonable until you realize a repo doing twenty runs a day is generating a lot of artifacts, and "90 days" means the oldest stuff eventually drops off but your steady-state storage footprint grows to match your throughput.
For repos that upload large artifacts - bundled JS, compiled binaries, Docker layer tarballs, test coverage HTML reports - that steady state can get expensive fast. GitHub charges $0.008 per GB per day for storage beyond the included amount. That's about $0.25 per GB per month. Doesn't sound like much. Multiply by 50 GB and you're at $12/month. By 200 GB you're at $50/month. And I've seen repos with more than that.
The thing that bites people is that storage isn't surfaced anywhere obvious in the GitHub UI. You don't get a storage dashboard per repository. You have to dig into your organization's billing settings or use the API to figure out what's actually sitting there.
The sneaky part: nobody prunes old runs
Developers think about CI costs in terms of compute minutes. That's what GitHub emphasizes in its pricing page. But artifacts are a separate line item, and unlike minutes, they accumulate. Minutes reset monthly. Storage doesn't.
I've audited pipelines for clients where every push to every branch was uploading a vendor/ directory as an artifact "for debugging." Vendor directories in a Laravel app can easily hit 150-200 MB. Three developers pushing five times a day, across feature branches, PRs, and main - that's a lot of artifacts. Most of them are completely useless within 24 hours of the run completing.
The other culprit I see constantly: test reports and coverage artifacts that nobody ever looks at. The team set them up during a compliance push, they proved their point, and now they just sit there generating storage costs forever.
How to actually fix it
There are three levers: retention policies on upload, org-level default retention, and the REST API for cleaning up historical junk.
Set retention on every upload-artifact call
This is the simplest fix and it costs you one line of YAML per upload step:
- name: Upload test coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ github.run_id }}
path: coverage/
retention-days: 7
Seven days is usually more than enough for anything you'd actually go back and look at. For artifacts used between jobs in the same workflow (passing a build output from a build job to a deploy job), you can go as low as 1 day - the workflow finishes the same day, so the artifact is just a relay.
# Short-lived relay artifact between jobs
- name: Upload compiled assets
uses: actions/upload-artifact@v4
with:
name: compiled-assets
path: public/build/
retention-days: 1
For anything that genuinely needs longer retention - say, a release binary that corresponds to a tagged version - be explicit about that too rather than relying on the default:
- name: Upload release binary
uses: actions/upload-artifact@v4
with:
name: myapp-${{ github.ref_name }}
path: dist/myapp.tar.gz
retention-days: 90
Set an org-level default
In your GitHub organization settings under Actions > General, there's a "Artifact and log retention" setting. The default is 90 days. I'd drop it to 30 days for most organizations, and to 7 days if your teams are disciplined about setting explicit retention where they need it longer.
This is a blunt instrument, but it provides a safety net for any workflow that forgets to set a retention policy. You want defense in depth here.
Prune historical artifacts via the API
For existing repos with years of accumulated cruft, you need to go through the REST API. GitHub doesn't give you a bulk-delete button. Here's a quick PHP script I've used to identify and delete old artifacts from a repo:
<?php
$token = getenv('GITHUB_TOKEN');
$owner = 'your-org';
$repo = 'your-repo';
$olderThanDays = 30;
$cutoff = new DateTimeImmutable("-{$olderThanDays} days");
$page = 1;
$deleted = 0;
$freedBytes = 0;
do {
$url = "https://api.github.com/repos/{$owner}/{$repo}/actions/artifacts";
$url .= "?per_page=100&page={$page}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$token}",
"Accept: application/vnd.github+json",
"X-GitHub-Api-Version: 2022-11-28",
"User-Agent: nwos-artifact-pruner",
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
echo "API error: {$status}\n";
break;
}
$data = json_decode($body, true);
$artifacts = $data['artifacts'] ?? [];
foreach ($artifacts as $artifact) {
$createdAt = new DateTimeImmutable($artifact['created_at']);
if ($createdAt >= $cutoff) {
continue;
}
$artifactId = $artifact['id'];
$sizeBytes = $artifact['size_in_bytes'];
$delCh = curl_init("https://api.github.com/repos/{$owner}/{$repo}/actions/artifacts/{$artifactId}");
curl_setopt_array($delCh, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$token}",
"Accept: application/vnd.github+json",
"X-GitHub-Api-Version: 2022-11-28",
"User-Agent: nwos-artifact-pruner",
],
]);
$delStatus = curl_getinfo($delCh, CURLINFO_HTTP_CODE);
curl_exec($delCh);
curl_close($delCh);
if ($delStatus === 204) {
$deleted++;
$freedBytes += $sizeBytes;
echo "Deleted artifact {$artifact['name']} ({$artifactId}), " . round($sizeBytes / 1048576, 1) . " MB\n";
}
}
$page++;
} while (count($artifacts) === 100);
$freedMb = round($freedBytes / 1048576, 1);
echo "Done. Deleted {$deleted} artifacts, freed {$freedMb} MB.\n";
Run this with a fine-grained token that has actions:write on the repo. First pass, point it at your biggest offenders and run it with a dry-run mode (replace the DELETE call with a print statement) so you know what you're getting into.
For the client I mentioned earlier, the first cleanup pass freed about 35 GB. That took their monthly artifact cost from meaningful to negligible almost immediately.
Gotchas I've hit
The API paginates at 100 artifacts per page and rate-limits you. If you have a repo with thousands of artifacts, add a sleep(1) between delete calls or you'll start getting 403s. GitHub's secondary rate limit on mutations is real.
Artifact names aren't unique across runs. If you have an artifact named coverage-report from 500 different runs, the API gives them to you by run, not by name. The ID is what matters for deletion.
The expired flag. Artifacts that have hit their retention date show expired: true in the API response but still count against your storage until GitHub's cleanup job actually removes them. There can be a lag of a day or two. Don't double-count when you're estimating cleanup.
GitHub Packages vs. artifacts. If you're publishing Docker images to GitHub Packages (ghcr.io), that's a separate storage bucket with its own billing. The same problem applies - old image versions accumulate and nobody deletes them. The fix is the same conceptually (retention policies, API cleanup) but it's a different API endpoint (/packages).
When this actually matters
If you're a solo developer with a handful of repos and modest CI usage, none of this is urgent. You're probably within GitHub's free storage tier and the overhead isn't worth thinking about.
This becomes a real problem when:
- You have multiple teams committing to multiple repos with active CI
- You're uploading anything larger than a few MB per run
- You've been running GitHub Actions for more than a year without ever thinking about this
- You're on a GitHub Enterprise or Team plan and noticing that your bill creeps month over month
For organizations in that situation, an artifact hygiene policy is worth formalizing. Add retention-days to your YAML templates, set the org default to something aggressive, and schedule a quarterly API cleanup job as a workflow itself. Twenty minutes of work saves real money.
I would not reach for a third-party tool to manage this. The GitHub API is straightforward enough, and adding a vendor to solve a billing problem you can address with 80 lines of PHP seems like the wrong trade.
The bottom line
GitHub Actions artifact storage is a slow leak, not a burst pipe. By the time you notice it, you've been overpaying for months. Set retention-days on every upload-artifact step you write, change your org default today, and spend an afternoon cleaning up the backlog. It's boring work with a satisfying end: a smaller bill and a cleaner system.
Need help shipping something like this? Get in touch.