DEV Community

Munir Ahmad
Munir Ahmad

Posted on Originally published at Medium on

Fastlane for Flutter: Complete Local Setup for iOS and Android Deployments

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

android/Gemfile

source "https://rubygems.org"
gem "fastlane"
Enter fullscreen mode Exit fullscreen mode

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 ..
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now get the pc-key.json:

  1. Open the Google Cloud APIs & Services page.
  2. Enable the Google Play Android Developer API for that project.

Create the Service Account directly in Google Cloud

  1. Open the Google Cloud IAM Service Accounts page.
  2. Make sure you are logged into the Google account that manages this Play Store developer profile.
  3. Select your Google Cloud project from the top dropdown (or create a quick project named “Play Store Fastlane” if none exists).
  4. Click + CREATE SERVICE ACCOUNT at the top.
  5. Enter fastlane-supply as the Service Account name and click DONE.
  6. Copy the generated Service Account Email address (looks like fastlane-supply@your-project-id.iam.gserviceaccount.com).

Generate and download the JSON key:

  1. Click on the newly created service account email in the list.
  2. Click the Keys tab at the top.
  3. Click ADD KEY > Create new key.
  4. Choose JSON as the key type and click CREATE.
  5. Save the downloaded .json file into your project's android/ directory as pc-key.json.

Grant access via Users and permissions:

  1. Return to your Google Play Console tab.
  2. Click Users and permissions from the left sidebar (the 3rd item from the top in your screenshot).
  3. Click Invite new users at the top right.
  4. Paste the Service Account Email you copied above into the email address field.
  5. Under Account permissions , grant permissions for Release to testing tracks.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. App Store ConnectUsers and AccessIntegrationsApp Store Connect API.
  2. Create a key with the App Manager role.
  3. Download the AuthKey_XXXXXXXXXX.p8 — you can only download it once.
  4. 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • .env.deploy at the top . Both files load it with dotenv before 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 ipa takes ~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 highest versionCode across the production and internal tracks. If your pubspec.yaml isn't ahead, it stops with "run cider bump build first."
  • app_store_api_key / play_json_key_data decode the base64 secret in memory. Nothing is ever written to disk.
  • Two lanes. deploy = build + upload. upload = upload the artifact that's already in build/, 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/*.ipa by modified time rather than guessing the filename from the app's display name. It uploads with skip_submission (goes to TestFlight, not the App Store review queue) and skip_waiting_for_build_processing (don't block the terminal while Apple processes it).
  • Android uploads to the internal track 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>
Enter fullscreen mode Exit fullscreen mode

This project manages the version with cider, added as a dev-dependency:

dev_dependencies:
  cider: ^0.2.10
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

minor and major work the same way:

cider bump minor --bump-build
cider bump major --bump-build
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Step 2 — stamp the changelog:

fvm dart run cider release
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ..
Enter fullscreen mode Exit fullscreen mode

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

  • flutter or fvm: command not found inside a lane: Ensure FVM is added to your shell's PATH where you launch Fastlane.
  • invalid curve name / .p8 key errors: Your Base64-encoded key string contains hidden line breaks. Re-encode it cleanly:
base64 -i key.p8 | tr -d '\n'
Enter fullscreen mode Exit fullscreen mode
  • versionCode N is not ahead of Play's M: You skipped the version bump step. Run fvm dart run cider bump patch --bump-build, commit, and retry.
  • *_missing from .env.deploy errors: Fastlane cannot locate your environment file. Ensure .env.deploy sits directly at your app's root directory (foody-app/.env.deploy), not inside ios/ or android/.

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)