# No space left on device in GitHub Actions

> Fix "No space left on device" in GitHub Actions: find what filled the 14 GB, reclaim it before the heavy step, and know when it is inodes.

Source: https://latchkey.dev/learn/failures/no-space-left-on-device-github-actions  
Updated: 2026-09-19

"No space left on device" in GitHub Actions means the runner disk filled up mid-job and the next write failed. Find out which of the three shapes you have, bytes, inodes or watchers, then reclaim space before the step that needs it rather than after.

## What this error means

A step fails the moment it tries to write something, and the wording depends on which tool got there first. `tar: write error: No space left on device` during a cache save, the Node form, "Error: ENOSPC: no space left on device, write", `OSError: [Errno 28] No space left on device` from Python, `failed to register layer` followed by the same phrase from a Docker pull. The step before it usually succeeded, because the disk crossed the line between the two. A confusing variant reports the same errno with plenty of free space in `df -h`: that one is inodes or inotify watchers, not bytes. The run below is on a Latchkey runner with a 96 GB disk rather than a hosted runner's 14 GB; a full filesystem behaves the same way at either size, and the only number that changes is how long it takes to fill.

```Actions log, packaging step
/dev/root        96G   96G  600M 100% /
dd: error writing '/home/runner/build-artifact.tar': No space left on device
600+0 records in
599+0 records out
628097024 bytes (628 MB, 599 MiB) copied, 3.95684 s, 159 MB/s
```

## Common causes

### The job writes more than the runner holds

The ordinary case. A container build, a dataset, a browser download and a packed artifact are each fine on their own and do not fit together in 14 GB. The step that fails is rarely the greedy one; it is whichever step happened to need the last megabyte.

### Docker layers and build cache accumulate through the job

Every pulled image, every intermediate layer and every BuildKit cache entry stays on the disk until something removes it. A multi-stage build that discards its build stage still wrote that stage to disk first, so the peak matters more than the final image size.

### The caches you restore land on the same disk as the work

A restored npm, pip or Gradle cache is spent before the job starts, and a cache save at the end needs room for the archive as well as the originals. In our experience a job that starts failing after a cache key change is usually restoring a much larger cache than the one it used to.

### It is not bytes at all

Inode exhaustion produces the same errno with a half-empty disk, and so does the inotify watch limit, which reports "System limit for number of file watchers reached". The first comes from millions of tiny files, the second from running a watch-mode command in CI. Neither is fixed by deleting large files.

## How to fix it

### Measure before you delete

1. Run `df -h /` and `df -i /` in the failing job to separate blocks from inodes.
2. List the biggest consumers with `du`, so you reclaim the directory that matters rather than the one you remember.
3. Add the same two commands to a failure step permanently: the next occurrence then arrives with its own evidence.

```.github/workflows/ci.yml
- name: Disk state
  if: failure()
  run: |
    df -h /
    df -i /
    sudo du -h -d1 /home/runner /var/lib/docker | sort -h | tail || true
```

### Reclaim space before the heavy step, not after it fails

Cleanup belongs early in the job, where it is cheap and deterministic. Removing the preinstalled toolchains a job does not use is the single biggest win on a hosted runner, and pruning Docker is the biggest one on a reused runner.

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

### Write less per job

Scope the caches to what the job uses, stop uploading artifacts nothing downloads, and stream large downloads into the process that consumes them instead of landing them on disk first. A job that never writes the file never needs the space.

```.github/workflows/ci.yml
- uses: actions/cache@v6
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('package-lock.json') }}
# not: path: node_modules, which is larger and rebuilt anyway
```

### Fix the watcher limit rather than the disk

If the message names file watchers, the disk is a red herring. Run the build command rather than the watch command in CI, and raise the inotify limit only when something genuinely has to watch.

```Terminal
sudo sysctl fs.inotify.max_user_watches=524288
```

## How to prevent it

- Put a cleanup step at the top of any Docker-heavy or dataset-heavy job.
- Cache the package manager's store, not the installed tree.
- Record `df -h` and `df -i` on failure so the next occurrence is diagnosed in one read.
- Right-size the runner when a job needs the space every time: cleanup is a workaround for a machine that is too small.

## Bytes, inodes or watchers: two commands tell you which

ENOSPC is errno 28, and the kernel raises it for more than one reason. Running both forms of `df` before you change anything saves you from fixing the wrong thing.

`df -h` at 100 per cent is the ordinary case: the filesystem is out of blocks. `df -i` at 100 per cent with free space in `df -h` is inode exhaustion, which comes from millions of small files rather than large ones, and no amount of deleting big artifacts fixes it. A message containing "System limit for number of file watchers reached" is neither: that is the inotify limit, it has nothing to do with the disk, and it means a watch-mode command is running in CI where it should not be.

```Terminal
df -h /
df -i /
sudo du -h -d1 /home/runner /var/lib/docker /tmp | sort -h | tail -15 || true
```

## Where the space on a hosted runner goes

A standard GitHub-hosted runner gives the job 14 GB of SSD, and the image arrives with a large share of it already spent on preinstalled toolchains you are probably not using. That is the headroom every other number on this page competes for.

On top of that, a job adds its checkout, its dependency install, everything the caches restore, every Docker layer it pulls, and every artifact it packs before upload. A cache save is particularly easy to underestimate: `tar` writes the archive to the same disk before it uploads it, so a 5 GB cache needs 5 GB free on top of the 5 GB it is archiving.

Freeing space is a routine part of a Docker-heavy job rather than an emergency measure. [Freeing disk space on GitHub Actions runners](/learn/failures/free-disk-space-on-github-actions-runners) has the measured version: which directories are worth removing, and how much each one gave back on a real runner.

## If /tmp or /dev/shm is the thing that filled

Not every full filesystem is the root one. Compilers, linkers and package managers write intermediates to `TMPDIR`, and browsers write shared-memory segments to `/dev/shm`, which on a container defaults to 64 MB. A Chromium test suite that crashes with "Target closed" after a `/dev/shm` write error is hitting that limit and not the disk.

Point `TMPDIR` at the roomiest filesystem you have for the step that needs it, and give containers a larger `/dev/shm` or run the browser with the flag that bypasses it.

```.github/workflows/ci.yml
env:
  TMPDIR: ${{ github.workspace }}/tmp
# docker
docker run --shm-size=1g my-image
# chromium
chrome --disable-dev-shm-usage
```

## What the runner does about it, and what it cannot do

Latchkey's pattern for this failure, `ENOSPC_DISK_FULL`, is the highest-confidence entry in the memory and disk set at 0.99, because the wording is unambiguous: its recorded false-positive risk is only a test assertion that prints the literal string, and its cleanup "is idempotent and will simply do nothing if disk is fine".

The inode variant is treated differently and the difference is worth knowing. The engine carries a separate `INODE_EXHAUSTED` pattern for a create or mkdir that fails with the same errno, and that pattern is in shadow mode: it observes and reports, and it does not yet repair automatically. An inode-exhausted job on any runner still needs you to delete the file-heavy directory yourself.

## FAQ

### How much disk space does a GitHub Actions runner have?

GitHub documents 14 GB of SSD storage for standard hosted runners on Linux, Windows and macOS, for both public and private repositories. That is the whole filesystem, including the preinstalled toolchains, so the space actually available to your job is well under it.

### Why does df show free space but writes still fail?

Because you are out of inodes rather than blocks. Every file consumes one inode, the count is fixed when the filesystem is created, and millions of small files can exhaust it while gigabytes remain free. Run `df -i` to confirm, then delete the directory with the file count rather than the one with the size.

### Why does pulling a Docker layer fail with no space left on device?

Because a pull writes every layer to disk before it composes the image, so the peak is the sum of the layers rather than the size of the final image. A multi-stage build has the same shape: the discarded build stage was written to disk first. Free space before the pull, not after it fails.

### Why did this start failing on ubuntu-latest without a workflow change?

Because the image changed under you. Runner images are rebuilt continuously, and a new preinstalled toolchain or a larger base image takes headroom your job was quietly relying on. A job that sits near the limit fails on the week the image grows, which is why the cleanup step belongs in the workflow rather than in your memory of one bad afternoon.

## References

- [GitHub-hosted runners: standard runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
- [errno(3): ENOSPC, no space left on device](https://man7.org/linux/man-pages/man3/errno.3.html)
- [actions/runner-images#2840: disk space on hosted runners](https://github.com/actions/runner-images/issues/2840)
- [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
