Skip to content
Latchkey LogoLatchkey home

No space left on device in GitHub Actions

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

Runner log: a write failing on a full disk, then the retry writing 1.2 GB after cleanup
The recorded run: the disk is at 100 per cent and the write fails, the runner reclaims space, and the retried step writes the 1.2 GB artifact.
Diagram of the three shapes of ENOSPC on a runner, with the fix and the runner action
The same errno covers three different problems. df -h and df -i tell them apart in two seconds, and they have different fixes.

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

Reproduced on a Latchkey runner

Run 2026-09-19·Runner latchkey-small·Exit code 0·Repaired by ENOSPC_DISK_FULL

/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
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)
/dev/root        96G   54G   42G  57% /
packaged 1.2G artifact
1200+0 records in
1200+0 records out
1258291200 bytes (1.3 GB, 1.2 GiB) copied, 0.6136 s, 2.1 GB/s

Freed disk space via docker/npm/pip/tmp/gradle/cargo/terraform cache cleanup and retried after ENOSPC

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

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

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

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.

Frequently asked questions

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.

Related guides

References

A full disk is the runner's problem, not your code's. Latchkey reclaims the space mid-job and retries the write. Start free → 30-day trial · No credit card