How Do I Cache CocoaPods in GitHub Actions?

Cache the Pods directory and the CocoaPods download caches with a key from Podfile.lock, then run pod install --deployment so a stale lockfile fails the job.

Cache three paths and derive the key from Podfile.lock: the Pods directory in the checkout, ~/Library/Caches/CocoaPods, and ~/.cocoapods/repos. Then run pod install --deployment so a lockfile that no longer matches the Podfile stops the job instead of resolving new versions in the background.

Answer

CocoaPods work in a job splits into two halves, and they want different cache policies. Downloading pod sources and podspec metadata is content addressed and safe to reuse across lockfile changes. Integrating those pods into Pods/ and the generated .xcworkspace is exact: the result belongs to one Podfile.lock and to nothing else.

PathWhat it holdsRestore policy
PodsPods integrated into the workspace, plus Pods/Manifest.lockExact key on Podfile.lock, no restore-keys
~/Library/Caches/CocoaPodsDownloaded pod source archives, addressed by name and versionPrefix restore with restore-keys is safe
~/.cocoapods/reposSpec repo clones and CDN trunk metadataPrefix restore with restore-keys is safe

hashFiles('Podfile.lock') returns a SHA-256 of the file, so the key changes only when the resolved dependency set changes (GitHub dependency caching reference). Add Gemfile.lock to the key when you install CocoaPods through Bundler, because the pod layout and the generated support files differ across CocoaPods releases.

This workflow keeps the two halves separate:

name: ios
on:
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: warp-macos-26-arm64-6x
    steps:
      - uses: actions/checkout@v4

      - name: Cache pod downloads
        uses: actions/cache@v4
        with:
          path: |
            ~/Library/Caches/CocoaPods
            ~/.cocoapods/repos
          key: ${{ runner.os }}-pod-downloads-${{ hashFiles('Podfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-pod-downloads-

      - name: Cache integrated Pods
        uses: actions/cache@v4
        with:
          path: Pods
          key: ${{ runner.os }}-pods-${{ hashFiles('Gemfile.lock') }}-${{ hashFiles('Podfile.lock') }}

      - name: Install pods
        run: |
          bundle install
          bundle exec pod install --deployment

      - name: Build
        run: |
          xcodebuild -workspace App.xcworkspace -scheme App \
            -destination 'generic/platform=iOS Simulator' build

pod install --deployment disallows any change to the Podfile or Podfile.lock during installation, so a job that would have rewritten the lockfile exits with an error instead (CocoaPods command reference). Run it on every job rather than gating it behind a cache hit. When the Pods entry restored on an exact key, the step re-verifies the integration and finishes without downloading anything; when it missed, the download cache still covers the network work.

runs-on: warp-macos-26-arm64-6x puts that job on a 6 vCPU macOS runner with 22 GB of memory and a 120GB SSD at $0.08 per minute, and warp-macos-26-arm64-12x doubles the shape to 12 vCPU and 44 GB at $0.16 per minute (WarpBuild cloud runners documentation and the pricing page, checked on 2026-08-13). The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Every label and rate is on the macOS runners page.

Detail

Why the key comes from Podfile.lock and never from Podfile

Podfile declares constraints. Podfile.lock records the exact versions the resolver picked, and it is the file Pods/Manifest.lock is copied from during installation. Keying on Podfile gives you a stable key across a dependency bump that changed every pod, which is the worst outcome: the key hits, the stale Pods directory restores, and the mismatch surfaces later inside xcodebuild.

Two files are worth adding to the key beyond Podfile.lock. Gemfile.lock pins the CocoaPods version when you run through Bundler. A checksum of any local podspec in the repository belongs there too, since a change to a development pod alters the integrated output without touching Podfile.lock.

Failure mode one: key churn from an unpinned Podfile

A Podfile that declares pod 'Alamofire' with no version constraint lets the resolver pick a newer release on any run that is allowed to rewrite the lockfile. Podfile.lock changes, hashFiles returns a different digest, and the job writes a fresh cache entry that the next run will also miss. The visible symptom is a restore step logging a miss on a key that looks identical to yesterday's, with no dependency change in any pull request.

The cost lands twice. Every run pays a cold install, and every run leaves a full-size entry behind. GitHub caps the combined size of all caches in a repository at 10 GB by default and evicts least recently used entries once the total passes that ceiling, so a week of churn from four active branches can push out the entries that would have hit (GitHub dependency caching reference, checked on 2026-08-13). Cache entries are also scoped to the branch that wrote them and to the base branch, so each feature branch stores its own copy.

Two changes stop the churn. Pin versions in the Podfile, or at minimum commit Podfile.lock and treat it as the source of truth. Then run installs in deployment mode so any drift fails the job at the install step, where the error names the file, rather than at the xcodebuild step, where the error names a build phase.

Failure mode two: a partial restore that leaves the workspace inconsistent

restore-keys performs a prefix match and restores the most recent entry whose key starts with the prefix. On the download caches that is what you want. On the Pods directory it hands the job an integration built from a different Podfile.lock, and cache-hit is only true on an exact primary key match, so a step written as if: steps.pods.outputs.cache-hit != 'true' behaves correctly while a step written to skip on any restore does not.

The failure appears during compilation. The [CP] Check Pods Manifest.lock build phase compares Pods/Manifest.lock against Podfile.lock and fails with "The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation." A quieter version of the same problem skips that phase and links against a pod version the lockfile no longer references.

Three rules keep the workspace consistent:

  • Keep restore-keys off the Pods entry so it either hits exactly or misses.
  • Run pod install --deployment unconditionally instead of gating it on cache-hit.
  • Cache Pods and the download directories as separate steps, so a miss on the exact entry still reuses the downloads.

When a job does fail this way, the fastest confirmation is to compare the two lockfiles on the runner itself. The Action Debugger opens a shell on the failed runner so you can diff Podfile.lock Pods/Manifest.lock in place, and CI observability reports the per-step durations that show whether the install step is running cold across consecutive runs. The caching behavior described here is documented in the WarpBuild caching documentation.

Where the CocoaPods step sits in an iOS pipeline

Dependency restore is one of three caches an Xcode job usually wants. The others are Swift Package Manager artifacts and derived data. Keep them in separate steps with separate keys so a change to one lockfile does not invalidate the others, and set up the pipeline shape described in iOS builds and tests on GitHub Actions. Signup includes $10 free credits, which covers a few hundred minutes of macOS runner time while you tune the keys.

Should I commit the Pods directory instead of caching it?

Committing Pods removes the restore step and the cache key entirely, and it costs a large diff on every dependency bump plus a repository that grows with every vendored source tree. Caching keeps the checkout small and keeps pod install as the single integration step. Either way, run pod install --deployment in the job so a Podfile.lock that no longer matches the Podfile fails the build. The trade-off between checkout size and install time is covered alongside the other Xcode levers in cutting Xcode build times on GitHub Actions.

Why does my build fail with "The sandbox is not in sync with the Podfile.lock"?

The [CP] Check Pods Manifest.lock build phase compares Pods/Manifest.lock against Podfile.lock and fails when they differ. In a cached job this almost always means a restore-keys prefix match handed you a Pods directory built from a different lockfile and the workflow then skipped pod install. Key the Pods directory on an exact hash of Podfile.lock, keep restore-keys off that entry, and run pod install on every job.

Can I use the WarpBuild cache action for CocoaPods?

The WarpBuild cache is a Linux runner feature, and CocoaPods jobs run on macOS runners, so keep those steps on actions/cache@v4 with a key derived from Podfile.lock. WarpBuild macOS runners are warp-macos-26-arm64-6x at 6 vCPU, 22 GB and $0.08 per minute and warp-macos-26-arm64-12x at 12 vCPU, 44 GB and $0.16 per minute, checked on 2026-08-13 against the cloud runners documentation. The same key discipline applies to Swift dependencies, which is the subject of caching Swift Package Manager dependencies.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.