A message popped up in the team chat: "Android build finishes in three minutes on CI, but the iOS build took fifteen minutes locally, and I still had to switch Xcode versions by hand." That's daily life for a lot of Flutter teams — one codebase, two build pipelines, each with its own environment to babysit. Move both platforms' release builds onto the same cloud Mac mini, lock the Flutter version with fvm, and get Gradle and CocoaPods caching sorted out, and the two pipelines end up sharing most of the warm-up cost. Here's a setup that actually works in practice.
Why put both build pipelines on one Mac
Android builds alone could run on a cheap Linux box, but the real pain point in Flutter projects is that iOS and Android share the same pubspec.yaml dependencies and the same Flutter SDK version. If the two environments are split apart, the team ends up maintaining two separate version-alignment records, and every Flutter upgrade has to be verified on two machines instead of one. Put everything on a single dedicated physical Mac mini, and ~/fvm, ~/.pub-cache, and the Gradle/CocoaPods cache directories can all be reused by both platforms. What you save isn't build CPU time — it's the repeated downloading and re-resolving of dependencies.
A dedicated physical machine isn't just about raw performance — it's about cache continuity. Caches on a shared VM can get evicted by a neighbor's build at any moment; a dedicated environment never has that problem.
Lock the version with fvm so the whole team builds against the same SDK
Pin the version at the project root:
brew install fvm
fvm install 3.24.3
fvm use 3.24.3 --force
echo "3.24.3" > .fvmrc
From then on, every build command should go through fvm flutter instead of the global flutter, so nobody's local SDK version drifts:
fvm flutter pub get
fvm flutter build ios --release --no-codesign
fvm flutter build appbundle --release
Add a version check to the CI script to catch cases where someone changed their global Flutter install but forgot to update .fvmrc:
LOCKED=$(cat .fvmrc)
CURRENT=$(fvm flutter --version | head -1 | awk '{print $2}')
if [ "$LOCKED" != "$CURRENT" ]; then
echo "flutter version mismatch: locked=$LOCKED current=$CURRENT" >&2
exit 1
fi
Android side: SDK, NDK, and the Gradle cache
A cloud Mac mini doesn't ship with the Android SDK by default. For a first-time setup, go in this order:
brew install --cask android-commandlinetoolssdkmanager --install "platforms;android-34" "build-tools;34.0.0" "ndk;26.3.11579264"- Enable parallelism and the daemon in
~/.gradle/gradle.properties:
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4096m
The Gradle daemon exits by default after ten minutes of idle time once a build finishes. If your build cadence is tight, it's worth explicitly setting --max-workers to (core count minus one), so Gradle doesn't compete with xcodebuild on the iOS side for CPU.
The first flutter build appbundle will pull down all Gradle dependencies from scratch — time it, so you have a clear before/after baseline once the cache kicks in.
iOS side: certificates, the Pod cache, and flutter build ipa
CocoaPods caching has two layers: the pod spec index pulled from the CDN, and the actual pod source code itself:
pod cache list | head
export CP_HOME_DIR="$HOME/.cocoapods-cache"
cd ios && pod install --repo-update
For certificates and provisioning profiles, centralized management along the lines of match is preferable to manually importing them every time. Once that's set up, shipping a build is a single command:
fvm flutter build ipa --release \
--export-options-plist=ios/ExportOptions.plist
Nine out of ten signing errors trace back to a provisioning profile that doesn't match the bundle ID. Before you even kick off the build, run security find-identity -v -p codesigning to confirm the signing identity is still valid in the keychain — much faster than debugging it after a failed build.
Pipeline timing, side by side
On a single M4 cloud instance, build times before and after cache warm-up differ substantially:
| Stage | First run (cold cache) | Subsequent run (warm cache) |
|---|---|---|
pub get dependency resolution |
~40 seconds | ~8 seconds |
| Android Gradle build | ~6 minutes | ~1 min 40 sec |
| iOS Pod install | ~2 minutes | ~20 seconds |
| iOS xcodebuild packaging | ~5 minutes | ~3 minutes |
A warm cache assumes ~/.gradle, ~/.pub-cache, ios/Pods, and ~/fvm haven't been wiped. For team workflows, it's worth backing up all four directories at the machine level rather than having every individual maintain their own separate cache.
Common pitfalls checklist
- NDK version mismatched with the Gradle plugin version:
ndk;26.3.11579264needs to exactly match thendkVersiondeclared inandroid/app/build.gradle. Otherwise the error only shows up at build time, wasting an entire round. - CocoaPods out of sync with the Xcode version: after upgrading Xcode, skipping
pod deintegrate && pod installcan cause intermittent signing failures that get misdiagnosed as certificate problems. - fvm cache directory accidentally cleaned up: when clearing disk space,
~/fvm/versionsis easy to mistake for orphaned files and delete. Add it to your exclusion list explicitly. - Gradle daemon holding onto memory: on machines running builds constantly, run
./gradlew --stopperiodically to free up memory for the iOS build that follows.
Frequently asked questions
Do I really need a Mac mini just for the Android build?
Not strictly, but since a Flutter project shares one codebase and dependency set across both platforms, keeping the Android build on the same dedicated Mac mini lets it reuse the fvm-pinned SDK, pub cache and Gradle cache that iOS already warmed up, which is cheaper than maintaining a separate Linux runner for a short-term project.
After pinning a version with fvm, does a new teammate have to redownload the whole SDK?
Only the first time. fvm reads the version from .fvmrc and downloads it once into ~/fvm/versions; every teammate on the same dedicated machine after that reuses the same cached SDK, so on a shared cloud Mac mini the second build onward has essentially zero setup wait.
If Gradle and CocoaPods caches live on the rented machine, do they disappear when the rental ends?
During your rental period the dedicated machine is fully yours and nothing gets wiped automatically, but before letting the rental lapse you should archive ~/.gradle, ~/.pub-cache and the Pods directory so a renewed rental can restore them instead of rebuilding every cache from scratch.
portal://order
Need one right now?
A dedicated physical Mac mini, billed by the day, ready to use in about 4 minutes from payment.