Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Build Flutter iOS Apps in CI on a Mac Runner

For a Flutter iOS pipeline that archives and ships to TestFlight, request a Figment M-series Mac from $95/month at /mac-hosting/start and register it as a GitHub self-hosted runner. The exact M-series chip, RAM and disk are confirmed in the quote; physical plans collect requests until supply is confirmed. If you want someone else to own runner setup, backups and monitoring, the Managed Build Mac plan at $249/month bundles that work on one M-series Mac with 30 minutes of monthly support. The $29 virtual Intel macOS VM (2 vCPU, 8 GiB RAM, 80 GiB disk) can host an Intel-compatible Xcode 26-or-earlier toolchain and cross-compile iOS targets, but Dart's arm64 AOT compile is faster and the long-term toolchain path belongs on Apple silicon — Xcode 27 is Apple-silicon-only per Apple's porting notes.

Flutter iOS CI stitches four tools together on one Mac — Flutter, CocoaPods, Ruby, and Xcode — in a specific order, documented in the primary Flutter iOS deployment guide. The extra CI problem is Flutter SDK drift between developers and runners. This page solves that with FVM (Flutter Version Management), commits a signing export plist, and ends on a pilot upload to TestFlight.

What the Runner Needs

flutter build ipa is not one command; it drives several underneath:

  1. flutter pub get fetches Dart packages and writes the Flutter-engine Podfile entries.
  2. pod install resolves the CocoaPods graph for the iOS plugins.
  3. xcodebuild archive builds the generated Runner.xcworkspace.
  4. xcodebuild -exportArchive produces the signed IPA using your ExportOptions.plist.

So the Mac needs Xcode, CocoaPods, Ruby (CocoaPods and Fastlane), and Flutter. This workflow installs Flutter on the runner via FVM so the only preinstalled requirements are Xcode and a Ruby toolchain. On a Figment M-series Mac, the Dart AOT compile runs native arm64, which is where most of the per-build time goes on real apps.

Pin Flutter with FVM

FVM reads .fvmrc at the repo root. Commit it so every developer and every CI run compiles against the same release:

{
  "flutter": "3.24.3",
  "flavors": {}
}

Install FVM on the runner from the current tap. Per the FVM installation docs, the tap is btwld/fvm (migrated from the older leoafarias/fvm):

- name: Install FVM
  run: |
    brew tap btwld/fvm
    brew install btwld/fvm/fvm

- name: Install pinned Flutter
  run: fvm install

- name: Expose fvm flutter on PATH
  run: echo "$(fvm flutter --version --machine >/dev/null; fvm list --path 2>/dev/null || true)" >> /dev/null

After fvm install, call Flutter as fvm flutter for every subsequent step. FVM proxies the pinned version so you do not have to export a path manually.

Order: pub get, then pod install

Flutter plugins that expose native iOS code are written into ios/Podfile by flutter pub get. If pod install runs first, CocoaPods either fails or reuses a stale cached graph and ships the wrong plugin version.

- name: Dart dependencies
  run: fvm flutter pub get

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

- name: CocoaPods install
  run: cd ios && pod install

Keying the cache on Podfile.lock means any plugin bump invalidates it; unchanged graphs skip the network.

Export Options for Signing

Commit ios/ExportOptions.plist. A minimal App Store version:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>method</key><string>app-store</string>
  <key>teamID</key><string>YOUR_TEAM_ID</string>
  <key>uploadBitcode</key><false/>
</dict>
</plist>

Then:

fvm flutter build ipa --release \
  --export-options-plist=ios/ExportOptions.plist

The IPA lands in build/ios/ipa/. The signing identity and provisioning profile must be in place before this step — handle that with Fastlane Match against a private, encrypted certificates repo. Never export your whole shell environment to disk, and never import provisioning profiles into Keychain; profiles are files Xcode reads from ~/Library/MobileDevice/Provisioning Profiles/.

The Workflow

# .github/workflows/flutter-ios.yml
name: Flutter iOS CI

on:
  push:
    branches: [main, 'release/**']
  pull_request:
    branches: [main]

jobs:
  build-ios:
    runs-on: [self-hosted, macOS, figment]   # label your runner to match
    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3'
          bundler-cache: true

      - name: Install FVM
        run: |
          brew tap btwld/fvm
          brew install btwld/fvm/fvm

      - name: Install pinned Flutter
        run: fvm install

      - name: Dart dependencies
        run: fvm flutter pub get

      - uses: actions/cache@v4
        with:
          path: ios/Pods
          key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
          restore-keys: pods-${{ runner.os }}-

      - name: CocoaPods install
        run: cd ios && pod install

      - name: Sync signing (Fastlane Match, read-only)
        run: bundle exec fastlane match appstore --readonly
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_PRIVATE_KEY }}

      - name: Build IPA
        run: |
          set -euo pipefail
          fvm flutter build ipa --release \
            --export-options-plist=ios/ExportOptions.plist

      - name: Upload to TestFlight
        run: |
          set -euo pipefail
          IPA=$(ls build/ios/ipa/*.ipa | head -1)
          bundle exec fastlane pilot upload --ipa "$IPA"
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_PRIVATE_KEY }}

set -euo pipefail makes each script step stop on the first failure instead of letting a broken archive march on to TestFlight. This is a reference workflow to adapt, not a measured benchmark.

When Managed Build Mac Pays Back

Here is a worked worksheet, not a quote. Your team runs 40 Flutter iOS builds a week. On GitHub-hosted macOS at $0.062/minute, a 20-minute build costs $1.24 per run, or roughly 40 × 4.33 × $1.24 ≈ $215/month before any queueing. Figment Managed Build Mac is $249/month, flat, with CI setup, backups, monitoring and 30 minutes of monthly support on one M-series Mac. If you expect two 20-minute builds per push and the pipeline grows past ~215 runs per month, the managed plan is the simpler line-item; if volume is lower, the self-hosted M-series at $95/month plus your own setup time is the lower cash cost. The sibling guide GitHub-hosted vs self-hosted Mac runner costs steps through break-even with queue time included.

Checklist Before the First Run

  • .fvmrc committed with the target Flutter version.
  • ios/ExportOptions.plist committed with the correct teamID.
  • Fastlane Match repo created; certificates and profiles pushed.
  • GitHub Secrets set: MATCH_PASSWORD, MATCH_GIT_BASIC_AUTHORIZATION, ASC_KEY_ID, ASC_ISSUER_ID, ASC_PRIVATE_KEY.
  • Self-hosted runner registered with a label matching the runs-on list.
  • Xcode version on the runner matches your minimum deployment target; on Intel hosts, stay on Xcode 26 or earlier.

Request a configured Mac at Figment Mac hosting or start the quote at /mac-hosting/start.

Related guides: Send Slack alerts for iOS CI build status · GitHub-hosted vs self-hosted Mac runner costs · Remote Xcode setup on Windows


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms