Skip to content
Latchkey LogoLatchkey home

How to free disk space on GitHub Actions runners

To free disk space on a GitHub Actions runner, remove the preinstalled toolchains your job does not use: .NET, the Android SDK and the hosted tool cache were 18.5 GB between them on the 96 GB image we measured. A hosted runner has 14 GB in total, so the share is what matters, and one step at the top of the job reclaims it.

Bar chart of disk reclaimed per directory on a runner, taking free space from 42.5 to 62 GB
Measured on a Latchkey latchkey-small runner on 2026-09-19. A GitHub-hosted runner ships the same toolchains on a 14 GB disk, so the shares matter more than the totals.

Most advice on this problem is a copied block of rm -rf lines with no numbers attached, so nobody knows which lines are earning their keep and which are cargo. This page has the numbers from one runner, measured the same day it was written, and it puts the steps in the order that pays.

It is the companion to no space left on device in GitHub Actions, which covers the failure itself. If your job is failing right now, start there; if you want it to stop failing, start here.

Measure first, in the job that is failing

Runner images change. The directory that was worth 11 GB last quarter may not exist this quarter, and a cleanup block that deletes paths which are already gone reports success while freeing nothing. Spend one run finding out what is actually on the machine.

Three commands answer it. df -h / gives the headroom, du ranked by size names the consumers, and docker system df separates images from build cache, which prune differently.

.github/workflows/ci.yml
df -h /
sudo du -sh /usr/share/dotnet /usr/local/lib/android /opt/hostedtoolcache \
  /usr/share/swift /usr/local/.ghcup /usr/lib/jvm /usr/share/miniconda || true
docker system df

What each directory gave back

These are the figures from one pass on a Latchkey latchkey-small runner on 2026-09-19. The image is not a GitHub-hosted image and its disk is much larger, so read the sizes rather than the free-space totals: the same toolchains ship on the GitHub image, where they compete for 14 GB instead of 96 GB.

DirectorySize on the imageFree space after removing it
(starting point)42.5 GB free of 96 GB
docker system prune -af --volumes0 B reclaimable42.5 GB, unchanged
/usr/share/dotnet5.5 GB48.1 GB
/usr/local/lib/android10.6 GB58.7 GB
/opt/hostedtoolcache2.4 GB61.1 GB
sudo apt-get cleanthe rest62 GB

The order that pays

Remove the large toolchains first. On the image above, .NET, the Android SDK and the hosted tool cache were 18.5 GB between them, and no job that is not building .NET, Android or a pinned tool version needs any of it. Deleting them costs a few seconds.

Prune Docker second, and only if the job pulls or builds images. On a fresh hosted runner it reclaims nothing; on a self-hosted or reused runner it is usually the largest single win, because layers and build cache accumulate across every job the machine has run.

Clean the apt cache third. It is small and free.

Stop there. Each additional path in a cleanup block is another thing that can silently no-op, and the long rm -rf lists that circulate are mostly the same three directories plus paths that no longer exist.

.github/workflows/ci.yml
- name: Free disk space
  run: |
    df -h /
    sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/hostedtoolcache
    sudo apt-get clean
    docker system prune -af --volumes
    df -h /

Use the action, or write the four lines

The community action jlumbroso/free-disk-space does the same job with switches per category, and it is a reasonable choice when you want someone else to track which paths still exist on the current image. It is slower than the four lines above, because by default it removes a great deal more.

Whichever you use, print df -h before and after. A cleanup step that reports nothing is indistinguishable from a cleanup step that freed nothing, and the second one happens more often than people think.

What not to delete

Leave the runner's own work directory, /home/runner/work, alone: that is your checkout. Leave /home/runner/runners alone: that is the agent running the job. Leave the language runtime your job uses alone, which sounds obvious until a cleanup block copied from a Node repository lands in a Python one and removes the toolcache the setup step just populated.

Ordering matters for that last one. Cleanup belongs before the setup steps that install tools, not after, or you will delete the thing you just spent thirty seconds installing.

When freeing space is the wrong fix

Cleanup is a workaround for a machine that is too small. If a job needs the space on every run, the durable answer is a larger disk rather than a ritual at the top of every workflow, and the ritual has a cost: it runs on every job, it fails silently when an image changes, and it is one more thing to maintain.

A managed runner sized for the work removes the step entirely. On a Latchkey runner the disk-full failure is also repaired on the runner: the recorded run in no space left on device in GitHub Actions shows the engine reclaiming space mid-job and the retried step writing the artifact it could not write a minute earlier.

Key takeaways

  • Measure with df -h and du before copying anyone's cleanup block, including this one.
  • Three directories carry most of the win: .NET, the Android SDK and the hosted tool cache.
  • Docker prune is the biggest lever on a reused runner and reclaims nothing on a fresh one.
  • Put the cleanup before the setup steps, never after.

Frequently asked questions

How do I increase disk space on a GitHub Actions runner?
You cannot change the 14 GB a standard hosted runner ships with. You can free some of it by removing preinstalled toolchains the job does not use, and you can move to a larger runner or a managed runner that comes with more disk. Everything else is a way of needing less.
Is it safe to delete /usr/share/dotnet and the Android SDK?
Yes, on an ephemeral hosted runner, as long as the job does not build .NET or Android. The machine is destroyed when the job ends, so nothing you remove affects the next run. On a self-hosted runner that persists, remove them once from the image rather than in every job.
Should I use the free-disk-space action instead?
It is a good default when you do not want to track which paths exist on the current image, and it gives you switches per category. It is slower than removing three directories yourself, because it removes far more by default. Either way, print df -h before and after so you can see what it actually did.
Why did docker system prune free nothing?
Because the runner was fresh and had pulled no images yet. A hosted runner starts with an empty Docker store, so the prune has nothing to reclaim until your own job has pulled or built something. On a reused or self-hosted runner the same command is usually the largest single win.

Related guides

References

Deleting the Android SDK to fit in 14 GB is a workaround. Latchkey runners start with 96 GB. Start free → 30-day trial · No credit card