# How to free disk space on GitHub Actions runners

> Free disk space on a GitHub Actions runner in the order that pays: what to remove, what each directory gives back, and when to stop bothering.

Source: https://latchkey.dev/learn/failures/free-disk-space-on-github-actions-runners  
Updated: 2026-09-19

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.

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](/learn/failures/no-space-left-on-device-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.

| Directory | Size on the image | Free space after removing it |
| --- | --- | --- |
| (starting point) |  | 42.5 GB free of 96 GB |
| `docker system prune -af --volumes` | 0 B reclaimable | 42.5 GB, unchanged |
| `/usr/share/dotnet` | 5.5 GB | 48.1 GB |
| `/usr/local/lib/android` | 10.6 GB | 58.7 GB |
| `/opt/hostedtoolcache` | 2.4 GB | 61.1 GB |
| `sudo apt-get clean` | the rest | 62 GB |

> The Docker prune reclaimed nothing because the runner was fresh and had pulled no images. That is the normal result on a hosted runner and the reason a generic cleanup block can look effective while doing nothing: the line that matters on a reused runner is the line that matters least on a clean one.

## 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](/learn/failures/no-space-left-on-device-github-actions) shows the engine reclaiming space mid-job and the retried step writing the artifact it could not write a minute earlier.

## FAQ

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

## References

- [GitHub-hosted runners: standard runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
- [actions/runner-images: what is installed on the Ubuntu image](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md)
- [jlumbroso/free-disk-space action](https://github.com/jlumbroso/free-disk-space)
- [Docker: docker system prune reference](https://docs.docker.com/reference/cli/docker/system/prune/)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
