Supercharge Your GitHub Actions with Smart Caching

· GitHub Actions to TestFlight - from zero to hero

Introduction

Alright, let’s be honest here. You’ve set up your fancy GitHub Actions workflow from our previous posts, you’re feeling like a CI/CD wizard, and then… you sit there for 15 minutes watching your build crawl along while Xcode downloads the same dependencies for the 47th time this week.

Sound familiar? Yeah, I thought so.

Well, my friend, today we’re going to fix that. We’re going to take your glacially slow builds and turn them into something that’ll make you look like you’ve got superpowers. How? Caching. The magical art of not doing the same work twice.

After implementing the caching strategies in this post, your 15-minute builds will drop to 3-4 minutes, your teammates will think you’ve sold your soul to the DevOps gods, and you’ll finally have time to grab that coffee instead of watching progress bars.

Why caching matters (and why you should care)

The cold, hard numbers

Without caching, here’s what happens every single time your workflow runs:

That’s potentially 15+ minutes of pure waiting. For work that’s been done hundreds of times before.

With proper caching:

Total time saved: 10-12 minutes per build. That’s enough time to contemplate life, check Twitter, or actually be productive. Your choice! 😄

GitHub Actions pricing impact

Here’s the kicker - GitHub Actions charges by the minute for private repos. Those extra 10 minutes per build? That’s 10x more expensive than it needs to be. Caching literally pays for itself by reducing your compute costs.

Understanding GitHub Actions cache fundamentals

Before we dive into iOS-specific strategies, let’s understand how GitHub Actions caching works:

Cache keys and restore keys

- uses: actions/cache@v4
  with:
    path: path/to/cache
    key: my-cache-${{ hashFiles('**/lockfile') }}
    restore-keys: |
      my-cache-

Cache invalidation strategy

The beauty is in the hashFiles() function. It creates a hash of your dependency files (like Package.resolved or Gemfile.lock). When dependencies change, the hash changes, and you automatically get a fresh cache. When they don’t change, you get blazing fast restores.

The iOS caching trinity

Let’s set up caching for the three main bottlenecks in iOS builds:

1. Xcode derived data caching

This is the big one. Xcode’s derived data contains all the intermediate build artifacts, and it’s usually the slowest part of your build.

- name: Cache Xcode derived data
  uses: actions/cache@v4
  with:
    path: ~/Library/Developer/Xcode/DerivedData
    key: ${{ runner.os }}-xcode-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj', '**/*.xcworkspace/contents.xcworkspacedata') }}
    restore-keys: |
      ${{ runner.os }}-xcode-deriveddata-

What this does:

Pro tip: The derived data cache can get huge (several GB). GitHub Actions has a 10GB limit per repository, so you might want to clean it periodically.

2. Swift Package Manager caching

SPM dependencies can take forever to download and build. Let’s cache them:

- name: Cache Swift Package Manager
  uses: actions/cache@v4
  with:
    path: |
      .build
      ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex
      ~/Library/Caches/org.swift.swiftpm
      ~/Library/org.swift.swiftpm
    key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
    restore-keys: |
      ${{ runner.os }}-spm-

What this caches:

3. Ruby gems caching (for Fastlane)

Since you’re using Fastlane (you are, right?), let’s cache those Ruby gems:

- name: Set up Ruby
  uses: ruby/setup-ruby@v1
  with:
    ruby-version: 3.1
    bundler-cache: true  # This automatically handles gem caching!

Wait, that’s it? Yep! The ruby/setup-ruby action has built-in caching when you set bundler-cache: true. It’s so good that you don’t need to do it manually.

4. CocoaPods caching (if you’re still using them)

For those who haven’t made the SPM transition yet:

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

The complete caching workflow

Here’s how to put it all together in a workflow that’ll make your builds fly:

name: Build and Test with Caching

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: macos-latest

    steps:
    - uses: actions/checkout@v4

    # Set up Xcode (this doesn't need caching)
    - name: Set up Xcode
      uses: maxim-lobanov/setup-xcode@v1
      with:
        xcode-version: "15.4.0"

    # Cache Ruby gems (built into setup-ruby)
    - name: Set up Ruby with caching
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.1
        bundler-cache: true

    # Cache Xcode derived data
    - name: Cache Xcode derived data
      uses: actions/cache@v4
      with:
        path: ~/Library/Developer/Xcode/DerivedData
        key: ${{ runner.os }}-xcode-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj', '**/*.xcworkspace/contents.xcworkspacedata') }}
        restore-keys: |
          ${{ runner.os }}-xcode-deriveddata-

    # Cache Swift Package Manager
    - name: Cache Swift Package Manager
      uses: actions/cache@v4
      with:
        path: |
          .build
          ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex
          ~/Library/Caches/org.swift.swiftpm
          ~/Library/org.swift.swiftpm
        key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
        restore-keys: |
          ${{ runner.os }}-spm-

    # Optional: Cache CocoaPods (if you're using them)
    - name: Cache CocoaPods
      if: hashFiles('**/Podfile.lock') != ''
      uses: actions/cache@v4
      with:
        path: |
          Pods
          ~/Library/Caches/CocoaPods
        key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}
        restore-keys: |
          ${{ runner.os }}-pods-

    # Set up code signing (from our previous posts)
    - name: Set up SSH key for Match
      run: |
        mkdir -p ~/.ssh
        echo "${{ secrets.MATCH_GIT_PRIVATE_KEY }}" > ~/.ssh/id_rsa
        chmod 600 ~/.ssh/id_rsa
        ssh-keyscan github.com >> ~/.ssh/known_hosts

    - name: Set up code signing
      run: bundle exec fastlane match appstore --readonly --app_identifier com.yourcompany.yourapp
      env:
        MATCH_REPOSITORY: ${{ secrets.MATCH_REPOSITORY }}
        MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}

    # Run tests (now much faster!)
    - name: Run tests
      run: bundle exec fastlane test

    # Build app (also much faster!)
    - name: Build app
      run: bundle exec fastlane build

Advanced caching strategies

Once you’ve got the basics down, here are some pro-level techniques:

Cache warming

Pre-populate caches in a dedicated job that runs on a schedule:

name: Cache Warming

on:
  schedule:
    - cron: '0 2 * * *'  # Run daily at 2 AM

jobs:
  warm-cache:
    runs-on: macos-latest
    steps:
    - uses: actions/checkout@v4

    # ... set up steps ...

    - name: Warm SPM cache
      run: swift package resolve

    - name: Warm derived data cache
      run: xcodebuild build -scheme YourScheme -destination 'platform=iOS Simulator,name=iPhone 15 Pro' -quiet

This ensures your main workflows always have warm caches to work with.

Matrix caching

If you’re building for multiple configurations, share caches between them:

strategy:
  matrix:
    scheme: [MyApp, MyAppTests]

steps:
- name: Cache Xcode derived data
  uses: actions/cache@v4
  with:
    path: ~/Library/Developer/Xcode/DerivedData
    key: ${{ runner.os }}-xcode-${{ matrix.scheme }}-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }}
    restore-keys: |
      ${{ runner.os }}-xcode-${{ matrix.scheme }}-
      ${{ runner.os }}-xcode-  # Fallback to any scheme

Conditional caching

Only cache CocoaPods if you actually use them:

- name: Cache CocoaPods
  if: hashFiles('**/Podfile.lock') != ''  # Only run if Podfile.lock exists
  uses: actions/cache@v4
  with:
    path: Pods
    key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }}

Cache size optimization

Clean up your derived data before caching to keep it manageable:

- name: Clean derived data before caching
  run: |
    # Keep only the most recent build products
    find ~/Library/Developer/Xcode/DerivedData -name "Build" -type d -exec rm -rf {}/Intermediates.noindex \;

Monitoring cache performance

Add some logging to see how much time you’re saving:

- name: Cache Swift Package Manager
  id: spm-cache
  uses: actions/cache@v4
  with:
    path: .build
    key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}

- name: Report cache status
  run: |
    if [ "${{ steps.spm-cache.outputs.cache-hit }}" == "true" ]; then
      echo "✅ SPM cache hit! Saved time on dependency resolution."
    else
      echo "❌ SPM cache miss. Dependencies will be downloaded."
    fi

You can also track cache performance over time by checking the Actions logs and seeing how often you get cache hits vs misses.

Troubleshooting common caching issues

”Cache size exceeded 10GB limit”

GitHub Actions has a 10GB cache limit per repository. If you hit this:

  1. Clean your derived data: Remove build intermediates before caching
  2. Use more specific cache keys: Separate caches for different branches/schemes
  3. Set up cache cleanup: Use actions/cache/restore and actions/cache/save to have more control
- name: Clean cache before saving
  run: |
    # Remove large intermediate files
    rm -rf ~/Library/Developer/Xcode/DerivedData/*/Build/Intermediates.noindex

”Cache is slower than rebuilding”

This usually happens when your cache keys are too broad. If you’re invalidating cache too often:

  1. Make keys more specific: Use more granular file hashes
  2. Use restore keys effectively: Have good fallback strategies
  3. Profile your cache sizes: Large caches can be slower to restore than rebuilding

”Dependencies not found after cache restore”

This often happens with SPM when the cache doesn’t include everything needed:

- name: Cache Swift Package Manager
  uses: actions/cache@v4
  with:
    path: |
      .build
      ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex
      ~/Library/Caches/org.swift.swiftpm
      ~/Library/org.swift.swiftpm  # Don't forget this!
    key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}

“CocoaPods cache not working”

Make sure you’re not skipping the cache in your pod install:

- name: Install pods
  run: bundle exec pod install
  # Note: Don't use COCOAPODS_SKIP_CACHE=TRUE if you want caching!

Cache key collisions

If you’re seeing unexpected cache behavior, you might have key collisions. Make your keys more unique:

key: ${{ runner.os }}-${{ github.workflow }}-xcode-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }}

Best practices and gotchas

Do’s

Start with Ruby gem caching - It’s built into setup-ruby, easy to set up, and gives immediate benefits

Use specific file patterns for cache keys - Package.resolved, Podfile.lock, etc. are better than generic patterns

Set up restore keys - Always have a fallback strategy

Monitor cache hit rates - Add logging to see how well your caching is working

Clean before caching - Remove temporary files to keep cache sizes manageable

Don’ts

Don’t cache node_modules equivalent directories without good reason - They can be huge and change frequently

Don’t use overly broad cache keys - ${{ runner.os }}-cache will almost never hit

Don’t cache secrets or sensitive data - Caches are not encrypted

Don’t forget about cache limits - 10GB per repo, plan accordingly

Don’t cache absolute paths - Use relative paths when possible

Real-world impact

Let me share some real numbers from implementing this in production:

Before caching:

After implementing smart caching:

That’s a 70% reduction in build time and 70% reduction in costs. Not bad for a few hours of YAML writing!

What’s next?

Once you’ve got caching humming along nicely, you can explore:

But honestly? The strategies in this post will get you 90% of the benefit with 10% of the complexity. Perfect is the enemy of good, and good caching is already pretty amazing.

Conclusion

Look, I get it. YAML configuration isn’t the sexiest part of iOS development. But trust me on this one - spending an hour setting up proper caching will save you hours every week waiting for builds.

Your future self will thank you when you push a small change and your CI finishes before you can even switch to Twitter. Your teammates will think you’re a wizard. Your manager will love the reduced CI costs. And you’ll finally have time to do what we all really want to do: write more Swift code instead of watching progress bars.

The strategies in this post have saved my team literally hundreds of hours over the past year. That’s time we could spend on features, bug fixes, or just not being frustrated by slow builds.

So go forth, cache all the things, and may your builds be swift and your coffee always hot! ☕️

Got questions about caching? Hit me up! I love talking about developer productivity, especially when it involves making computers do less work. That’s basically the entire point of programming, right? 😄


P.S. - If you found this helpful, you might also enjoy our previous posts on GitHub Actions setup and TestFlight automation. Together, they form the holy trinity of iOS CI/CD automation.