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:
- Ruby gems: Downloaded and installed from scratch (1-2 minutes)
- Swift Package Manager: Downloads and builds all dependencies (3-5 minutes)
- Xcode derived data: Builds everything from zero (5-10 minutes)
- CocoaPods (if you’re still using them): Downloads and installs everything (2-3 minutes)
That’s potentially 15+ minutes of pure waiting. For work that’s been done hundreds of times before.
With proper caching:
- Ruby gems: Restored in 10-15 seconds
- Swift Package Manager: Restored in 30 seconds
- Xcode derived data: Restored in 1-2 minutes
- CocoaPods: Restored in 30 seconds
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-
key
: The exact cache identifier. If this matches, you get a perfect cache hitrestore-keys
: Fallback patterns. If the exact key doesn’t match, it finds the closest matchpath
: What directories/files to 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:
- Caches the entire derived data directory
- Invalidates when your project structure changes
- Falls back to any previous derived data if project changes
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:
.build
: Your local SPM build artifactsModuleCache.noindex
: Compiled Swift modules~/Library/Caches/org.swift.swiftpm
: SPM’s download cache~/Library/org.swift.swiftpm
: SPM’s configuration and metadata
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:
- Clean your derived data: Remove build intermediates before caching
- Use more specific cache keys: Separate caches for different branches/schemes
- Set up cache cleanup: Use
actions/cache/restore
andactions/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:
- Make keys more specific: Use more granular file hashes
- Use restore keys effectively: Have good fallback strategies
- 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:
- Average build time: 14 minutes
- Cache hit rate: 0% (obviously)
- GitHub Actions cost: ~$50/month for a team of 5
After implementing smart caching:
- Average build time: 4 minutes
- Cache hit rate: 85% (meaning 85% of builds use cached dependencies)
- GitHub Actions cost: ~$15/month
- Developer happiness: Significantly improved 😄
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:
- Self-hosted runners with persistent caches (for even faster builds)
- Docker layer caching (if you’re containerizing your builds)
- Artifact caching between different workflows
- Build result caching (only rebuild what changed)
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.