Running flutter build ipa from the terminal on every push and uploading it to App Store Connect by hand doesn't cause problems while you're working alone; but the moment the team grows and the number of platforms increases, it stops being sustainable. A Flutter CI/CD pipeline built with the GitHub Actions and Fastlane duo automatically runs the analyze-test-build-deploy steps every time code is pushed and removes human error from the process. In this guide we set this pipeline up from scratch, keeping an eye on macOS runner cost.
💡 Pro Tip: Trigger the deploy job only on pushes to the main branch; uploading to TestFlight on every PR wastes both your minute budget and floods testers' notification inboxes unnecessarily.Table of Contents
- The Pipeline's 4 Stages: Analyze, Test, Build, Deploy
- GitHub Actions Workflow Skeleton
- Triggers (on)
- Dependency Between Jobs (needs)
- macOS Runner Cost and Cache Strategy
- Certificate and Profile Management with Fastlane match
- Creating a Matchfile
- Usage in the CI Lane
- TestFlight and Play Internal Testing Upload
- iOS: TestFlight with pilot
- Android: Play Internal Testing with supply
- Version Number and Build Number Automation
- Secret Management and Security
- Multiple Builds by Flavor
- The Flavor Parameter in the Fastfile
- Parallel Flavors with a Matrix Build
- 5 Common CI Mistakes
- FAQ
- How do you build a Flutter app with GitHub Actions?
- How do you automate uploading to TestFlight from Flutter?
- How are iOS code-signing certificates managed in CI?
- What are the free CI options for Flutter?
- How is uploading to internal testing automated on the Android side?
- Update (September 2026)
- Conclusion
- Sources
The Pipeline's 4 Stages: Analyze, Test, Build, Deploy
Splitting a Flutter CI/CD pipeline into four separate steps, instead of cramming it into a single dev job, catches errors earlier and avoids wasting expensive macOS runner minutes. Flutter's official continuous delivery guide also recommends setting up deployment through fastlane in separate stages.
Stage | Command / Tool | Purpose |
|---|---|---|
Analyze | flutter analyze | Static analysis, lint errors — takes seconds, can run on a Linux runner |
Test | flutter test | Unit + widget tests — runs on a Linux runner, doesn't need macOS |
Build | flutter build ipa / flutter build appbundle | Produces platform binaries — a macOS runner is required for iOS |
Deploy | fastlane pilot / supply | Upload to TestFlight and Play Internal Testing |
The critical point: only the build and deploy steps need a macOS runner. Running the analyze and test steps on a cheap ubuntu-latest runner directly lowers your minute bill.
GitHub Actions Workflow Skeleton
A workflow on GitHub Actions is a YAML file under .github/workflows/; it consists of name, on (trigger) and jobs fields, and each job in turn contains runs-on (the runner it runs on) and steps (the list of steps). The skeleton below splits the 4 stages into separate jobs; the test job runs on Linux, and the build/deploy jobs run on macOS:
1name: Flutter CI/CD2 3on:4 push:5 branches: [main]6 pull_request:7 branches: [main]8 9jobs:10 analyze_test:11 runs-on: ubuntu-latest12 steps:13 - uses: actions/checkout@v414 - uses: subosito/flutter-action@v215 with:16 flutter-version: "3.27.0"17 - run: flutter pub get18 - run: flutter analyze19 - run: flutter test20 21 build_deploy:22 needs: analyze_test23 if: github.ref == 'refs/heads/main'24 runs-on: macos-1425 steps:26 - uses: actions/checkout@v427 - uses: subosito/flutter-action@v228 with:29 flutter-version: "3.27.0"30 - run: flutter pub get31 - name: Install signing via fastlane match32 run: cd ios && bundle exec fastlane signing33 env:34 MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}35 - run: flutter build ipa --release36 - name: Deploy via fastlane37 run: cd ios && bundle exec fastlane release38 env:39 APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}The subosito/flutter-action community action installs a specific Flutter SDK version on the runner; the official GitHub Actions documentation describes a workflow as consisting of name, on, jobs, runs-on and steps fields, and describes uses: actions/checkout@v4 as checking out the repo's code onto the runner.
Triggers (on)
The on block determines when the workflow runs. The example above has both a push and a pull_request trigger; but the if: github.ref == 'refs/heads/main' condition on the build_deploy job ensures that only the analyze_test job runs on PRs, while the build and deploy steps are only triggered when an actual push lands on main. This separation prevents every opened PR from burning unnecessary macOS runner minutes.
Dependency Between Jobs (needs)
The needs: analyze_test line guarantees that the build_deploy job will not start before the analyze_test job finishes successfully. If analysis or tests come back red, the build is never triggered — this stops a broken piece of code from ever making it to TestFlight in the first place.
macOS Runner Cost and Cache Strategy
macOS runners are noticeably more expensive than Linux runners on GitHub Actions; that's why in the skeleton above we deliberately assigned the analyze/test job to ubuntu-latest, and only build/deploy to macos-14. The second way to cut cost further is caching.
GitHub's official actions/cache documentation states that the action first looks for an exact match with key, and if it can't find one, falls back to a partial match in order with restore-keys. In Flutter projects you can cache the pub cache like this:
1- uses: actions/cache@v42 with:3 path: |4 ~/.pub-cache5 **/.dart_tool6 key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}7 restore-keys: |8 ${{ runner.os }}-pub-As long as pubspec.lock doesn't change, this cache stays the same and flutter pub get finishes in seconds. On the iOS side you can similarly cache the ios/Pods directory keyed on the Podfile.lock hash for CocoaPods — this directly shortens one of the slowest steps on the macOS runner (pod install).
Certificate and Profile Management with Fastlane match
iOS code signing is the most headache-inducing part of CI, because certificates and provisioning profiles are normally tied to a single Mac. fastlane's match tool solves this: according to the official documentation, match "creates all the certificates and provisioning profiles you need and stores them in a separate git repository, Google Cloud, or Amazon S3," so that the team and CI share the same signing identity.
Storage | How it works | When to prefer it |
|---|---|---|
Git repo | Certificates are encrypted with OpenSSL and pushed to a private repo | Small-to-medium team, don't want an extra cloud account |
Google Cloud | Encrypted with keys Google manages and stored in GCS | Teams already using GCP |
Amazon S3 | Stored in a bucket you provide yourself | Teams already using AWS |
Creating a Matchfile
Running fastlane match init creates a Matchfile:
1git_url("https://github.com/<org>/certificates")2app_identifier("com.example.app")3username("[email protected]")Usage in the CI Lane
match must be called before the step that produces the signed build — the documentation states this explicitly: "match should be called before building with gym". In this pipeline the IPA is produced by the workflow's flutter build ipa step rather than fastlane's gym action, so match lives in a separate signing lane that the workflow calls before the build step:
1lane :signing do2 match(type: "appstore", readonly: true)3endUsing readonly: true in CI prevents the pipeline from accidentally generating a new certificate — this distinction is especially critical in a multi-developer + CI combination.
TestFlight and Play Internal Testing Upload
iOS: TestFlight with pilot
Once the build is signed, it's time to distribute it. fastlane's pilot action (also known as upload_to_testflight) uploads the build to TestFlight; the official documentation lists not needing 2FA and better performance over an Apple ID among the advantages of the API key method:
1lane :release do2 pilot(3 ipa: Dir[File.expand_path("../../build/ios/ipa/*.ipa")].first,4 api_key_path: "./fastlane/api_key.json",5 skip_waiting_for_build_processing: true6 )7endThe ipa parameter gives the path of the file to upload: the lane doesn't build again, it uploads the IPA that the workflow's flutter build ipa step produced under build/ios/ipa/ (Ruby code in the Fastfile runs from the fastlane/ folder, so the path starts two directories up). The skip_waiting_for_build_processing: true parameter controls whether fastlane waits for the build to finish processing on Apple's side; if you don't want the CI job hanging for minutes after the build is uploaded, leaving this value true lets the job trigger the upload and finish right away — but in that case distribute_external will not work and the build is not distributed to testers automatically.
Android: Play Internal Testing with supply
The Android counterpart is the upload_to_play_store action (also known as supply). According to the documentation the default track options are production, beta, alpha, internal — uploading to the internal track rather than directly to production on every push is the right habit from CI:
1platform :android do2 lane :release do3 upload_to_play_store(4 track: "internal",5 json_key: ENV["PLAY_STORE_JSON_KEY"]6 )7 end8endThe json_key parameter points to the path of the credentials file for the Google Cloud service account (or its contents coming from an environment variable in CI). Separating these two lanes in the same Fastfile with platform :ios do ... end and platform :android do ... end blocks lets you manage both platforms from a single file.
Version Number and Build Number Automation
In Flutter, version information is kept on a single line inside pubspec.yaml; according to the official documentation the format is {version}+{build-number}:
1version: 1.0.0+1Here 1.0.0 is the version visible to the user (CFBundleShortVersionString on iOS), and 1 is the build number (CFBundleVersion). Instead of updating this value by hand on every build in CI, you can generate the build number from GitHub Actions' own run counter and override it from the command line — the documentation explicitly supports this override:
1- name: Build IPA2 run: |3 flutter build ipa \4 --build-name=1.2.0 \5 --build-number=${{ github.run_number }}github.run_number is a context variable automatically provided by GitHub that tells you how many times that workflow has run in that repo; it increases on every push, giving you a build number that's guaranteed to be unique. You still typically set the --build-name value by hand, the way it's kept in pubspec.yaml, or read it from a git tag — meaningful version numbering (semver) still requires a human decision; only the build number part is automated.
Secret Management and Security
MATCH_PASSWORD, the App Store Connect API key, the Play Store service account JSON — none of these should ever be committed to the repo. GitHub's official guide describes adding a secret via Settings → Secrets and variables → Actions → New repository secret or with the CLI:
1gh secret set MATCH_PASSWORDIn the workflow YAML, the secret is accessed through the secrets context:
1env:2 MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}A critical security detail: GitHub's documentation explicitly states that secrets other than GITHUB_TOKEN are never passed to the runner on pull requests coming from a fork. This means that even if an outsider opens a PR and modifies the workflow file to try to print secrets to the console, the attempt will fail — but this protection only applies to fork PRs; PRs opened from branches within the same repo can access secrets. Flutter's own CI guide also stresses that you shouldn't "re-echo" secret values to the console in test scripts; leaving debug modes like set -x turned on in fastlane lanes is the most common reason secrets leak into logs.
Multiple Builds by Flavor
The Flavor Parameter in the Fastfile
Each of the dev/staging/prod flavors needs a different bundle ID, different signing and a different App Store Connect/Play Console registration. The way to manage this from a single Fastfile is to pass the flavor name as a parameter. The --flavor flag you pass to flutter build ipa and the entry-point file you specify with -t (like lib/main_dev.dart, lib/main_prod.dart) are the standard structure Flutter uses to produce flavor-specific builds; each flavor is wired to its own Info.plist/bundle ID setting through an Xcode scheme:
1lane :release do |options|2 flavor = options[:flavor] || "prod"3 match(type: "appstore", app_identifier: "com.example.app.#{flavor}", readonly: true)4 sh("flutter build ipa --flavor #{flavor} --release " \5 "-t lib/main_#{flavor}.dart")6 pilot(7 ipa: Dir[File.expand_path("../../build/ios/ipa/*.ipa")].first,8 api_key_path: "./fastlane/api_key.json"9 )10endParallel Flavors with a Matrix Build
On the GitHub Actions side you can trigger this with a matrix build, running a separate job for each flavor:
1strategy:2 matrix:3 flavor: [dev, staging, prod]4steps:5 - run: bundle exec fastlane release flavor:${{ matrix.flavor }}This structure runs all three flavors as parallel jobs in a single workflow file — each pulls its own certificate with its own match app_identifier, without mixing with the others. As the number of flavors grows, adding a new line to the matrix carries far less maintenance burden than duplicating the Fastfile.
5 Common CI Mistakes
- Running match without readonly: if you don't pass
readonly: truein CI, every build can try to generate a new certificate and you'll hit Apple's certificate limit; generate certificates only locally, by hand, and leave CI read-only. - Writing secrets in plaintext in the workflow file: a line like
env: MATCH_PASSWORD: "real-password"gets permanently baked into the repo's history; always use thesecretscontext. - Combining test and build into a single job: when one widget test turns out flaky, your entire macOS runner minutes (build included) go to waste; keep tests on a separate, cheap runner.
- Forgetting to update the build number by hand: if
flutter build ipaships with the same build number every time, App Store Connect will reject the upload; automating this in CI (see the section above) permanently eliminates this mistake. - Setting up the cache key wrong: if you make
keya static string (e.g. justpub-cache), stale dependencies get served even afterpubspec.lockchanges; always tie the key to a content-sensitive value likehashFiles('**/pubspec.lock').
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
If you've applied this guide from start to finish, you should now have a Flutter CI/CD pipeline set up in your own project. The checklist below summarizes, in the order the article covered them, the steps you should review before integrating it into your project — check off each item to see whether you've left a step incomplete.
FAQ
How do you build a Flutter app with GitHub Actions?
You add a YAML file to your repo's .github/workflows/ folder; inside it you install the Flutter SDK with a community action like subosito/flutter-action, then run flutter pub get and, depending on the platform, flutter build appbundle (Android) or flutter build ipa (iOS, requires a macOS runner). The GitHub Actions Workflow Skeleton section in this article has a complete example.
How do you automate uploading to TestFlight from Flutter?
fastlane's pilot action (upload_to_testflight) does this. After signing and building the IPA (match + flutter build ipa), calling pilot(ipa: "...", api_key_path: "...") in your Fastfile is enough; using an App Store Connect API key removes the 2FA requirement.
How are iOS code-signing certificates managed in CI?
With fastlane match. Certificates and provisioning profiles are encrypted and stored in a git repository, Google Cloud, or S3; CI pulls these certificates read-only with a match(readonly: true) call, without generating a new certificate.
What are the free CI options for Flutter?
GitHub Actions is free with unlimited minutes for public repos and a set monthly minute quota for private repos; however, macOS runner minutes are deducted from the quota at a multiplier several times higher than Linux runners, which is why moving build/deploy steps to macOS only when needed, as in this article, protects the budget.
How is uploading to internal testing automated on the Android side?
With fastlane's upload_to_play_store (supply) action; the track: "internal" parameter uploads the build directly to the Internal Testing track in Play Console instead of production, and the json_key parameter passes the Google Cloud service account credentials.
Update (September 2026)
This guide was written in February 2025; since then, two changes to Apple's App Store Connect upload requirements directly affect this pipeline. According to Apple's official "Upcoming requirements" page, as of April 28, 2026, apps uploaded to App Store Connect must be built with Xcode 26 or later, using the iOS 26/iPadOS 26/tvOS 26/visionOS 26/watchOS 26 SDK. This means workflows using an older runner image like macos-14 now need to move to a newer macOS runner (for example macos-15 or later) that hosts a compatible Xcode version; tracking image updates in GitHub's runner-images repo should be your reference point for this transition.
The same page also clarified that, as of September 9, 2026, iOS/iPadOS apps uploaded to App Store Connect must target at least iOS 13 — meaning you can't keep your deployment target below that. You need to keep the iOS Deployment Target field in your project's Xcode settings, and the minimum version Flutter itself supports (which you can check on the official Supported deployment platforms page), aligned with these two requirements. No other change is needed in the Fastfile or CI workflow; what's actually affected is the runner image the build job runs on and the Xcode version.
Related posts published later:
- State Management with Flutter Riverpod
- Flutter Clean Architecture
- Flutter Firebase Integration
- Flutter Performance Optimization
Conclusion
A pipeline built with the Flutter CI/CD GitHub Actions and Fastlane duo rests on four simple principles: separate the stages (analyze/test on a cheap runner, build/deploy on macOS), share certificates with match, never write secrets into the repo, and automate the build number. Once you apply these four principles, the entire manual build-and-upload process disappears, and every push turns into a release candidate that tests and deploys itself.
If you want to see how the same duo is set up on the native iOS side, you can check out the iOS CI/CD Pipeline: GitHub Actions and Fastlane guide — it covers the same match+gym+pilot flow specifically for a Swift/Xcode project; this article instead focuses on managing Flutter's two platforms (iOS+Android) from a single Fastfile.
If you're considering migrating from React Native, the React Native vs Flutter Comparison can help you decide.
Sources
- Continuous delivery with Flutter — Flutter Docs — fastlane setup, secret management, GitHub Actions/Codemagic/Xcode Cloud options.
- Build and release an iOS app — Flutter Docs — Bundle ID registration, pubspec.yaml version format,
flutter build ipaand TestFlight/App Store upload steps. - iOS setup — fastlane Docs — fastlane setup,
fastlane init, Fastfile structure. - match — fastlane Docs — certificate/profile sharing, storage options, Matchfile.
- pilot — fastlane Docs — automatic upload to TestFlight, using an App Store Connect API key.
- upload_to_play_store — fastlane Docs — Play Store track options, using json_key.
- Caching dependencies to speed up workflows — GitHub Docs —
actions/cache, key/restore-keys logic. - Using secrets in GitHub Actions — GitHub Docs — adding secrets, secret access restriction on fork PRs.
- SDK minimum requirements — Apple Developer — Xcode/iOS SDK requirement dates (April 2026, September 2026).
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

