Stop manual store clicks. Learn how to configure Fastlane locally to build, guard, and ship your Flutter app to TestFlight and Google Play with a single command.
Why bother
Every Flutter release usually looks like this: bump the version, build the IPA, open Xcode, wait for the organizer, switch to browsers, build the AAB, drag-and-drop into the Play Console, and fill out release notes. It’s ten minutes of tedious clicking that changes every time, meaning it never becomes muscle memory.
Fastlane turns that entire ritual into:
bundle exec fastlane ios deploy
bundle exec fastlane android deploy
This article is Part 1 : getting it working locally on your machine. Part 2 moves these exact lanes into GitHub Actions so a simple tag push handles the rest. Do this part first — the CI setup assumes your local lanes are already green.
What you’ll need
- A Flutter app that already builds and runs
- An Apple Developer account ($99/yr) with the app registered in App Store Connect
- A Google Play Console account ($25 one-time) with the app created and at least one AAB uploaded manually — Play won’t accept the first-ever build over the API.
- Ruby (macOS ships with one; a version manager like rbenv is nicer but not required)
- Xcode with a valid signing setup for your app (Automatic signing with your team selected is fine for local)
I’m on FVM, so my Flutter SDK lives at /Users/<you>/fvm/versions/3.32.0. Note your own path now — you'll need it in a minute.
Step 1 — Install fastlane via a Gemfile (not globally)
You can brew install fastlane or gem install fastlane, but pinning it per-project with Bundler means your machine and your CI runner use the exact same version. Flutter projects have a ios/ and an android/ folder, and each gets its own Gemfile.
ios/Gemfile
source "https://rubygems.org"
gem "fastlane"
gem "cocoapods"
android/Gemfile
source "https://rubygems.org"
gem "fastlane"
The iOS Gemfile also pins CocoaPods so the build doesn’t depend on whatever pod version happens to be on the system (this matters a lot on CI later). Android has no such dependency, so its Gemfile stays minimal.
Install both:
cd ios && bundle install && cd ..
cd android && bundle install && cd ..
From here on, always call fastlane as bundle exec fastlane … so you get the pinned version.
Step 2 — Set FLUTTER_ROOT
fastlane’s iOS build scripts shell out to Flutter and need to know where the SDK is. Add this to your shell profile (~/.zshrc):
export FLUTTER_ROOT="/Users/<you>/fvm/versions/3.32.0"
If you’re not on FVM, it’s wherever which flutter points, minus /bin/flutter. Reload your shell (source ~/.zshrc) and confirm:
echo $FLUTTER_ROOT
Step 3 — Confirm the raw builds work
Before automating anything, make sure the two build commands fastlane will run actually succeed on their own:
fvm flutter build appbundle --release # Android -> build/app/outputs/bundle/release/app-release.aab
fvm flutter build ipa --release # iOS -> build/ios/ipa/<YourApp>.ipa
If flutter build ipa complains about signing, open ios/Runner.xcworkspace in Xcode once, select the Runner target → Signing & Capabilities, tick Automatically manage signing, pick your team, and let it create the profile. Then re-run. Don't move on until both commands produce an artifact.
Step 4 — fastlane init for iOS
# from app's root
cd ios
bundle exec fastlane init
Pick option 2 (Automate beta distribution to TestFlight). It parses your Xcode project, then asks you to log in with your Apple ID. This triggers a 2FA prompt on your trusted device — enter the 6-digit code. It’ll verify the app exists in both the Developer Portal and App Store Connect.
This creates ios/fastlane/Appfile and ios/fastlane/Fastfile.
Step 5 — fastlane init for Android
# from app's root
cd android
bundle exec fastlane init
It asks for your package name (must match applicationId in android/app/build.gradle), then asks for the path to a Play service account JSON. Press Enter to skip that for now — we'll wire it up properly in Step 6.
This creates android/fastlane/Appfile and android/fastlane/Fastfile.
Step 6 — Fill in the Appfiles
android/fastlane/Appfile
json_key_file("pc-key.json") # only used by validate/supply — the deploy lane uses the base64 env var
package_name("com.example.app") # must match applicationId in android/app/build.gradle
Now get the pc-key.json:
- Open the Google Cloud APIs & Services page.
- Enable the Google Play Android Developer API for that project.
Create the Service Account directly in Google Cloud
- Open the Google Cloud IAM Service Accounts page.
- Make sure you are logged into the Google account that manages this Play Store developer profile.
- Select your Google Cloud project from the top dropdown (or create a quick project named “Play Store Fastlane” if none exists).
- Click + CREATE SERVICE ACCOUNT at the top.
- Enter
fastlane-supplyas the Service Account name and click DONE. -
Copy the generated Service Account Email address (looks like
fastlane-supply@your-project-id.iam.gserviceaccount.com).
Generate and download the JSON key:
- Click on the newly created service account email in the list.
- Click the Keys tab at the top.
- Click ADD KEY > Create new key.
- Choose JSON as the key type and click CREATE.
- Save the downloaded
.jsonfile into your project'sandroid/directory aspc-key.json.
Grant access via Users and permissions:
- Return to your Google Play Console tab.
- Click Users and permissions from the left sidebar (the 3rd item from the top in your screenshot).
- Click Invite new users at the top right.
- Paste the Service Account Email you copied above into the email address field.
- Under Account permissions , grant permissions for Release to testing tracks.
- Click Invite user at the bottom right.
Verify the connection in Fastlane:
bundle exec fastlane run validate_play_store_json_key json_key:pc-key.json
Now base64-encode it into .env.deploy (this is what the lane actually uses, and the same value goes into GitHub Secrets later — one encoding, two homes):
base64 -i android/pc-key.json | tr -d '\n' | pbcopy
# paste into .env.deploy as: PLAY_STORE_JSON_KEY_BASE64=<pasted>
ios/fastlane/Appfile
app_identifier("com.example.app") # must match your bundle ID in Xcode
apple_id("you@example.com") # your Apple Developer account email
itc_team_id("1234567") # App Store Connect team ID
team_id("ABCDE12345") # Developer Portal team ID
If you don’t know the team IDs, run bundle exec fastlane spaceship or check the URLs in the App Store Connect / Developer portal — they're in there. These aren't secret, but people often redact them out of habit; your call.
Step 7 — iOS auth: use an App Store Connect API key, not your password
You’ll see guides that tell you to set FASTLANE_USER and FASTLANE_PASSWORD. Don't. With an Apple ID that has 2FA (all of them now do), that approach means:
- constant 2FA prompts you can’t answer from a CI runner
- sessions that expire every few days
- the occasional locked account when Apple decides the login looks robotic
An App Store Connect API key sidesteps all of it — no 2FA, no expiry, and it’s scoped to just what it needs.
- App Store Connect → Users and Access → Integrations → App Store Connect API.
- Create a key with the App Manager role.
- Download the
AuthKey_XXXXXXXXXX.p8— you can only download it once. - Note the Key ID and the Issuer ID shown on that page.
Encode the key and the two IDs into .env.deploy:
base64 -i AuthKey_XXXXXXXXXX.p8 | tr -d '\n' | pbcopy
# .env.deploy
APP_STORE_CONNECT_API_KEY_ID=XXXXXXXXXX
APP_STORE_CONNECT_API_ISSUER_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
APP_STORE_CONNECT_API_KEY_BASE64=<pasted>
Delete the downloaded .p8 once it's encoded — .env.deploy is now the only copy you keep, and it's gitignored. Apple only lets you download the .p8 once. If you lose it, revoke that key in App Store Connect and issue a new one.
Step 7B — the .env.deploy file
All deploy secrets live in foody-app/.env.deploy, which both Fastfiles load via dotenv at the top. It is gitignored; a committed .env.deploy.example documents every key. Your .gitignore should already have:
.env
.env.*
!.env.example
!.env.deploy.example
android/pc-key.json
.env.deploy.example also lists BUILD_CERTIFICATE_BASE64, P12_PASSWORD, BUILD_PROVISION_PROFILE_BASE64, KEYCHAIN_PASSWORD, APPLE_TEAM_ID — those are only for CI signing (Part 2). Locally, Xcode's managed signing handles it and you can leave them blank.
Step 8 — Write the deploy lanes
Replace the generated Fastfile in each folder with these.
fastlane init generates a placeholder Fastfile. Replace each with the version below. They share the same shape: load .env.deploy, check the store first , build with fvm flutter, upload.
android/fastlane/Fastfile
default_platform(:android)
require "dotenv"
deploy_env = File.expand_path("../../.env.deploy", __dir__ || File.dirname( __FILE__ ))
Dotenv.load(deploy_env) if File.exist?(deploy_env)
platform :android do
desc "Automated build & deploy to Play Store Internal Track"
lane :deploy do
json_key_data = play_json_key_data
guard_version_code_is_new(json_key_data)
UI.message("🚀 Building Android App Bundle")
Bundler.with_unbundled_env do
sh("cd ../.. && fvm flutter build appbundle --release")
end
upload_build(json_key_data: json_key_data)
end
desc "Upload the already-built AAB to Play Console (skips Flutter build)"
lane :upload do
json_key_data = play_json_key_data
guard_version_code_is_new(json_key_data)
upload_build(json_key_data: json_key_data)
end
private_lane :upload_build do |options|
app_root = File.expand_path("../..", __dir__ || File.dirname( __FILE__ ))
aab_path = File.join(app_root, "build/app/outputs/bundle/release/app-release.aab")
UI.user_error!("No .aab found at #{aab_path} — run `fastlane android deploy` or `flutter build appbundle` first") unless File.exist?(aab_path)
upload_to_play_store(
json_key_data: options[:json_key_data],
track: "internal",
aab: aab_path,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
end
def play_json_key_data
b64 = ENV["PLAY_STORE_JSON_KEY_BASE64"].to_s
UI.user_error!("PLAY_STORE_JSON_KEY_BASE64 missing from foody-app/.env.deploy") if b64.empty?
require "base64"
Base64.decode64(b64)
end
def pubspec_version
pubspec = File.expand_path("../../pubspec.yaml", __dir__ || File.dirname( __FILE__ ))
m = File.read(pubspec).match(/^version:\s*(\d+\.\d+\.\d+)\+(\d+)/)
UI.user_error!("Could not read version from pubspec.yaml") unless m
[m[1], m[2].to_i]
end
def guard_version_code_is_new(json_key_data)
_, local = pubspec_version
package = CredentialsManager::AppfileConfig.try_fetch_value(:package_name)
seen = %w[production internal].flat_map do |track|
begin
google_play_track_version_codes(package_name: package, track: track, json_key_data: json_key_data)
rescue => e
UI.important("Could not read Play '#{track}' track (#{e.message})")
[]
end
end.map(&:to_i)
max_live = (seen + [0]).max
if local <= max_live
UI.user_error!("pubspec versionCode #{local} is not ahead of Play's #{max_live} — run `cider bump build` first.")
end
UI.success("pubspec versionCode #{local} is ahead of Play (#{max_live}) ✓")
end
ios/fastlane/Fastfile
default_platform(:ios)
require "dotenv"
deploy_env = File.expand_path("../../.env.deploy", __dir__ || File.dirname( __FILE__ ))
Dotenv.load(deploy_env) if File.exist?(deploy_env)
platform :ios do
desc "Build IPA and upload to TestFlight"
lane :deploy do
api_key = app_store_api_key
guard_build_number_is_new(api_key)
Bundler.with_unbundled_env do
sh("cd .. && fvm flutter build ipa --release")
end
upload_build(api_key: api_key)
end
desc "Upload the already-built IPA to TestFlight (skips the Flutter build)"
lane :upload do
api_key = app_store_api_key
guard_build_number_is_new(api_key)
upload_build(api_key: api_key)
end
private_lane :upload_build do |options|
app_root = File.expand_path("../..", __dir__ || File.dirname( __FILE__ ))
ipa_path = Dir[File.join(app_root, "build/ios/ipa/*.ipa")].max_by { |f| File.mtime(f) }
UI.user_error!("No .ipa found under #{app_root}/build/ios/ipa — run `fastlane ios deploy` or `flutter build ipa` first") unless ipa_path
upload_to_testflight(
api_key: options[:api_key],
ipa: ipa_path,
skip_waiting_for_build_processing: true,
skip_submission: true
)
end
end
def app_store_api_key
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"].to_s
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"].to_s
key_b64 = ENV["APP_STORE_CONNECT_API_KEY_BASE64"].to_s
UI.user_error!("APP_STORE_CONNECT_API_KEY_ID missing from foody-app/.env.deploy") if key_id.empty?
UI.user_error!("APP_STORE_CONNECT_API_ISSUER_ID missing from foody-app/.env.deploy") if issuer_id.empty?
UI.user_error!("APP_STORE_CONNECT_API_KEY_BASE64 missing from foody-app/.env.deploy") if key_b64.empty?
app_store_connect_api_key(
key_id: key_id,
issuer_id: issuer_id,
key_content: key_b64,
is_key_content_base64: true,
in_house: false
)
end
def pubspec_version
pubspec = File.expand_path("../../pubspec.yaml", __dir__ || File.dirname( __FILE__ ))
m = File.read(pubspec).match(/^version:\s*(\d+\.\d+\.\d+)\+(\d+)/)
UI.user_error!("Could not read version from pubspec.yaml") unless m
[m[1], m[2].to_i]
end
# Fail before the ~10-minute build if pubspec's +N was not bumped past what
# TestFlight already has for this marketing version.
def guard_build_number_is_new(api_key)
name, local = pubspec_version
begin
live = latest_testflight_build_number(
api_key: api_key,
app_identifier: CredentialsManager::AppfileConfig.try_fetch_value(:app_identifier),
version: name,
initial_build_number: 0
).to_i
rescue => e
UI.error("Could not check TestFlight build numbers (#{e.message}) — skipping the guard; the store will still reject a reused build.")
return
end
if local <= live
UI.user_error!("pubspec build #{name}+#{local} is not ahead of TestFlight's #{live} — run `cider bump build` first.")
end
UI.success("pubspec #{name}+#{local} is ahead of TestFlight (#{live}) ✓")
end
-
.env.deployat the top . Both files load it withdotenvbefore anything runs. On CI the same variables come from the environment, so the Fastfile doesn't change. - The
guard_*check runs first, and hits the store API, not a build.flutter build ipatakes ~10 minutes. If you forgot to bump the version, the guard catches it in a couple of seconds — iOS asks TestFlight for the latest build number of the current marketing version; Android asks Play for the highestversionCodeacross theproductionandinternaltracks. If yourpubspec.yamlisn't ahead, it stops with "runcider bump buildfirst." -
app_store_api_key/play_json_key_datadecode the base64 secret in memory. Nothing is ever written to disk. - Two lanes.
deploy= build + upload.upload= upload the artifact that's already inbuild/, skipping the build — for when the upload failed and you don't want to wait 10 minutes again. -
Bundler.with_unbundled_env { sh("… fvm flutter build …") }runs the Flutter build outside fastlane's Bundler context so Flutter/CocoaPods resolve against FVM cleanly. - iOS picks the newest
build/ios/ipa/*.ipaby modified time rather than guessing the filename from the app's display name. It uploads withskip_submission(goes to TestFlight, not the App Store review queue) andskip_waiting_for_build_processing(don't block the terminal while Apple processes it). - Android uploads to the
internaltrack and skips metadata/images/screenshots so the lane only touches the binary.
Step 9 — Bump the version and Changelog
Play and TestFlight both reject a build number that already exists. pubspec.yaml’s version: line — X.Y.Z+N — is the single source of truth for both the iOS CFBundleVersion and the Android versionCode, and +N is the value both stores check for uniqueness:
version: 1.0.0+1 # <marketing version> + <build number>
This project manages the version with cider, added as a dev-dependency:
dev_dependencies:
cider: ^0.2.10
It’s a dependency, not a global tool, so run it through the SDK: fvm dart run cider …
While you work: log changes
cider collects entries under an ## Unreleased heading in CHANGELOG.md:
fvm dart run cider log added "Dark mode toggle in settings."
fvm dart run cider log fixed "Crash when opening an expired invite link."
(log subcommands: added, changed, deprecated, fixed, removed, security.)
At release time: bump once, then release
Step 1 — bump the version. Run one of these, never both:
fvm dart run cider bump build # same version, next build: 1.0.0+1 -> 1.0.0+2
fvm dart run cider bump patch --bump-build # new marketing version: 1.0.0+1 -> 1.0.1+2
minor and major work the same way:
cider bump minor --bump-build
cider bump major --bump-build
Always pass --bump-build on a marketing bump. A plain cider bump patch drops the +N build number, and Play Console rejects any upload whose versionCode isn’t higher than the last one shipped. To set a version explicitly:
fvm dart run cider version 1.4.0+37.
Step 2 — stamp the changelog:
fvm dart run cider release
cider release turns ##Unreleased into a dated heading. After cider bump build (->1.0.0+2) and the two log entries above, CHANGELOG.md becomes:
## 1.0.0+2 - 2026-09-10
### Added
- Dark mode toggle in settings.
### Fixed
- Crash when opening an expired invite link.
Commit just those two files and tag — the tag is what Part 2’s CI triggers on:
git add pubspec.yaml CHANGELOG.md
git commit -m "chore(release): v1.0.0+2"
git tag -a v1.0.0+2 -m "v1.0.0+2"
git push --follow-tags
Do this once per release, from a clean tree, then deploy both platforms from that commit.
Step 10 — Ship it
# from app's root
cd android && bundle exec fastlane deploy && cd ..
cd ios && bundle exec fastlane deploy && cd ..
Both lanes read foody-app/.env.deploy. If a build fails at upload, rerun just bundle exec fastlane <platform> upload — it skips the 10-minute rebuild.
Troubleshooting the first run
-
flutterorfvm: command not foundinside a lane: Ensure FVM is added to your shell's PATH where you launch Fastlane. -
invalid curve name/.p8key errors: Your Base64-encoded key string contains hidden line breaks. Re-encode it cleanly:
base64 -i key.p8 | tr -d '\n'
-
versionCode N is not ahead of Play's M: You skipped the version bump step. Runfvm dart run cider bump patch --bump-build, commit, and retry. -
*_missingfrom.env.deployerrors: Fastlane cannot locate your environment file. Ensure.env.deploysits directly at your app's root directory (foody-app/.env.deploy), not insideios/orandroid/.
What’s next — Part 2
Part 2 moves this exact workflow to GitHub Actions. Because every secret is already a base64 string in .env.deploy, CI is mostly “same variables, sourced from GitHub Secrets instead of the file.” The additions are: the iOS signing vars from .env.deploy.example (BUILD_CERTIFICATE_BASE64, P12_PASSWORD, BUILD_PROVISION_PROFILE_BASE64, KEYCHAIN_PASSWORD), a step that builds a throwaway keychain on the runner, and a workflow triggered by the v* tag you push in Step 9. The Fastfiles don't change at all.
Ten minutes of tedious web console clicking are now permanently replaced by a single command. Your local release pipeline is fully automated, version-guarded, and bulletproof.
Up next in Part 2 , we’ll take these exact lanes, move them to the cloud, and wire them up to GitHub Actions so a simple tag push handles the rest. Let’s ship code faster! 🚀





Top comments (0)