Vuncloud Blog
← Back to Dev Notes

Xcode Cache Sharing in Practice: Tips for Syncing Build Data Across Multiple Nodes

DerivedData · SPM · CocoaPods · multi-runner sync~13 min read

Data analytics dashboard symbolizing multi-node Xcode build cache monitoring and sync efficiency
TL;DR · Three takeaways
  • DerivedData cache on one Mac does not exist on a second Runner by default—the hidden cost of multi-node iOS CI is every machine cold-starting on its own
  • What you can share is the build artifact layer (DerivedData, SPM resolution output, Pods/)—not “multiple machines writing the same directory at once”; pull → build → push or a regional cache hub is the 2026 mainstream approach
  • On a Cloud Mac fleet, use lockfile hash + Xcode version as cache keys with rsync/object storage—warm builds often save another 5–15 minutes (on top of single-machine cache optimization)

You configured GitHub Actions actions/cache and warm builds dropped from 18 minutes to 9—then you scaled out to three self-hosted M4 Runners and P95 crept back to 16 minutes. Every machine is “seeing this commit for the first time.”

That is not back to square one—it is phase two of multi-node builds: Xcode cache sharing. This article does not cover “why cache” (see the CocoaPods / SPM / DerivedData single-machine guide); it covers how to sync build data across multiple Macs so any Runner can reuse modules a teammate already compiled.

3
cache layers: dependencies · build artifacts · indexes
5–15 min
typical extra savings from multi-node warm builds
1
iron rule: never concurrent-write the same DerivedData

1. Why multi-node CI is slower than single-machine

When load balancing randomly assigns jobs to Runner A/B/C, each machine’s disk state is independent. MyAppKit.swiftmodule that Runner A just compiled is useless to Runner B—unless you move the artifacts over.

GitHub-hosted runners use actions/cache to store tarballs in the cloud and restore at job start. Self-hosted or Cloud Mac fleets often lack an equivalent managed layer, so teams either:

  • Accept per-machine warming (wasted compute)
  • Mount cache directories on NFS with concurrent writes (corruption risk)
  • Build a cache hub: pull before build, push after success

The third option is most common among iOS teams and fits multi-region node deployments: three M4s in US East share one cache bucket, two in Asia-Pacific share another, and only the SPM/Pods layer syncs across regions.

2. Layers of Xcode build data

Before syncing, separate what is worth transferring from what must not be mixed.

2.1 DerivedData

The directory pointed to by xcodebuild -derivedDataPath, containing compiled .o, .swiftmodule, link intermediates, and partial indexes. Volume often reaches several GB—it is the biggest lever for warm-build speed and the layer most sensitive to Xcode version.

Fix a path on CI, e.g. /Volumes/CI/DerivedData/<scheme>, matching the REMOTE variable in your sync script—the single-machine cache article stressed that mismatched paths mean no cache at all.

2.2 SPM and CocoaPods

SPM: ~/Library/Caches/org.swift.swiftpm + in-repo .build (if you commit resolution output). CocoaPods: Pods/ and ~/Library/Caches/CocoaPods.

These layers change less often than DerivedData (they track the lockfile), so they suit cross-machine and even cross-region sync. Many teams push only SPM/Pods to S3 and keep DerivedData on regional rsync.

2.3 ModuleCache and Xcode global caches

~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex and various SDKStatCaches. Usually handled together with DerivedData; if synced separately, enforce the same Xcode minor version.

Code editor on a MacBook, symbolizing multiple Runners sharing the same Xcode compile cache
You share compiled modules—not an open project on every machine. Each Runner still builds from a local DerivedData copy.

3. Four multi-node sync architectures

PatternApproachProsRisks
Local-onlyEach Runner has independent disk, no sharingZero opsHorizontal scale does not save time
NFS shared mountDerivedData on network volume, read-only or read-writeSimple setupConcurrent writes corrupt easily; network latency slows linking
rsync hubCentral directory or leader machine; pull before build, push after successControllable, good Xcode compatibilityRequires scripts and locking
Object-storage tarballPack and upload to S3/R2 by cache key; download and extract at job startCross-region, matches GHA cache modelLarge DerivedData uploads are slow; compression needed

In 2026 practice, same-datacenter / same-region Cloud Mac fleets favor rsync hubs; global teams use object storage for SPM/Pods and keep DerivedData regional. Do not treat NFS as a silver bullet for multi-writer shared DerivedData.

Iron rule

Never run xcodebuild against the same DerivedData tree on two Runners at once. If you must share a volume, use file locks or single-writer multi-reader (one designated warmer builds, then rsync distributes).

4. Cache keys and invalidation

Multi-node shared cache keys should be more conservative than single-machine—a wrong hit is worse than a cold start (mystery link errors, signing failures).

Fields to include in the key:

  • xcodebuild -version or an XCODE_VERSION environment variable
  • hashFiles('**/Podfile.lock') or Package.resolved
  • Scheme name or target set (separate caches for multi-scheme repos)
  • arm64 (Apple Silicon–only prefix to avoid legacy x86 pollution)

Do not use the full github.sha as the only key—or every machine always misses. Use layered restore-keys: dd-arm64-main-<lockhash>dd-arm64-main-dd-arm64-.

Invalidate when: Xcode upgrades, lockfile changes, SWIFT_VERSION or major Build Settings change, or after a manual clean build—bump the key suffix or delete the remote directory.

5. Hands-on: rsync pull/push script

Below is a minimal script validated on an M4 Cloud Mac fleet. The cache hub lives at cache-leader.internal:/cache/ios/ (a large-disk machine in the fleet or a NAS).

#!/usr/bin/env bash
# cache-sync.sh — 在 xcodebuild 前后调用
set -euo pipefail

ACTION="${1:?pull|push}"
CACHE_ROOT="${CACHE_ROOT:-cache-leader.internal:/cache/ios}"
SCHEME="${SCHEME:-MyApp}"
KEY="${CACHE_KEY:-arm64-$(md5 -q Podfile.lock 2>/dev/null || echo nolock)-$(xcodebuild -version | head -1 | tr ' ' '-')}"
LOCAL_DD="${DERIVED_DATA_PATH:-/Volumes/CI/DerivedData/${SCHEME}}"
REMOTE="${CACHE_ROOT}/${KEY}/${SCHEME}/DerivedData"
LOCAL_SPM="${HOME}/Library/Caches/org.swift.swiftpm"
REMOTE_SPM="${CACHE_ROOT}/${KEY}/swiftpm"

rsync_opts=(-az --delete-delay --contimeout=10)

case "$ACTION" in
  pull)
    rsync "${rsync_opts[@]}" "${REMOTE}/" "${LOCAL_DD}/" || true
    rsync "${rsync_opts[@]}" "${REMOTE_SPM}/" "${LOCAL_SPM}/" || true
    ;;
  push)
    # 仅成功构建后调用
    rsync "${rsync_opts[@]}" "${LOCAL_DD}/" "${REMOTE}/"
    rsync "${rsync_opts[@]}" "${LOCAL_SPM}/" "${REMOTE_SPM}/"
    ;;
  *) echo "usage: $0 pull|push" >&2; exit 1 ;;
esac

CI flow:

  1. Job start → cache-sync.sh pull
  2. xcodebuild -scheme "$SCHEME" -derivedDataPath "$LOCAL_DD" build
  3. Only on successful build → cache-sync.sh push

With parallel machines, push may race—use flock or accept last-write-wins; DerivedData under the same lockfile + Xcode should be compatible. If push is very frequent, switch to async upload (background rsync after the build, without blocking the pipeline).

6. Cross-region Cloud Mac cache hubs

When Runners sit in US East, US West, and Asia-Pacific, cross-ocean rsync of full DerivedData is often not worth it (latency + egress). Recommended approach:

  • One cache bucket per region (S3 / R2 / provider object storage)
  • Sync content: Pods/ tarball keyed by Podfile.lock hash + SPM cache keyed by Package.resolved
  • DerivedData only within the region via rsync; first job cold-starts, later jobs warm
  • Align image versions with golden image tags to avoid Xcode drift invalidating the whole library

Compress SPM cache with tar czf before upload—often 40%–60% smaller; DerivedData is already many small files, so tar + parallel upload is steadier than raw cross-region rsync.

7. Wiring into GitHub Actions / self-hosted Runners

On self-hosted runners, wrap the workflow like this:

- name: Restore shared Xcode cache
  run: ./scripts/cache-sync.sh pull
  env:
    CACHE_ROOT: ${{ secrets.IOS_CACHE_SSH }}
    SCHEME: MyApp
    DERIVED_DATA_PATH: /Volumes/CI/DerivedData/MyApp

- name: Build
  run: xcodebuild -scheme MyApp -derivedDataPath /Volumes/CI/DerivedData/MyApp build

- name: Save shared Xcode cache
  if: success()
  run: ./scripts/cache-sync.sh push

If you still use GitHub-hosted actions/cache, self-hosted fleets can run dual-track: GHA cache as fallback, rsync hub as the low-latency same-region layer. Keep the two key namespaces separate to avoid overwriting each other.

Monitoring: log pull/push duration, bytes transferred, and whether the build was incremental (count CompileSwift lines in xcodebuild logs). When P95 slows, check cache miss first, then machine load—the same playbook as troubleshooting erratic build times.

8. Pitfall checklist

  • Concurrent DerivedData writes: random stat cache file corrupted → switch to pull/push
  • Mismatched Xcode minor versions: module ABI mismatch → key includes version, images share a unified tag
  • Path mismatch: cache at path A, compile at path B → full rebuild
  • Push after clean: pushing empty dirs overwrites good cache → push only on success and not after clean
  • Signed products in cache: stale signing after provisioning changes → exclude Build/Products from cache or split keys by configuration
  • Disk full: DerivedData bloat → periodically LRU-prune remote ${KEY} directories, keep the last N lockfile hashes

9. FAQ

Can multiple Macs share the same DerivedData directory directly?

Not with concurrent writes. Read-only mounts or single-writer/multi-reader can work, but linking may still hit locks. Production setups prefer local DerivedData plus rsync.

Does Xcode cache sharing require the same Xcode version?

Yes. After upgrading Xcode, use a new cache key; old directories can be deleted asynchronously.

How does this compare to Bazel / Tuist remote cache?

Bazel/Tuist remote cache is finer-grained (action-level) and suits huge monorepos. Standard Xcode projects get lower migration cost from DerivedData sync and stay compatible with existing xcodebuild flows.

How much disk do multiple Cloud Macs need?

Reserve at least 50–100GB per machine for DerivedData + SPM locally; size the hub or object storage by concurrent schemes × cache size per scheme × retained versions. M4 + 2TB expansion is usually enough for mid-size teams.

Conclusion

Xcode cache sharing addresses the “second cold start” of the multi-Runner era: after single-machine cache optimization, scaling out should not pay the full compile tax again. Remember three separated layers (Pods / SPM / DerivedData), one sync discipline (pull-build-push), and one iron rule (never concurrent-write DerivedData).

Cache is the cheapest compute multiplier in distributed builds—faster ROI than buying two more M4s.

Start with one cache leader and cache-sync.sh; name remote directories by lockfile hash; push only when the build is green. Next week’s P95 curve will tell you if it was worth it.

Cloud Mac with Disk Headroom for Multi-Node CI

Vuncloud Mac mini M4: US East, US West, and Asia-Pacific SSH-ready—ideal for large DerivedData volumes, rsync cache hubs, and self-hosted Runner fleets.

View Cloud Mac plans · M4 CI Runner cost model

Apple toolchain behavior follows official releases; audit SSH and object-storage credentials per your team’s security policy. Last updated: July 27, 2026.

Dev Notes · Build Acceleration

DerivedData sync · multi-runner · Cloud Mac

rsync hub · cache key · regional cache buckets

View Cloud Mac Plans
Limited offer View plans