Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Caching CocoaPods Across Mac CI Runner Jobs

Why pod install Dominates Cold-Runner CI Time

A project with 30–40 CocoaPods dependencies runs pod install in two phases: first it fetches the podspec sources (the public CDN at https://cdn.cocoapods.org/ or your private spec repos), then it downloads and integrates each pod into the Xcode workspace. On a cold runner that has never seen the project, that work happens before a single line of your app's source code is compiled.

The podspec fetch and dependency resolution step is the painful part — the Podfile's version pinning rules documented in the CocoaPods Podfile guide mean the resolver has to walk the full spec index before it can produce a matching Podfile.lock. If your team runs 50 CI builds a day and each pod install adds 4 minutes, that is roughly 200 minutes (about 3 hours 20 minutes) of cumulative wait per day — time a cache hit removes entirely.

The fix is a cache keyed on Podfile.lock. When no dependency version changes, the key is identical to the previous run, the cache is restored, and pod install can be skipped.

What to Cache — and What Not To

PathCache?Reason
Pods/YesIntegrated pod sources and generated xcconfig/headers
~/.cocoapods/reposYesPodspec index; avoids CDN walk on hit
~/.cocoapods/cacheOptionalDownloaded pod archives; large, sometimes inconsistent
DerivedData/Separate keyCompiled Xcode artifacts; invalidate on toolchain change
Any file containing API keysNeverGitHub Actions caches are reachable from pull requests

The GitHub Actions documentation warns that "anyone with read access can create a pull request on a repository and access the contents of a cache" (Caching dependencies to speed up workflows). Never cache .env files, Keychain items, signing material, or any path that could hold secrets.

Cache Key Design

A good key is specific enough to bust when dependencies change and broad enough to get a useful fallback on an unrelated commit.

pods-{OS}-{hash of all Podfile.lock files}

Fallback restore-keys chain:

pods-{OS}-{exact Podfile.lock hash}   # exact hit → skip pod install
pods-{OS}-                            # prefix hit → pod install against warm repo

When only the prefix matches, pod install still runs, but against an already-populated ~/.cocoapods/repos, so only changed pods are resolved and fetched. That is significantly faster than a cold start.

GitHub Actions: The Full Cache Block

Drop these two steps into your iOS workflow immediately before your pod install step:

# ── CocoaPods cache ───────────────────────────────────────────────────────
# Exact Podfile.lock hash → skip pod install entirely on hit.
# Prefix fallback → run pod install with a warm spec repo.

- name: Restore CocoaPods cache
  id: pods-cache
  uses: actions/cache@v4
  with:
    path: |
      Pods
      ~/.cocoapods/repos
    key: pods-${{ runner.os }}-${{ hashFiles('**/Podfile.lock') }}
    restore-keys: |
      pods-${{ runner.os }}-

- name: Install pods
  # Runs on a true cache miss and on prefix-only restores.
  if: steps.pods-cache.outputs.cache-hit != 'true'
  run: |
    set -euo pipefail
    pod install --repo-update

cache-hit is only 'true' on an exact key match; a prefix restore via restore-keys leaves it empty. The != 'true' condition therefore runs pod install on both misses and partial hits, which is the correct behaviour because a partial hit means some dependency version changed.

If your repo has multiple Podfile.lock files (a monorepo with separate app and framework targets), hashFiles('**/Podfile.lock') hashes all of them together. Any change busts the key.

GitLab CI Adaptation

GitLab CI's cache.key.files hashes one or more files automatically:

# .gitlab-ci.yml
ios-build:
  stage: build
  tags: [figment-mac]
  cache:
    key:
      files:
        - Podfile.lock
    paths:
      - Pods/
    policy: pull-push
  script:
    - set -euo pipefail
    - pod install --repo-update
    - xcodebuild test -workspace "$XCODE_WORKSPACE" -scheme "$XCODE_SCHEME" -destination "$XCODE_DESTINATION"

Pods/ is project-relative and caches cleanly. ~/.cocoapods/repos lives outside the project tree; check your Runner version's rules on absolute cache paths before adding it. On a persistent Mac runner the repo survives on local disk between jobs anyway, so the CDN walk only happens on the very first run. policy: pull-push restores before the job and saves after it succeeds — use policy: pull on parallel test shards that consume but never update pods.

Replace $XCODE_WORKSPACE, $XCODE_SCHEME, and $XCODE_DESTINATION with values from your own project; do not guess a scheme name from a template.

Buildkite Adaptation

Buildkite has no built-in cache primitive. On a persistent self-hosted Mac the simplest reliable pattern is a keyed directory on local disk:

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

PODS_CACHE_DIR="${HOME}/.buildkite-pods-cache"
LOCK_HASH=$(shasum -a 256 Podfile.lock | awk '{print $1}')
CACHED_PODS_PATH="${PODS_CACHE_DIR}/${LOCK_HASH}"

if [[ -d "${CACHED_PODS_PATH}/Pods" ]]; then
  echo "Cache hit: ${LOCK_HASH}"
  rsync -a --delete "${CACHED_PODS_PATH}/Pods/" ./Pods/
else
  echo "Cache miss: running pod install"
  pod install --repo-update
  mkdir -p "${CACHED_PODS_PATH}"
  rsync -a ./Pods/ "${CACHED_PODS_PATH}/Pods/"
fi

This stores one Pods directory per unique Podfile.lock hash. Add a periodic cleanup to delete entries older than 30 days if the runner's disk gets tight.

Persistent vs Ephemeral Runners

Runner typeCache survivalRecommendation
Persistent self-hosted MacSurvives between jobs on local diskUse actions/cache plus the warm local disk
Ephemeral / JIT self-hostedWiped per jobRely on the CI service's cache store

On a persistent Mac the Pods/ directory from the last run can still exist even when actions/cache reports a miss. Treat the actions/cache restore as authoritative and let pod install --repo-update reconcile anything the cache did not restore — otherwise a pod removed from Podfile without a lock-file change could linger.

A Small Figment Cost Worksheet

Use your own numbers; this is an example, not a benchmark.

  • 50 builds/day × 22 build days = 1,100 builds/month
  • Time saved per build on a cache hit: 4 minutes
  • Hit rate on a persistent runner (your team's real number): assume 70%
  • Minutes saved: 1,100 × 0.70 × 4 = 3,080 minutes = ~51 engineer-hours/month

A persistent Figment M-series Mac starts at $95/month (exact chip, RAM, and disk are confirmed in the quote). If you prefer the setup and ongoing operation handled for you, the Managed Build Mac is $249/month and includes one M-series Mac, CI setup, backups, monitoring, and 30 minutes of monthly support. Compare that to the engineer-hours your team currently spends on cold pod install runs and on keeping a Mac runner healthy.

Verifying Cache Hits and Debugging Stale Pods

In the GitHub Actions log, an exact hit looks like:

Cache restored from key: pods-macOS-a3f9e2b1...

A prefix-only restore looks like:

Cache restored with key: pods-macOS-

If a dependency upgrade produces odd linker or header errors, suspect a stale cache:

  1. Delete the offending entry under Actions → Caches in GitHub.
  2. Re-run the workflow so the miss triggers a clean pod install --repo-update.
  3. If the build then passes, the old cache was the problem.

Locally, pod deintegrate && pod install --repo-update reproduces a cold install without touching CI.

Keeping the Cache Warm on a Dedicated Figment Mac

On a persistent runner the cache is warm the moment one job writes it; the next job on the same machine reads from the local filesystem without waiting for an upload/download round trip. That is the main operational reason to park iOS CI on a dedicated Mac rather than a per-job ephemeral host.

Set up a persistent Figment Mac runner →


Related guides:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms