Flutter CI/CD with Fastlane & GitHub Actions — Part 1: iOS
Series note: This one grew big while I was writing it, so I'm splitting it into 3 parts. Part 1 (this one) gets you shipping to TestFlight from your local machine. Part 2 will do the same for Android. Part 3 is where we hand the whole thing over to GitHub Actions and never touch a build machine again 🚀
As a Flutter and mobile developer it was always tedious for me to deploy Android and iOS versions to Google and Apple stores especially when there are a lot of releases and bug fixes, and as for today, things become easier for Front and Backend developers when there are a lot of tools to handle code builds and deployment over different platforms such as Docker, Kubernetes and much more.
For today's blog, I'll take you on a journey step by step as to how to simplify that kind of work for your day-to-day work as a Mobile developer or maybe just a Mobile enthusiast.
First thing first, let's get an idea of what tools we're going to use today (and as always, you can skip the boring parts if you know what to do)
What is GitHub Actions?
GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline. You can create workflows that build and test every pull request to your repository, or deploy merged pull requests to production.
GitHub Actions goes beyond just DevOps and lets you run workflows when other events happen in your repository. For example, you can run a workflow to automatically add the appropriate labels whenever someone creates a new issue in your repository.
GitHub provides Linux, Windows, and macOS virtual machines to run your workflows, or you can host your own self-hosted runners in your own data center or cloud infrastructure.
What is Fastlane?
In short, Fastlane is an easy way to automate the creation and release of new versions to test and production environments for Android/iOS/Mac apps.
Think of it as a Ruby-powered swiss army knife: it drives Xcode and Gradle for you, handles signing, bumps version numbers, uploads to TestFlight or Play Console, posts to Slack when things break — all through simple "lanes" you define once and forget about.
AND YES THIS IS THE KIND OF THING YOU CAN GOOGLE IN 5 SECONDS.. I know I know 🥲
Yaaaaaaaaay! let's go to the important part
1. Prepare your existing project, or create a new project in Flutter
Before we start, make sure to go over your Google Play Console and App Store Connect and create the projects with your app's package name or bundle ID.
iOS — App Store Connect prerequisites
Head to App Store Connect and make sure you have:
- An app record created (My Apps → + → New App), with a bundle ID that matches your Flutter iOS project's bundle ID exactly
- Access as an Admin or App Manager (you'll need this to generate API keys later)
- Your Apple Developer account is active (paid, $99/year — Fastlane can't work miracles here)
Once your app exists in App Store Connect, we can start wiring things up locally.
Fastlane Installation Process:
Check your Ruby version:
ruby --version
Mine is
ruby 3.4.10 (2026-06-30 revision e7935c68b6) [arm64-darwin24]
If you have never set up Ruby, please do as follows (Ruby is pre-installed, but don't use the default version)
Install Homebrew
/bin/bash -c \
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Install Ruby
brew install ruby
Install Fastlane
gem install fastlane
Check Fastlane version
fastlane -v
fastlane installation at path:
/opt/homebrew/Cellar/fastlane/2.236.1/libexec/gems/fastlane-2.236.1/bin/fastlane
-----------------------------
[✔] 🚀
fastlane 2.236.1
👏👏 Congratulations, you have succeeded in what took me a week of setup 🥲
Fastlane Setup for iOS:
Open your Flutter project in VS Code and open the terminal, then go to your iOS path
cd ios
Then launch Fastlane initialization via the following command
fastlane init
Fastlane will ask you what you want to use it for. You'll see 4 options:
- 📸 Automate screenshots
- 👩✈️ Automate beta distribution to TestFlight
- 🚀 Automate App Store distribution
- 🛠 Manual setup — manually setup your project to automate your tasks
Pick option 4. The other three assume you'll authenticate with your Apple ID + password, which is a nightmare once 2FA is involved (and it IS involved, Apple made it mandatory). We're going the modern route with an App Store Connect API key.
After it finishes, you'll have a new fastlane/ folder inside your ios/ directory with:
Appfile→ identity and team IDsFastfile→ where the actual lanes live ✨
Generate an App Store Connect API Key
This is the piece that will make Fastlane happy on any machine — including GitHub runners later.
Head to App Store Connect → Users and Access → Integrations → App Store Connect API
Click the (+) button and generate a new key with the App Manager role.
IMPORTANT: download the .p8 file IMMEDIATELY when Apple offers it. You only get ONE chance to download it. Lose it and you'll have to revoke it and start over (ask me how I know 🥲)
You'll need three things from that page:
- Key ID — a short string next to the key name
- Issuer ID — a UUID at the top of the Keys page (same for all your keys)
- The .p8 file itself
I usually drop the .p8 under ios/fastlane/AuthKey_XXXXXXXXXX.p8 and add it to .gitignore right away, because that file is basically a password.
# .gitignore
ios/fastlane/AuthKey_*.p8
ios/fastlane/report.xml
ios/fastlane/README.md
Configure the Appfile
Open ios/fastlane/Appfile and replace whatever it generated with:
app_identifier("com.your.package") # Your app's bundle ID
apple_id("your.email@example.com") # Optional — we won't really use it
itc_team_id("XXXXXXXX") # App Store Connect team ID
team_id("XXXXXXXXXX") # Developer Portal team ID
If you're not sure what your team IDs are, run fastlane produce -u your.email@example.com once and it'll tell you, or check the top-right team dropdown at developer.apple.com.
Write your first Fastfile
Open ios/fastlane/Fastfile and paste this:
default_platform(:ios)
APP_STORE_CONNECT_API_KEY_ID = "YOUR_KEY_ID"
APP_STORE_CONNECT_API_ISSUER_ID = "YOUR_ISSUER_ID"
APP_STORE_CONNECT_API_KEY_PATH = "./AuthKey_YOUR_KEY_ID.p8"
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do
# Authenticate with App Store Connect using the API key
api_key = app_store_connect_api_key(
key_id: APP_STORE_CONNECT_API_KEY_ID,
issuer_id: APP_STORE_CONNECT_API_ISSUER_ID,
key_filepath: APP_STORE_CONNECT_API_KEY_PATH,
duration: 1200,
in_house: false
)
# Bump build number based on the latest one in TestFlight
increment_build_number(
build_number: latest_testflight_build_number(api_key: api_key) + 1,
xcodeproj: "Runner.xcodeproj"
)
# Let Flutter compile the Dart side first (no signing yet)
sh("cd ../.. && flutter build ios --release --no-codesign")
# Then let Fastlane's gym handle signing + archiving
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store"
)
# Ship it 🚀
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true
)
end
end
Two things worth calling out here because they tripped me up early on:
- Notice we call
flutter build ios --no-codesignFIRST, then let Fastlane'sbuild_app(a.k.a.gym) handle the signing and archiving. This split matters — Flutter compiles the Dart into an Xcode-buildable state, and Fastlane takes over from there. Trying to make Flutter and Fastlane both sign is a fast track to a headache. latest_testflight_build_number(...) + 1means your build numbers monotonically increase forever without you thinking about it. This one line alone saved me from at least a dozenERROR ITMS-90062: This bundle is invalidnightmares.
Give it a spin
From your ios/ directory:
fastlane beta
Sit back. Grab a coffee ☕. This will take a few minutes the first time (Xcode has to fetch signing assets, compile, archive, export, and upload).
If everything goes right, you'll see a big green success block at the end of the fastlane output, and a few minutes later Apple will email you: "Your build has completed processing." Open TestFlight in App Store Connect and there it is — waiting for testers 🎉
Wait, what about signing certificates?
I hear you. If your first fastlane beta fails with a signing error (and honestly, it probably will), you've got two ways out:
- Manual → open Xcode, go to Signing & Capabilities, tick "Automatically manage signing", and let Xcode handle it. This is fine when you're the only dev on the project.
- Fastlane
match→ the proper move.matchstores your certificates and provisioning profiles in a private Git repo, encrypted, so any machine (or GitHub runner) can pull them down and sign IDENTICALLY. No more "works on my machine" signing drama.
I was going to squeeze match into this article too but it deserves its own walkthrough alongside the Android setup, so I'm parking it for Part 2.
PS: I didn't mind much about the design or the folder architecture, the sole reason for this article is to show you how to get a Flutter iOS build to TestFlight without opening Xcode every single time.
What's next
- Part 2: Android Fastlane setup +
matchfor certificate management on both platforms - Part 3: Wiring the whole thing into GitHub Actions with encrypted secrets, so every push to
main(or every tag) auto-deploys
Hope this helps you skip the week of setup pain I went through 🥲
Enjoy!
References:
Oussama Aniba Newsletter
Join the newsletter to receive the latest updates in your inbox.