
pod install Dominates Cold-Runner CI TimeA 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.
| Path | Cache? | Reason |
|---|---|---|
Pods/ | Yes | Integrated pod sources and generated xcconfig/headers |
~/.cocoapods/repos | Yes | Podspec index; avoids CDN walk on hit |
~/.cocoapods/cache | Optional | Downloaded pod archives; large, sometimes inconsistent |
DerivedData/ | Separate key | Compiled Xcode artifacts; invalidate on toolchain change |
| Any file containing API keys | Never | GitHub 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.
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.
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'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 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.
| Runner type | Cache survival | Recommendation |
|---|---|---|
| Persistent self-hosted Mac | Survives between jobs on local disk | Use actions/cache plus the warm local disk |
| Ephemeral / JIT self-hosted | Wiped per job | Rely 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.
Use your own numbers; this is an example, not a benchmark.
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.
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:
pod install --repo-update.Locally, pod deintegrate && pod install --repo-update reproduces a cold install without touching CI.
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: