Xcode build cache: principles and hit-condition reference table
On Apple platforms, most of what people call “Xcode build cache” is really a bundle of stores under Derived Data: Swift incremental state, Clang module caches, PCH artifacts, index data, and per-workspace SourcePackages when you use Swift Package Manager. A cache hit means the compiler can skip work because inputs (source, flags, SDK, module maps) match a previous fingerprint. On a remote Mac shared by many jobs, misses often come from path churn, Xcode upgrades, switching -derivedDataPath, or cleaning intermediates while leaving metadata inconsistent.
Use the table below as a quick diagnostic: if your symptom matches the left column, the usual miss reason and stabilizing parameter are on the right.
| Symptom in iOS CI | Likely miss reason | Stabilizing parameter or practice |
|---|---|---|
| Full recompile after every job | Ephemeral DerivedData path or random temp dir |
Fixed -derivedDataPath per project slug + stable checkout path |
| SPM re-resolves every run | Networked resolve or deleted SourcePackages |
Persist Derived Data folder; use -clonedSourcePackagesDirPath + commit Package.resolved |
| “Module not found” after cache restore | Mixing Xcode versions or SDK paths | Pin DEVELOPER_DIR; invalidate cache on Xcode bump |
| Slow link phase but fast compile | Cold LTO or bitcode pipeline (if enabled) | Disable unused modes in CI schemes; keep Debug for PR validation |
| High CPU in “Indexing” | Index store enabled for CI builds | COMPILER_INDEX_STORE_ENABLE=NO for pure CI compile jobs |
Copy-paste: isolate Derived Data per repo on the runner
export DEVELOPER_DIR="/Applications/Xcode_16.2.app/Contents/Developer"
export DERIVED_DATA_CI="$HOME/ci-derived-data/${CI_PROJECT_NAME:-myapp}"
mkdir -p "$DERIVED_DATA_CI"
xcodebuild -scheme "MyApp" \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$DERIVED_DATA_CI" \
-clonedSourcePackagesDirPath "$DERIVED_DATA_CI/SourcePackages" \
build
Pair this with a cache key that hashes Package.resolved, Podfile.lock (if CocoaPods), .xcode-version or runner image tag, and the Xcode app path. When any of those change, expect a deliberate miss rather than chasing “flaky cache.”
Remote node pre-warm and cleanup strategy
Pre-warm reduces the first-build penalty after a runner image update or cold disk: run one full xcodebuild of your default scheme against the simulator destination you use in CI, then keep the resulting Derived Data directory on fast local SSD. For teams that share a remote Mac, schedule pre-warm after Xcode installation and whenever the default SDK changes.
Cleanup must be automated; manual deletes do not scale across dozens of branches. Prefer LRU-style eviction of ~/ci-derived-data/* by last-modified time rather than wiping everything on each job, unless compliance requires ephemeral disks.
| Phase | Goal | Executable action |
|---|---|---|
| Pre-warm | Populate modules & SPM checkouts | Nightly xcodebuild build on main with production -destination; persist $DERIVED_DATA_CI |
| Between jobs | Prevent unbounded growth | find "$HOME/ci-derived-data" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + (tune days) |
| Simulators | Reclaim disk from old runtimes | xcrun simctl delete unavailable; delete unused runtimes in Xcode Settings if allowed |
| SPM | Avoid corrupted checkouts | On resolve failure after network blip, remove only SourcePackages/checkouts for the failing package, then rebuild |
Node stability improves when CPU thermal throttling and disk pressure are rare: keep at least 15–20 GB free on the volume that holds Derived Data and simulators; crossing into single-digit gigabytes free often correlates with mysterious compile timeouts, not compiler bugs.
Compiler cache vs clean build: parameter trade-offs for iOS CI
“Clean build” in the Xcode UI is not the same as a forensic rebuild. Clean Build Folder removes build products for the workspace but may leave other incremental artifacts depending on settings. For CI, express intent with flags and environment variables instead of clicking menus on a headless remote Mac.
| Goal | CI approach | Trade-off |
|---|---|---|
| Fast PR feedback | Reuse -derivedDataPath; Debug configuration; single simulator destination |
May hide rare incremental bugs; run a scheduled full clean nightly |
| Release parity | Separate job with Release + CODE_SIGNING_ALLOWED=NO where possible |
Slower; still not identical to App Store archive without signing assets |
| True cold build | New empty -derivedDataPath directory each run |
Longest wall time; use only for mainline or nightly |
| Skip indexing overhead | COMPILER_INDEX_STORE_ENABLE=NO |
Faster CPU; IDE features irrelevant on CI |
# Example: one-line cold vs warm toggle (set CLEAN_CI=1 for cold)
DERIVED_ROOT="${HOME}/ci-derived-data/${CI_PROJECT_NAME:-myapp}"
if [ "${CLEAN_CI:-0}" = "1" ]; then rm -rf "$DERIVED_ROOT"; fi
mkdir -p "$DERIVED_ROOT"
xcodebuild -scheme "MyApp" -destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$DERIVED_ROOT" build \
COMPILER_INDEX_STORE_ENABLE=NO
If you need deterministic Swift packages, prefer checking in Package.resolved and passing -disableAutomaticPackageResolution only when you have pre-populated SourcePackages; otherwise resolution errors become hard failures—often desirable on release branches.
Failure fallback and disk water-level threshold FAQ
Use explicit thresholds so operators and scripts agree when to sacrifice build acceleration for node stability. The numbers below are starting points; graph disk use on your own remote Mac fleet and tighten if you see latency spikes near full disks.
Stop the job immediately, alert, then run ordered cleanup: (1) df -h /; (2) prune oldest ci-derived-data trees; (3) xcrun simctl delete unavailable; (4) optional rm -rf ~/Library/Developer/Xcode/Archives/* if archives are not needed on runners. Re-queue the job only after free space is back above your safe floor (for example ≥15% or ≥20 GB, whichever is larger).
If only one target misbehaves after a merge, delete the subdirectory named after your workspace hash under Derived Data, or remove Build/Intermediates.noindex for that project. Full rm -rf ~/Library/Developer/Xcode/DerivedData/* fixes mystery states but forces a long warm-up for every concurrent job—bad on shared iOS CI nodes.
≥20% free: normal operation.
15–20% free: warning webhook; schedule cleanup after business hours.
10–15% free: aggressive LRU eviction of Derived Data folders older than N days.
<10% free: block new builds; run emergency cleanup; investigate logs for runaway cores or simulator data.
# Disk check snippet for pipeline pre-step (exit 1 blocks the build)
PCT=$(df -P / | awk 'NR==2 {gsub(/%/,"",$5); print $5}')
if [ "$PCT" -ge 90 ]; then echo "Disk use ${PCT}% — refuse build"; exit 1; fi
if [ "$PCT" -ge 85 ]; then echo "WARN: disk use ${PCT}% — trigger cleanup"; fi
Summary and next steps
Summary: Treat Derived Data as part of your iOS CI contract: stable -derivedDataPath, pinned DEVELOPER_DIR, and cache keys that include SPM/CocoaPods lockfiles and image version explain most Xcode build cache hits. Pre-warm after toolchain changes; evict with age-based rules; use COMPILER_INDEX_STORE_ENABLE=NO and deliberate cold builds only where release hygiene demands it. Disk policy (warn 85%, hard stop near 90%, maintain 15–20 GB headroom) keeps remote Mac runners predictable.
Purchase path (no login to browse): compare plans on MacPull pricing, rent hardware from Mac Mini M4 remote Mac purchase, read MacPull help & getting started, or return to the MacPull homepage. Suggested anchor text for editors: “Remote Mac pricing”, “Rent Mac Mini M4 for iOS CI”, “MacPull help center”, “MacPull home”.
More reading: all technical blog articles, Git, npm & CI cache strategy.
Run This Matrix on a Dedicated Remote Mac
Stable Derived Data and fast incremental Xcode builds need SSD headroom and a predictable toolchain. Mac Mini M4 remote Mac with SSH—view pricing and purchase without logging in.