Freeing Disk Space on macOS Runners

A macOS Xcode job fills the runner volume with DerivedData, archives and simulator data. Measure the budget, clean up in order, then price the larger label.

Last verified:

A macOS job runs out of disk because the runner volume is 120GB on the 6 vCPU labels and 270GB on the 12 vCPU labels, and the image ships Xcode plus its simulator runtimes on that same volume before your first step runs (cloud runners documentation, checked on 2026-08-13). The fix is to write down a disk budget from the catalog row, delete in an order that removes the largest files with the smallest rebuild cost, and move to the larger label only when the budget still fails to close.

This page gives the budget worksheet, the cleanup order with the commands, the three cleanups that destroy the cache you were trying to keep, and the arithmetic that prices the larger label against the minutes the cleanup steps cost.

Diagnosis

The failure arrives with several different messages, and all of them mean the same thing.

  • error: No space left on device from a compile or link task.
  • Failed to write to /Users/runner/work/.../DerivedData, usually during the archive step, which is the largest single write in the job.
  • Unable to boot device or Failed to install the app from simctl, when the simulator has no room to write its device data.
  • codesign failing on the export, since signing rewrites every bundle in place and needs room for the copy.
  • The cache save step failing at the end of an otherwise green job, because actions/cache writes a compressed archive to a temporary path on the same volume before it uploads. Saving a 28GB DerivedData tree needs room for the tarball on top of the tree itself.

Read the disk correctly before anything else. On macOS the root filesystem is the read-only system volume, so df -h / reports a number that has no relationship to your job. The writable volume is the one to watch:

df -h /System/Volumes/Data

The disk budget

The catalog gives the size of the volume. Your job cares about free space at step one, which is smaller, because the image is built from the matching upstream GitHub runner image and carries the same preinstalled tooling (preinstalled software documentation). Write the budget as a subtraction and every later decision on this page falls out of it.

Line itemPathHow to measureExample GB
Free space at step onedata volumedf -h /System/Volumes/Data90
Checkout, including LFS objects$GITHUB_WORKSPACEdu -sh .4
Resolved package checkoutsSourcePackagesdu -sh SourcePackages6
DerivedData after build-for-testingDerivedDatadu -sh DerivedData28
Simulator devices, logs, caches~/Library/Developer/CoreSimulatordu -sh ~/Library/Developer/CoreSimulator6
Archive, exported IPA, dSYM bundlesbuild/du -sh build12
Cache archive written before uploadtemp path on the same volumesize of the tree you save12
Headroom left22

The GB column is a placeholder for the arithmetic rather than a measurement. Replace every row with your own du -sh output from one instrumented run, because the ratios differ enormously between a two-module app and a workspace with sixty targets.

Three things push a budget that used to close over the edge, and none of them show up in a diff:

  1. A runtime downloaded at job time. xcodebuild -downloadPlatform iOS or a -destination that names a runtime the image does not carry pulls several GB onto the volume mid-job.
  2. Keeping the archive and the export together. The .xcarchive holds the binary and its dSYMs, and -exportArchive writes a second signed copy alongside it.
  3. A cache save added later. The tarball is a second full-size write that the job did not make last month.

macOS runners do not support nested virtualization and cannot run Docker (cloud runners documentation), so container images are never a line in this budget. Container work belongs on remote Docker builders, which sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the surface around the runner.

Fix

Delete in this order. It is sorted by space returned per unit of rebuild cost, so the first two steps are the ones to try before anything else.

1. Unused Xcode bundles. The largest single items on the image, and your job rebuilds none of them. Pin the toolchain first so a later step cannot reach for a bundle you removed.

KEEP=/Applications/Xcode_27.0.app
sudo xcode-select -s "$KEEP"
echo "DEVELOPER_DIR=$KEEP/Contents/Developer" >> "$GITHUB_ENV"
for app in /Applications/Xcode*.app; do
  [ "$app" = "$KEEP" ] || sudo rm -rf "$app"
done

2. Simulator runtimes and devices you do not test against. Runtimes ship inside the Xcode bundles and as standalone bundles; devices and their data accumulate under CoreSimulator.

xcrun simctl runtime list
xcrun simctl runtime delete <build-or-id>
xcrun simctl delete unavailable
rm -rf ~/Library/Developer/CoreSimulator/Caches
rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport
rm -rf ~/Library/Developer/Xcode/watchOS\ DeviceSupport

3. DerivedData index store and logs. The index store exists for the Xcode editor and has no consumer in a workflow, and the logs grow on every run. Both are safe to remove before the cache save step, and removing them shrinks the tarball too.

rm -rf DerivedData/Index.noindex DerivedData/Logs

4. The archive, once the export is done. Keep the IPA and the dSYMs, drop the .xcarchive they came from, and drop the stale archives Xcode keeps by default.

rm -rf build/App.xcarchive ~/Library/Developer/Xcode/Archives

5. Build intermediates, but only after the cache save step. Build/Intermediates.noindex and ModuleCache.noindex are what a warm restore reuses. Deleting them mid-job returns space and pays for it on the next run.

6. Package checkouts, last. Cheapest to lose in bytes and most expensive to lose in minutes, because dropping them means a fresh resolve on the next job that needs them.

Which cleanups break the cache you wanted to keep

CleanupSpace classWhat it breaks
rm -rf DerivedData/Index.noindex DerivedData/LogsLargeNothing. No step reads either directory.
rm -rf DerivedData/Build/Intermediates.noindex before the save stepLargeThe incremental object files a warm restore reuses. The next run recompiles from source.
rm -rf DerivedData/Build/ModuleCache.noindex before the save stepMediumPrecompiled module output. Same result: a full recompile on the next run.
rm -rf SourcePackagesMediumThe resolve cache keyed on Package.resolved, which puts the clone and resolve phase back into every job.
Removing an Xcode bundle after the key generation stepLargeThe cache key. xcodebuild -version reports a different toolchain, so the entry saved from main no longer matches and the job restores nothing.
xcrun simctl delete allSmallPre-created devices only. The next -destination recreates one and pays the boot time.

The ordering rule that follows from the table: everything in class "breaks the cache" runs after actions/cache/save, and everything else runs before it. Directory by directory reasoning for what belongs in the archive is in caching Xcode DerivedData in GitHub Actions.

Configuration

A build and archive job with the budget instrumented, the safe cleanups placed before the save step, and the destructive ones after it.

name: ios-release

on:
  push:
    branches: [main]

jobs:
  archive:
    runs-on: warp-macos-26-arm64-6x
    steps:
      - uses: actions/checkout@v4

      - name: Pin the toolchain and reclaim the unused Xcode bundles
        run: |
          KEEP=$(ls -d /Applications/Xcode_27*.app | tail -1)
          sudo xcode-select -s "$KEEP"
          echo "DEVELOPER_DIR=$KEEP/Contents/Developer" >> "$GITHUB_ENV"
          for app in /Applications/Xcode*.app; do
            [ "$app" = "$KEEP" ] || sudo rm -rf "$app"
          done
          xcrun simctl delete unavailable
          rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport

      - name: Record the starting budget
        run: df -h /System/Volumes/Data

      - name: Compute the cache key from the pinned toolchain
        id: toolchain
        run: echo "id=$(xcodebuild -version | tr '\n' '-' | tr -d ' ')" >> "$GITHUB_OUTPUT"

      - name: Restore DerivedData
        uses: actions/cache/restore@v4
        with:
          path: DerivedData
          key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }}
          restore-keys: dd-${{ steps.toolchain.outputs.id }}-

      - name: Archive
        run: |
          xcodebuild archive \
            -workspace App.xcworkspace \
            -scheme App \
            -configuration Release \
            -destination 'generic/platform=iOS' \
            -derivedDataPath DerivedData \
            -clonedSourcePackagesDirPath SourcePackages \
            -archivePath build/App.xcarchive

      - name: Prune what the cache should never carry
        run: |
          rm -rf DerivedData/Index.noindex DerivedData/Logs
          du -sh DerivedData SourcePackages build
          df -h /System/Volumes/Data

      - name: Save DerivedData
        uses: actions/cache/save@v4
        with:
          path: DerivedData
          key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }}

      - name: Reclaim before the export
        run: |
          rm -rf DerivedData/Build/Intermediates.noindex
          rm -rf DerivedData/Build/ModuleCache.noindex
          df -h /System/Volumes/Data

      - name: Export
        run: |
          xcodebuild -exportArchive \
            -archivePath build/App.xcarchive \
            -exportOptionsPlist ExportOptions.plist \
            -exportPath build/export

      - name: Drop the archive once the export exists
        run: rm -rf build/App.xcarchive

      - uses: actions/upload-artifact@v4
        with:
          name: ipa
          path: build/export

Four facts decide whether this lands cleanly:

  1. Runner storage is ephemeral. The volume is deleted when the runner terminates, so everything the next job needs travels through an artifact or a cache.
  2. warp-macos-latest-arm64-6x resolves to warp-macos-15-arm64-6x. Pin the explicit version label on a release branch so the alias moving does not change the toolchain mid-release.
  3. The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released.
  4. macOS 13 labels were removed on June 8, 2026, so a workflow that still names one queues without a match.

Every label, size, and rate is in the cloud runners documentation, and WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners under the same warp- label scheme.

Cost or Time Model

The size option, priced

LabelvCPUMemoryStoragePer minute
warp-macos-26-arm64-6x622GB120GB SSD$0.08
warp-macos-26-arm64-12x1244GB270GB SSD$0.16

The 12 vCPU label doubles the disk and doubles the rate (pricing page, checked on 2026-08-13). Per vCPU-minute the two cost the same, so the extra 150GB is priced at the extra $0.08 per minute rather than sold separately.

Put the two options against each other on a 20-minute archive job that runs 300 times a month:

  • Baseline on the 6 vCPU label: 300 x 20 x $0.08 = $480.00 per month.
  • Same job on the 12 vCPU label, same 20 minutes: 300 x 20 x $0.16 = $960.00 per month, so the disk costs $480.00.
  • Same job on the 6 vCPU label with three cleanup steps adding 2.5 minutes: 300 x 22.5 x $0.08 = $540.00 per month, so the cleanup costs $60.00.

Cleanup wins on that shape by $420.00 per month, which is the usual answer on the invoice. Reruns move the number but rarely reverse it. A job that dies on disk at the archive step has already paid for the whole build and pays again on the rerun: at a 5 percent failure rate that is 15 reruns x 22.5 minutes x $0.08 = $27.00 and roughly 340 minutes of waiting. For the cleanup path to reach the $960.00 of the wider label, reruns would have to add $420.00, which is 5,250 minutes, or 233 failed jobs out of 300.

So the wider label is a wall clock and reliability decision rather than a cost saving. It buys 150GB more headroom and removes the cleanup steps from the critical path for $480.00 more per month on this job shape. Take it when the working set genuinely exceeds 120GB, and take the cleanup steps when most of the working set is deletable.

Wall clock matters as much as the invoice. Cleanup steps run serially before and after the work you care about, so 2.5 minutes on 300 jobs is 12.5 hours per month of pull requests sitting in the queue behind a rm -rf.

The baseline this replaces

GitHub documents 14 GB of SSD storage on its hosted macOS runners, standard and larger alike (GitHub-hosted runners reference, checked on 2026-08-13). A job that has been engineered to survive 14 GB usually needs no cleanup at all at 120GB, so the first thing to try after a label change is deleting the cleanup steps and rerunning the budget.

The price side is direct arithmetic: warp-macos-latest-arm64-6x at 6 vCPU, 22GB and a 120GB SSD costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14GB, which is 22 percent lower list price, GitHub list price checked on 2026-08-13.

Sizes and shapes per label are on the macOS runner sizes page, and the full catalog is on the macOS runner hub.

FAQ

Why does a macOS runner run out of disk when the catalog says 120GB?

The catalog figure is the size of the volume, and the image ships Xcode, the bundled simulator runtimes, and the rest of the preinstalled toolchain on that same volume before step one runs. Free space at step one is the number that matters, and only df tells you what it is. Print df -h /System/Volumes/Data as the first step, because df -h / reports the read-only system volume rather than the writable data volume where the checkout and DerivedData live. The per-label volume sizes are in how much disk do macOS GitHub Actions runners have.

What should I delete first, and what is safe to delete?

Unused Xcode bundles and unused simulator runtimes first, since they are the largest items on the image and your job rebuilds none of them. Then DerivedData/Index.noindex and DerivedData/Logs, which no workflow step reads. Then the .xcarchive once the IPA and the dSYMs are exported. Pin DEVELOPER_DIR before removing any Xcode bundle so a later step cannot reach for one that is gone.

Which cleanups break the cache I wanted to keep?

Three. Deleting Build/Intermediates.noindex or ModuleCache.noindex before the cache save step throws away the incremental object files and module output that make the next run fast. Deleting SourcePackages costs a fresh package resolve on the next job. And changing which Xcode bundle DEVELOPER_DIR points at after the key generation step changes the toolchain identifier in the cache key, so the entry saved from main no longer matches.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.