Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Incremental Xcode Builds via Derived Data Cache

Cache the DerivedData directory between CI runs keyed on {runner-os}-xcode-{version}-{hash(Package.resolved + Podfile.lock)}, exclude Index.noindex, and only save the cache on the default branch after a successful build. A persistent self-hosted Mac makes this cheap because restore and save are local disk copies instead of network transfers; the Figment Managed Build Mac at $249/month is the matching configuration (one M-series Mac, CI setup, backups, monitoring, 30 minutes monthly support). An M-series developer Mac from $95/month is available on request; the exact chip, RAM and disk are confirmed on your configuration quote, not a fixed spec.

Every time an ephemeral CI runner starts fresh, Xcode recompiles every source file from scratch. Swift files that took 8 minutes locally, where Xcode reused the previous session's modules, can take 18 minutes in CI. Persisting DerivedData with a correct key fixes that.

Why CI Macs ignore Xcode's incremental build cache

Xcode's incremental build system checks file content hashes against the module dependency graph recorded in DerivedData. On an ephemeral runner there is no previous DerivedData, so every module is rebuilt. Even on a persistent runner, git checkout rewrites the working tree each run; combined with a missing module cache, Xcode cannot prove modules are up to date and recompiles them.

Restoring DerivedData addresses this in two ways:

  1. Previously compiled artifacts (.swiftmodule, .o, static archives) are present, so only files whose content hash changed recompile.
  2. The Clang module cache is warm, so system and framework headers parse faster.

A cache produced by a different Xcode version is not safe to restore. The module binary format changes between major versions and can produce module-load errors. The Xcode version must be part of the cache key.

What lives in DerivedData and what is safe to cache

DerivedData lives at ~/Library/Developer/Xcode/DerivedData/ by default. For a project MyApp:

DerivedData/
  MyApp-<hash>/
    Build/
      Intermediates.noindex/   # .o files, .d dependency files, .swiftmodule
      Products/                # built .app, .framework, .xctest
    Index.noindex/             # SourceKit index (skip; large, unused by xcodebuild)
    Logs/
    info.plist

Cache Build/Intermediates.noindex/ and Build/Products/. Skip Index.noindex/: it can reach several gigabytes on a large app and xcodebuild does not consume it.

Pin DerivedData to a project-relative path so the cache is predictable:

xcodebuild build \
  -scheme MyApp \
  -destination 'generic/platform=iOS' \
  -derivedDataPath ./DerivedData \
  -showBuildTimingSummary

-showBuildTimingSummary prints the slowest build phases at the end of the log, which is how you tell whether DerivedData caching or linker tuning will help more.

Cache key design

A good key invalidates exactly when cached products are no longer valid:

{runner-os}-xcode-{xcode-version}-{hash(Package.resolved + Podfile.lock)}
ComponentWhy
runner-osmacOS version affects SDK headers and linker behavior
xcode-versionModule binary format differs between Xcode versions
hash(Package.resolved)SwiftPM dependency tree changed
hash(Podfile.lock)CocoaPods dependency tree changed

Do not put github.sha in the primary key, because that mints a new cache per commit and defeats the point. Use github.sha only as a restore-key suffix fallback.

Restoring and saving DerivedData in GitHub Actions

The GitHub Actions caching documentation confirms a 512-character key limit and a default 10 GB cache quota per repository. The actions/cache repository documents split restore/save steps and the cache-hit output used below.

# .github/workflows/ios-build.yml
name: iOS Build

on: [push, pull_request]

env:
  XCODE_VERSION: '16.2'
  DERIVED_DATA_PATH: ./DerivedData

jobs:
  build:
    runs-on: [self-hosted, macOS]
    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app

      - name: Restore DerivedData cache
        id: cache-dd
        uses: actions/cache/restore@v4
        with:
          path: ${{ env.DERIVED_DATA_PATH }}
          key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved', '**/Podfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-

      - name: Build
        run: |
          set -euo pipefail
          xcodebuild build \
            -scheme MyApp \
            -destination 'generic/platform=iOS' \
            -derivedDataPath "${{ env.DERIVED_DATA_PATH }}" \
            -showBuildTimingSummary \
            | tee build.log

      - name: Save DerivedData cache
        if: success() && github.ref == 'refs/heads/main' && steps.cache-dd.outputs.cache-hit != 'true'
        uses: actions/cache/save@v4
        with:
          path: ${{ env.DERIVED_DATA_PATH }}
          key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('**/Package.resolved', '**/Podfile.lock') }}

Why this shape:

  • Split restore/save lets you gate save on success() and on github.ref so a failed or PR build never overwrites a good cache.
  • The restore-keys prefix warm-starts the next build after a minor lock-file change.
  • cache-hit != 'true' avoids rewriting an identical cache.
  • An untested example is not a benchmark. Measure before quoting a speedup.

Equivalent setup for Buildkite and GitLab CI

Buildkite on a persistent Mac runner

Buildkite artifacts move files between steps in one build, not across builds. On a persistent self-hosted Mac, keep the cache on local disk under a key derived from the lockfiles and Xcode version:

#!/usr/bin/env bash
set -euo pipefail

XCODE_VERSION=$(xcodebuild -version | awk 'NR==1{print $2}')
LOCKFILES=$(ls Package.resolved Podfile.lock 2>/dev/null || true)
LOCKFILE_HASH=$(cat $LOCKFILES 2>/dev/null | shasum -a 256 | awk '{print $1}')
CACHE_DIR="$HOME/.xcode-cache/$XCODE_VERSION/$LOCKFILE_HASH"
DERIVED_DATA="./DerivedData"

mkdir -p "$DERIVED_DATA"
if [[ -d "$CACHE_DIR" ]]; then
  rsync -a "$CACHE_DIR/" "$DERIVED_DATA/"
fi

xcodebuild build \
  -scheme MyApp \
  -destination 'generic/platform=iOS' \
  -derivedDataPath "$DERIVED_DATA" \
  -showBuildTimingSummary

# Only refresh the cache if the build succeeded (set -e handles failure).
rsync -a --delete \
  --exclude 'Index.noindex' \
  "$DERIVED_DATA/" "$CACHE_DIR/"

Add a weekly cron that prunes ~/.xcode-cache entries older than N days to keep disk usage bounded.

GitLab CI

build:
  tags: [mac]
  variables:
    DERIVED_DATA: "$CI_PROJECT_DIR/DerivedData"
  cache:
    key:
      files:
        - Package.resolved
        - Podfile.lock
      prefix: "xcode-16.2"
    paths:
      - DerivedData/Build/Intermediates.noindex/
      - DerivedData/Build/Products/
    policy: pull-push
  script:
    - set -euo pipefail
    - xcodebuild build -scheme MyApp -destination 'generic/platform=iOS' -derivedDataPath "$DERIVED_DATA" -showBuildTimingSummary

Worked worksheet: is persistent caching worth it on your project?

Fill this in with your own numbers; code is an example, not a benchmark.

MeasurementHow to captureYour value
Cold build wall time (T_cold)time xcodebuild build ... on a clean DerivedDatafill in
Warm build wall time (T_warm)Same command after one successful cache savefill in
Cache restore overhead (T_r)CI step timestamp delta for the restore stepfill in
Cache save overhead (T_s)Same for the save stepfill in
Builds per workday (N)PR + main runs in a typical dayfill in
Net daily time savedN * (T_cold - T_warm - T_r)calculate

Worked cost example at Figment list pricing: one Managed Build Mac is $249/month; one M-series Mac is $95/month. If the Managed Build Mac saves one engineer 30 minutes per day across cache-driven rebuilds (10 workdays per billing month equals 5 saved engineering hours), each saved hour costs $249 ÷ 5 = $49.80 in hosting. Compare that against your blended engineering cost per hour to decide whether the managed tier or a bare M-series developer Mac is the right fit; confirm the exact chip and RAM in the quote before you commit.

Invalidation triggers: when to bust the cache on purpose

  • Xcode upgrade. Bump XCODE_VERSION; the old cache key becomes unreachable.
  • Compiler flag change. OTHER_SWIFT_FLAGS, optimization level or -enable-experimental-feature flips invalidate modules. Encode the relevant flag or accept one stale-cache rebuild.
  • Module load errors after restore. Delete the cache entry (GitHub: Settings → Actions → Caches) and let the next run repopulate.
  • Large refactor or rename. Bump a prefix (v2-xcode-...) to drop orphaned .swiftmodule files that would otherwise sit in the cache forever.

Persistent runner vs ephemeral runner

Runner typeCache benefitNotes
Persistent self-hosted MacHighest. Restore and save are local disk copiesPrune policy required to bound disk use
Ephemeral hosted runnerModerate. Cache is uploaded and downloaded over the network each runNetwork throughput and the 10 GB per-repo quota become the limits
Shared pool of MacsLower. Hit rate depends on routing the same repo to the same hostConsider a shared remote cache keyed the same way

For an iOS CI job on a Figment Managed Build Mac, the cache and the toolchain both live on the same disk, so the path with the largest payoff is the Buildkite-style local rsync pattern above. See Automate tvOS App Builds in a CI Pipeline and Automate watchOS App Builds in a CI Pipeline for sibling platform notes, and Remote Xcode from Windows if you also need a desktop endpoint on the same Mac.

Configure a persistent Figment Mac runner →


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms