Caching Xcode DerivedData in GitHub Actions
Which DerivedData subdirectories restore safely on GitHub Actions, how to key and prune the archive so it fits the cache allowance, and the minutes it returns.
Last verified:
Caching Xcode DerivedData on GitHub Actions pays off when you restore the four subdirectories that survive a fresh machine and delete the two that only add weight, then key the archive on the toolchain so a mismatched restore cannot poison the build. This page maps DerivedData directory by directory, gives the workflow that prunes, keys, and restores it, and models the minutes a warm restore returns at the macOS per-minute rate.
Diagnosis
DerivedData is the single tree Xcode writes everything into: compiled object files, module output, the build database, the package checkouts, the index store, and the logs. Every GitHub Actions job starts on a fresh machine with an empty tree, so the compiler redoes the work it finished on the previous run. Restoring the whole tree without thinking about it produces one of two bad outcomes: an archive too large for the cache allowance, or a build that fails in a way that looks unrelated to caching.
What lives where
| Subdirectory | Contents | Restore | Failure mode if you get it wrong |
|---|---|---|---|
Build/Products | Built .app, .xctest, and dSYM bundles per configuration | Yes | A tree saved from Release restored into a Debug build links stale products |
Build/Intermediates.noindex | Object files, .swiftmodule output, and the XCBuildData build database | Yes | This is the incremental payload; a toolchain mismatch here is what most stale builds actually are |
ModuleCache.noindex | Precompiled clang modules | Yes, with the toolchain in the key | Surfaces as a module compiled by a different compiler version, mid-build |
SourcePackages | SwiftPM checkouts, binary artifacts, workspace-state.json | Yes, keyed on Package.resolved | A partial restore leaves workspace-state.json pointing at checkouts that are not on disk |
Index.noindex | Index store for editor navigation | No | Often the largest directory in the tree, with no consumer in a workflow |
Logs | .xcactivitylog build logs | No | Grows every run and inflates the archive for nothing |
Deleting Index.noindex and Logs before the save step is the single change that decides whether the archive fits. GitHub documents a per-repository cache allowance and least-recently-used eviction once that allowance is passed, so an unpruned DerivedData tree from a mid-size app can evict every other entry the repository depends on. See GitHub's caching reference, checked on 2026-08-13.
The timestamp problem
A cache that restores cleanly can still deliver a full rebuild. The Xcode build system records the size and modification time of every input file in its build database, and actions/checkout writes the current time onto every file it materializes. Restored object files therefore look older than the sources that produced them, and the compiler rebuilds them.
The half of the graph that reuses reliably is the package dependency half, because those sources arrive from the restored SourcePackages tree with the timestamps the archive carried rather than from the checkout. For most app repositories that is where the module count is highest, which is why a restore that appears to do nothing for first-party code still cuts several minutes.
Two more mismatches produce stale-build failures rather than slow builds. DerivedData records absolute paths, so the workspace path has to be identical between save and restore. And Xcode invalidates module output across versions, so a DerivedData tree from a different Xcode costs the compiler the time to read it before it discards it.
Fix
Prune, then save. Remove Index.noindex and Logs in the step before the save. Add COMPILER_INDEX_STORE_ENABLE=NO to the build so the index store never gets written in the first place.
Put the toolchain in the key. Generate a key component from xcodebuild -version in its own step. It costs a second and removes the entire class of module-version failures.
Split restore from save and save from main only. Use actions/cache/restore and actions/cache/save as separate steps, with the save guarded on the default branch. Pull request jobs then read the last main build and never write, which keeps the allowance from filling with one entry per open branch.
Keep the workspace path stable. Set -derivedDataPath to a fixed location under github.workspace and use the same expression in the cache path. Do not check out into a per-run directory.
Restore modification times for unchanged sources. After checkout, set each tracked file back to its last commit time so the build database sees the sources it recorded. Restrict it to files that did not change since the cached commit, because it runs one git log per file.
git ls-files -z | while IFS= read -r -d '' f; do
ts=$(git log -1 --format=%cd --date=format:%Y%m%d%H%M.%S -- "$f")
[ -n "$ts" ] && touch -t "$ts" "$f"
doneMeasure the hit in minutes rather than as a boolean. cache-hit being true says only that the archive downloaded. Compare build-step duration across runs that hit and runs that missed. CI observability reports CPU and memory P75 and P90 per job alongside duration, which tells you whether a warm job became compute bound or spent its time on the restore.
Then pick the machine. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so the change for an Xcode job is the value of runs-on. On WarpBuild runners WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4; cache entries carry a version derived from the compression tool and the cached paths, so a macOS DerivedData entry restores on macOS runners only. The details are in the caching documentation.
Configuration
name: ios-build
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
DERIVED_DATA: ${{ github.workspace }}/.derived-data
SPM_CHECKOUTS: ${{ github.workspace }}/.spm-checkouts
jobs:
build:
runs-on: warp-macos-15-arm64-6x
steps:
- uses: actions/checkout@v4
- name: Record the toolchain in the cache key
id: toolchain
run: echo "id=$(xcodebuild -version | tr '\n' '-' | tr -d ' ')" >> "$GITHUB_OUTPUT"
- name: Restore source timestamps
run: |
git ls-files -z | while IFS= read -r -d '' f; do
ts=$(git log -1 --format=%cd --date=format:%Y%m%d%H%M.%S -- "$f")
[ -n "$ts" ] && touch -t "$ts" "$f"
done
- name: Restore DerivedData
id: dd
uses: actions/cache/restore@v4
with:
path: |
${{ env.DERIVED_DATA }}
${{ env.SPM_CHECKOUTS }}
key: dd-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }}-${{ github.sha }}
restore-keys: |
dd-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }}-
dd-${{ steps.toolchain.outputs.id }}-
- name: Build for testing
run: |
xcodebuild build-for-testing \
-scheme App \
-destination "platform=iOS Simulator,name=iPhone 16" \
-derivedDataPath "$DERIVED_DATA" \
-clonedSourcePackagesDirPath "$SPM_CHECKOUTS" \
-onlyUsePackageVersionsFromResolvedFile \
-disableAutomaticPackageResolution \
-showBuildTimingSummary \
-quiet \
COMPILER_INDEX_STORE_ENABLE=NO \
DEBUG_INFORMATION_FORMAT=dwarf \
ONLY_ACTIVE_ARCH=YES \
SWIFT_COMPILATION_MODE=incremental \
CODE_SIGNING_ALLOWED=NO
- name: Prune the tree before saving
if: github.ref == 'refs/heads/main'
run: rm -rf "$DERIVED_DATA/Index.noindex" "$DERIVED_DATA/Logs"
- name: Save DerivedData
if: github.ref == 'refs/heads/main'
uses: actions/cache/save@v4
with:
path: |
${{ env.DERIVED_DATA }}
${{ env.SPM_CHECKOUTS }}
key: dd-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }}-${{ github.sha }}Three settings in that file carry the incremental behavior. SWIFT_COMPILATION_MODE=incremental keeps the compiler working per file so restored object files can be reused, since whole-module optimization recompiles the module whenever any file in it moves. ONLY_ACTIVE_ARCH=YES builds one slice, which halves the object output that has to travel through the cache. DEBUG_INFORMATION_FORMAT=dwarf skips dSYM generation, which a test build has no use for.
The restore-keys ladder matters as much as the key. A pull request misses its own SHA, falls back to the same toolchain and the same resolved package graph, and only then falls back to the toolchain alone. That last rung is the one that hands you a tree from before a dependency bump, which restores but compiles more than you expect.
Cost or Time Model
macOS sizes and rates, from the cloud runners documentation:
| Label | vCPU | Memory | Storage | Rate per minute |
|---|---|---|---|---|
warp-macos-26-arm64-6x | 6 | 22GB | 120GB SSD | $0.08 |
warp-macos-15-arm64-6x | 6 | 22GB | 120GB SSD | $0.08 |
warp-macos-15-arm64-12x | 12 | 44GB | 270GB SSD | $0.16 |
Assumptions, stated so you can substitute your own measurements:
- One iOS repository, 600 Xcode jobs per month, on a 6 vCPU macOS runner at $0.08 per minute.
- Clean job: 14.0 minutes of compile and link with an empty DerivedData.
- Warm job: 1.2 minutes to download and unpack a pruned 4.5GB archive, then 6.5 minutes of compile and link, so 7.7 minutes total.
- Pull request jobs restore only. The save cost lands on main builds and is excluded from both columns.
| Configuration | Minutes per month | Rate per minute | Monthly cost |
|---|---|---|---|
| Clean build every run | 8,400 | $0.08 | $672.00 |
| Warm DerivedData restore | 4,620 | $0.08 | $369.60 |
The arithmetic: 600 x 14.0 = 8,400 minutes at $0.08 is $672.00, and 600 x 7.7 = 4,620 minutes at $0.08 is $369.60, a $302.40 monthly difference and 3,780 minutes of engineers no longer waiting on a pull request.
The break-even is one line: the cache pays when the compilation it removes exceeds the time the restore costs. In the model above the restore costs 1.2 minutes and removes 7.5 minutes, so the margin is wide. Halve the compilation saving and the margin narrows to 5.3 minutes; double the archive size on a repository that never pruned Index.noindex and the restore alone can eat the whole gain. Log the restore duration as its own step so this number stays visible.
The runner rate is the other half of the same invoice. warp-macos-latest-arm64-6x at 6 vCPU and 22GB 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 from GitHub Actions per-minute rates. The same 4,620 warm minutes are $471.24 at the GitHub-hosted rate against $369.60 here. Rates per size are on the pricing page.
Where the model breaks: a pull request that touches a low-level module invalidates everything downstream of it, and the warm column moves toward the clean one. Track the split by comparing build-step duration on runs that restored against runs that missed, rather than assuming the average. The wider macOS runner catalog covers the size decision, cutting Xcode build times on GitHub Actions covers the phases outside compilation, and iOS pipelines on GitHub Actions covers the full workflow shape.
FAQ
Which DerivedData subdirectories should I cache?
Build/Products, Build/Intermediates.noindex, ModuleCache.noindex, and SourcePackages. Delete Index.noindex and Logs before the save step, because the index store exists for the Xcode editor and has no consumer in a workflow, and the logs grow on every run. Pruning those two usually removes the largest part of the archive.
Why did my build still recompile everything after the cache restored?
The Xcode build system records the size and modification time of every input in its build database, and actions/checkout writes the current time onto every tracked file. Restored object files then look older than the sources they came from, so the compiler rebuilds them. Restore modification times from git commit dates for files that have not changed since the cached commit, or expect the reuse to come mostly from the package dependency half of the graph.
What makes a restored DerivedData produce a stale-build failure?
Three mismatches. A different Xcode version, which invalidates the precompiled module cache and surfaces as a module built by a different compiler version. A different absolute workspace path, since DerivedData records absolute paths. And a partially restored SourcePackages tree, where workspace-state.json points at checkouts that are missing. Putting the toolchain identifier and the Package.resolved hash in the cache key removes the first two classes. The shorter version of this answer is at can I keep DerivedData between GitHub Actions runs.
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.