← Back to tech blog

Why is GitHub Actions iOS CI so slow on Mac? Self-hosted runners are the real fix

GitHub Actions iOS CI: Mac mini self-hosted runner with persistent DerivedData
Hosted macOS runners cold-start every job; a dedicated Mac mini keeps DerivedData alive across runs.

Most teams that complain about GitHub Actions iOS CI being “painfully slow” are not actually losing time inside the compiler. They are losing it in invisible overhead: waiting for a hosted macOS slot, pulling dependencies from scratch, rebuilding a cold DerivedData, and repeating the same setup work on every single run. On paper, Apple Silicon runners look powerful. In practice, the runtime model is what dominates your wall-clock time. If the environment resets every job, your pipeline keeps paying first-run cost forever.

This is the practical argument for a dedicated self-hosted macOS runner on a real Mac mini: you trade “ephemeral convenience” for persistent state and predictable throughput. In teams where one release pipeline used to sit around 28 minutes median, the same workflow often lands near 9 minutes median after moving to a pinned runner with stable cache paths. That number is a sample median from repeated runs, not a contract or SLA. If you want the cost side of this decision first, read the macOS runner region TCO comparison. If you want the broader context on why dedicated compute is becoming a moat in dev infra, this piece connects well with the AI infrastructure war analysis.

1) Three common misreads before touching YAML

The first misread is “our iOS CI is slow, so this Mac must be weak.” Usually wrong. If you split runtime into queue, bootstrap, build/archive, and sign/upload, build is frequently not the longest block. Queue and bootstrap often dominate because hosted jobs start in a fresh environment. That means your pipeline repeatedly redownloads and rehydrates work your team already did yesterday.

The second misread is “we switched to macos-latest, so we already optimized.” You changed image selection, not scheduling behavior or persistence semantics. Hosted macOS is still metered and shared, and the machine state still disappears after the job. GitHub explains hosted-minute billing in their official docs: About billing for GitHub Actions. Faster labels can help around the edges, but they cannot create cross-run locality if the disk is wiped at the end of each execution.

The third misread is over-rotating on tiny workflow edits. Yes, shaving a command or pinning a version can save a minute. But if your core problem is stateless compute, you are trying to optimize a symptom instead of the mechanism. A quick litmus test: print your DerivedData size at job start. If it is nearly empty every run, your biggest opportunity is runner persistence, not one more micro-optimization in your shell steps.

Quick sanity check: add du -sh ~/Library/Developer/Xcode/DerivedData || true at the beginning of the workflow. If every run starts at ~0, the bottleneck is environment reset, not a “bad laptop chip” narrative.

2) Where the 28 minutes usually go (hosted macOS)

Across mid-size iOS repos, timing details differ, but the shape is surprisingly stable. The table below shows a representative median profile for hosted macOS jobs when teams ship regularly and dependency graphs are non-trivial. Treat the numbers as directional. The point is allocation: where your time budget leaks in daily practice.

Phase Typical hosted median Why it stretches
queued 3-12 min Shared macOS capacity, org concurrency limits, peak-hour contention
bootstrap 6-10 min Cold dependency restore: CocoaPods/SPM fetch, fresh checkout setup
build / archive 8-14 min No warm incremental state, frequent full compile behavior
sign + upload 3-6 min Keychain/signing setup, transport to App Store Connect/TestFlight

If you only watch total duration, these blocks blur together and every problem feels like “Xcode is slow.” Once you separate the stages, the remediation path becomes obvious: give the iOS lane stable capacity and stable disk state, then optimize compile settings after that baseline is in place.

3) The architecture that actually moves the needle

Self-hosted runner speedups are mostly about continuity, not just CPU class. You keep one machine online, keep one runner identity registered, and keep the same cache directories alive across jobs. GitHub’s runner lifecycle and security model are documented here: About self-hosted runners. Apple’s build system behavior and toolchain references live in the Xcode documentation.

Operationally, this model also cleans up team behavior. Linux jobs stay cheap and parallel for tests/lint, while the iOS release lane lands on a dedicated macOS label. That split removes noisy contention and makes your release path measurable. If your team also wrestles with local Xcode lag or office-device constraints, the same offloading pattern is explained in this Xcode offload guide.

GitHub Actions iOS CI · self-hosted target architecture

git push / pull request
Linux lane: test · lint · Android
GitHub workflow scheduler
runs-on: [self-hosted, macos, ios-ci]
Dedicated Mac mini M4 runner
no shared queue · persistent SSD state
Archive → signing → TestFlight / artifacts
stable credentials + predictable release lane

Keep these across jobs (do not nuke by default)

  • DerivedData
  • ~/Library/Caches/CocoaPods
  • SourcePackages / .build
  • keychain signing context
  • bundler / npm helper caches

Once this is in place, queue time often collapses to near-zero for that lane, and second-run bootstrap/build times become materially lower. The “faster CI” feeling your team notices is usually this continuity effect compounding across every day of commits.

4) Cache strategy: keep local-first, use remote as backup

On a dedicated self-hosted box, local persistence beats network cache restoration in most iOS pipelines. That does not mean actions/cache is useless; it means you should treat it as a portability layer, not your primary acceleration mechanism. Local SSD cache hits are usually lower latency and lower variance than pulling compressed blobs over shared bandwidth.

Practical split that works for most teams:

  • DerivedData: pin to a stable path per runner user; only purge on deliberate events (major Xcode changes, corrupted index episodes).
  • CocoaPods: keep both project Pods/ and ~/Library/Caches/CocoaPods warm; tie full reinstall decisions to Podfile.lock movement.
  • SPM: preserve resolved package directories so xcodebuild doesn’t re-fetch everything each run.
  • actions/cache: use for resilience and migration scenarios, not as a replacement for host-level persistence.

If your pipeline still thrashes after this, look for accidental cleanup scripts. Many teams unknowingly keep a “helpful” rm -rf from old hosted-runner days that wipes exactly the state they now need for speed.

5) Workflow pattern: labels, concurrency, and clean separation

The most reliable setup is boring on purpose: explicit labels, one clear release lane, and constrained concurrency where signing and archive steps share mutable state. Here is a baseline snippet teams can start from:

name: ios-release

on:
  workflow_dispatch:
  push:
    branches: [main]

jobs:
  ios-release:
    runs-on: [self-hosted, macos, ios-ci]
    concurrency:
      group: ios-release-${{ github.ref }}
      cancel-in-progress: false
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - name: Print cache state
        run: |
          du -sh ~/Library/Developer/Xcode/DerivedData || true
          du -sh ~/Library/Caches/CocoaPods || true
      - name: Install pods
        run: |
          cd ios
          pod install
      - name: Build archive
        run: xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release archive

For TestFlight and signing specifics, keep this article high-level and use your team’s signing workflow guide. If you need a concrete CI/CD pattern for IPA generation and upload steps, the closest practical reference here is the Flutter iOS CI article, which discusses TestFlight flow and cache behavior in a similarly structured pipeline.

6) Hardware sizing: when 16GB is enough, when 24GB pays back

Choosing a machine tier is less about bragging rights and more about your concurrency model. A single release lane on a moderately sized app often runs fine on M4 16GB if your cache discipline is good and disk headroom stays healthy. Memory pressure appears when teams run archive jobs in parallel or pile additional services on the same host.

Runner profile Good fit Watch-outs
M4 16GB + standard disk Single app, one release lane, moderate dependency graph Can choke on parallel archives or huge asset/catalog workloads
M4 24GB + larger disk Multiple targets, heavier plugin/native mixes, PR + release overlap Needs policy for cache growth so large disk stays useful, not messy

Disk is part of performance planning, not just storage planning. If the volume sits above ~85% for long periods, your “persistent cache advantage” erodes quickly. Expansion or runner split is usually cheaper than oscillating between over-cleaning and slow cold rebuilds.

7) Hosted vs self-hosted benchmark (sample medians)

This side-by-side captures the behavior teams care about most: same repo class, repeated runs, and medians rather than best-case cherry picks. Again, this is not a guaranteed target. It is the observed directional impact after moving the iOS lane to dedicated self-hosted capacity.

Phase Hosted macOS runner Mac mini self-hosted runner
queued ~7 min ~0 min
bootstrap ~9 min ~2 min
build / archive ~11 min ~4 min
sign + upload ~5 min ~3 min
Total median ~28 min ~9 min

The reason this gap matters is compounding developer wait time. Even if each individual run “only” saves 10-20 minutes, release cadence and retries multiply that cost across the team every week.

8) 48-hour validation checklist before committing long-term

  1. Provision one dedicated Mac mini runner in the nearest practical region for your Git remote and upload path, then verify SSH and reboot persistence.
  2. Register self-hosted runner labels cleanly (macos, ios-ci) and pin your iOS workflow to that label set only.
  3. Run the same release pipeline at least twice back-to-back and record queue/bootstrap/build/sign timings for both runs.
  4. Confirm cache paths are not being cleaned by legacy scripts; inspect DerivedData, CocoaPods cache, and package dirs between runs.
  5. Compare 30-day hosted macOS minute spend against a fixed dedicated node plan and include engineer wait-time reduction in the decision.
Real-talk boundary: this post stays focused on runner throughput and pipeline timing. We are not rehashing every signing edge case or every regional price matrix in full detail here. Get the lane fast first, then tune extras. If you need plan details next, start from the pricing page and iterate with your own workload telemetry.

Conclusion in one practical decision tree

  1. If queue and cold bootstrap dominate your timeline, prioritize a dedicated self-hosted runner before buying more hosted minutes.
  2. If your second run is not materially faster than your first run, your cache policy is broken somewhere in workflow or host maintenance.
  3. If hosted cost plus wait-time pain keeps rising, lock a stable runner seat and treat it as core delivery infrastructure, not a temporary hack.

9) FAQ (the questions teams actually ask in standup)

Are we sure this is not just “our codebase is too big”?

Large repos absolutely add compile work, but that is only part of the story. If your first and second run look almost identical on hosted macOS, the system is paying repeated setup tax. Big codebase plus cold runner is brutal; big codebase plus warm persistent runner is usually manageable.

Can we mix hosted and self-hosted, or is it all-or-nothing?

Mixing is the normal pattern. Keep Linux checks and cheap jobs hosted. Route iOS archive/release to self-hosted labels. That split gives better economics and better predictability without forcing a full migration of every workflow in your org.

What about security? Is self-hosted runner risky?

It can be safe when scoped correctly: private repos, controlled membership, strict secrets handling, and careful rules around untrusted pull requests. Treat the runner like production-adjacent infrastructure, not a disposable laptop with admin rights for everyone.

Do we need to change signing and TestFlight flow right away?

Not necessarily. Start by stabilizing compute and cache persistence. Then harden signing ergonomics in a second pass. The workflow design patterns in the Flutter iOS CI/CD guide are a useful practical reference for TestFlight-oriented release lanes.

Will 28 to 9 minutes happen for every team?

No. It is a sample median from recurring workloads, not a promise. Tiny apps with already-optimized hosted paths may see smaller gains; very heavy monorepos may need additional tuning. Use the number as directional motivation, then benchmark your own repository for 48 hours.

How many runners should we start with?

Start with one dedicated runner for release, then add capacity only when timing data proves overlap pain. Buying two nodes before measuring concurrency needs is usually worse than buying one node and instrumenting queue/build segments properly.

Does region matter for iOS CI, or just raw machine specs?

Region still matters because dependency fetches, Git operations, and upload endpoints add network latency. A slightly less “perfect” machine in a better-located region can outperform a stronger but distant node in real end-to-end runtime.

Is this still relevant if we mostly write on Windows or Linux?

Yes, maybe even more relevant. You can keep dev ergonomics where your team is comfortable and reserve macOS for what only macOS can do: archive, signing, and Apple distribution steps. That separation often improves both velocity and tooling sanity.

Bottom line: when iOS CI feels mysteriously slow, stop framing it as a chip-generation problem first. Treat it as a runner-lifecycle problem. Give the workflow a permanent seat, keep cache state alive, and measure each phase like an engineering system. That one architectural shift usually does more than another month of micro-tweaks.

View plans