Purpose: Reusable reference for setting up automated builds, code signing, and deployment for Flutter apps across iOS and Android.
Last Updated: July 2025
Audience: Solo developers & small teams
- Overview & Tool Comparison
- Option 1: Codemagic (Recommended for Small Teams)
- Option 2: Fastlane + GitHub Actions (Full Control)
- Option 3: Shorebird (OTA Updates)
- Troubleshooting
- Reference Repos & Resources
- Quick Start Checklist
| Approach | Best For | Cost | Complexity | iOS Signing |
|---|---|---|---|---|
| Codemagic | Flutter-focused small teams | Free tier + paid | 🟢 Low | Automatic |
| Fastlane + GitHub Actions | Full control, complex flows | Free (GH Actions minutes) | 🔴 High | Manual (Match) |
| Fastlane + Codemagic | Best of both worlds | Codemagic pricing | 🟡 Medium | Automatic |
| GitHub Actions only | Cost-conscious, simple apps | Free (GH Actions minutes) | 🟡 Medium | Manual |
| Xcode Cloud | Apple-only teams | Apple pricing | 🟡 Medium | Automatic |
| Bitrise / Codemagic | Teams wanting managed CI | Paid | 🟢 Low | Managed |
Recommendation:
- Want it working today → Codemagic
- Want full control + free → Fastlane + GitHub Actions
- Need OTA updates → Add Shorebird on top of either
Why Codemagic over Fastlane for solo/small teams:
- Built by the Flutter community, Flutter-first
- Handles iOS code signing automatically (integrates with Apple Developer Portal)
- No Ruby/Fastlane dependency headaches
- 150,000+ developers trust it
- Free tier: 500 build minutes/month on macOS
- Go to codemagic.io
- Connect your GitHub/GitLab/Bitbucket repo
workflows:
ios-release:
name: iOS Release
instance_type: mac_mini_m2
max_build_duration: 60
integrations:
app_store_connect: <YOUR_ASC_KEY_NAME>
environment:
ios_signing:
distribution_type: app_store
bundle_identifier: com.yourcompany.yourapp
flutter: stable
xcode: latest
cocoapods: default
scripts:
- name: Get Flutter packages
script: flutter packages pub get
- name: Build IPA
script: |
flutter build ipa --release \
--build-name=1.0.$PROJECT_BUILD_NUMBER \
--build-number=$PROJECT_BUILD_NUMBER \
--export-options-plist=/Users/builder/export_options.plist
artifacts:
- build/ios/ipa/*.ipa
publishing:
app_store_connect:
auth: integration
submit_to_testflight: true
# submit_to_app_store: true # uncomment for production
android-release:
name: Android Release
instance_type: mac_mini_m2
max_build_duration: 60
environment:
android_signing:
- <YOUR_KEYSTORE_REFERENCE>
groups:
- google_play_credentials
flutter: stable
scripts:
- name: Get Flutter packages
script: flutter packages pub get
- name: Build AAB
script: |
flutter build appbundle --release \
--build-name=1.0.$PROJECT_BUILD_NUMBER \
--build-number=$PROJECT_BUILD_NUMBER
artifacts:
- build/app/outputs/bundle/**/*.aab
publishing:
google_play:
credentials: $GCLOUD_SERVICE_ACCOUNT_CREDENTIALS
track: internal- iOS: Add App Store Connect API key → Codemagic auto-fetches/creates certificates & profiles
- Android: Upload keystore + Google Play service account JSON
- Trigger: Set to run on push to
mainor tag creation
git add codemagic.yaml
git commit -m "Add Codemagic CI/CD"
git pushThat's it. No Match, no keychain, no provisioning profile hell.
On your Mac (development machine):
# Install Fastlane
brew install fastlane
# OR
gem install fastlane
# Verify
fastlane --versionRequired accounts:
- Apple Developer Account ($99/year)
- App Store Connect access
- Google Play Developer Account ($25 one-time)
- GitHub account
your_flutter_app/
├── android/
│ └── fastlane/
│ ├── Appfile # Android app metadata
│ ├── Fastfile # Android lanes
│ └── metadata/
│ └── versionCode # Track version code
├── ios/
│ └── fastlane/
│ ├── Appfile # iOS app metadata
│ ├── Fastfile # iOS lanes
│ └── Matchfile # Match configuration
├── .github/
│ └── workflows/
│ ├── build-ios.yml # iOS CI/CD workflow
│ ├── build-android.yml # Android CI/CD workflow
│ └── release.yml # Combined release workflow
├── .gitignore
├── Gemfile # Ruby dependencies
└── pubspec.yaml
source "https://rubygems.org"
gem "fastlane"
gem "cocoapods" # if using CocoaPods
plugins_path = File.join(File.dirname(__FILE__), 'ios', 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
plugins_path = File.join(File.dirname(__FILE__), 'android', 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)Run bundle install to generate Gemfile.lock. Commit both files.
Why: API keys avoid 2FA/session issues in CI. This is the recommended auth method.
Steps:
- Go to App Store Connect → Users and Access → Keys
- Click + to generate a new key
- Name:
Fastlane CI(or whatever you prefer) - Access: App Manager (minimum for uploads)
- Download the
.p8file — you can only download it once - Note the Key ID and Issuer ID
Store these values:
APPLE_KEY_ID— the Key ID (e.g.,ABC1234DEF)APPLE_ISSUER_ID— the Issuer ID (e.g.,12345678-abcd-efgh-ijkl-123456789012)APPLE_KEY_CONTENT— Base64-encoded.p8file content
# Encode your .p8 file to base64
base64 -i AuthKey_ABC1234DEF.p8 | pbcopy
# Paste this as APPLE_KEY_CONTENTMatch stores your certificates and provisioning profiles in a private Git repo (or Google Cloud Storage / S3). This is the single best way to manage code signing across machines and CI.
Initial setup (run once, from ios/ directory):
cd ios
fastlane match initChoose git storage. Create a private repo first (e.g., github.com/yourname/certificates).
This creates ios/fastlane/Matchfile:
# ios/fastlane/Matchfile
git_url("https://github.com/yourname/certificates.git")
storage_mode("git")
type("appstore") # default type
app_identifier("com.yourcompany.yourapp")
# Optional: For multiple apps
# app_identifier(["com.yourcompany.app1", "com.yourcompany.app2"])
# Optional: specific Apple ID
# username("your@email.com")Generate certificates and profiles:
# Development (for running on devices)
fastlane match development
# App Store (for TestFlight + App Store)
fastlane match appstore
# Ad Hoc (for direct distribution)
fastlane match adhocMatch will:
- Create certificates in Apple Developer Portal (if they don't exist)
- Create provisioning profiles
- Encrypt and store them in your Git repo
- Install them on your machine
Match passphrase: Match encrypts everything with a passphrase. Store this as MATCH_PASSWORD in your CI secrets.
- Never run
matchwithoutreadonly: trueon CI - Only generate new certs from your local machine
- If certs expire, run
fastlane match nukelocally, then regenerate
# ios/fastlane/Fastfile
default_platform(:ios)
APPLE_KEY_ID = ENV["APPLE_KEY_ID"]
APPLE_ISSUER_ID = ENV["APPLE_ISSUER_ID"]
APPLE_KEY_CONTENT = ENV["APPLE_KEY_CONTENT"]
DEVELOPER_APP_ID = ENV["DEVELOPER_APP_ID"]
platform :ios do
# ─────────────────────────────────────────────
# Helper: Get App Store Connect API key
# ─────────────────────────────────────────────
desc "Load App Store Connect API key"
lane :load_api_key do
app_store_connect_api_key(
key_id: APPLE_KEY_ID,
issuer_id: APPLE_ISSUER_ID,
key_content: APPLE_KEY_CONTENT,
is_key_content_base64: true,
duration: 1200,
in_house: false
)
end
# ─────────────────────────────────────────────
# Certificates & Profiles
# ─────────────────────────────────────────────
desc "Sync certificates and profiles"
lane :sync_certs do |options|
type = options[:type] || "appstore"
api_key = load_api_key
match(
type: type,
api_key: api_key,
readonly: is_ci, # CRITICAL: readonly on CI
force_for_new_devices: !is_ci
)
end
# ─────────────────────────────────────────────
# Build
# ─────────────────────────────────────────────
desc "Build iOS app"
lane :build do |options|
sync_certs(type: "appstore")
# Flutter build (run from project root before this lane, or use shell)
# sh("cd ../.. && flutter build ipa --release --no-codesign")
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
output_directory: "../build/ios",
output_name: "Runner.ipa",
export_method: "app-store",
export_options: {
provisioningProfiles: {
ENV["APP_IDENTIFIER"] => "match AppStore #{ENV["APP_IDENTIFIER"]}"
}
}
)
end
# ─────────────────────────────────────────────
# TestFlight
# ─────────────────────────────────────────────
desc "Upload to TestFlight"
lane :beta do
setup_ci if ENV['CI'] # Creates temporary keychain on CI
api_key = load_api_key
sync_certs(type: "appstore")
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store"
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true,
distribute_external: false
)
end
# ─────────────────────────────────────────────
# App Store Release
# ─────────────────────────────────────────────
desc "Deploy to App Store"
lane :release do
setup_ci if ENV['CI']
api_key = load_api_key
sync_certs(type: "appstore")
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store"
)
upload_to_app_store(
api_key: api_key,
submit_for_review: false, # Set true to auto-submit
automatic_release: false, # Set true for auto-release after approval
force: true, # Skip HTML preview verification
skip_metadata: true, # Skip metadata upload (do it separately)
skip_screenshots: true # Skip screenshot upload
)
end
# ─────────────────────────────────────────────
# Utilities
# ─────────────────────────────────────────────
desc "Register new device"
lane :register_device_lane do
device_name = prompt(text: "Device name: ")
device_udid = prompt(text: "Device UDID: ")
api_key = load_api_key
register_devices(
devices: { device_name => device_udid },
api_key: api_key
)
# Regenerate profiles to include new device
match(type: "development", api_key: api_key, force_for_new_devices: true)
match(type: "adhoc", api_key: api_key, force_for_new_devices: true)
end
desc "Nuke and regenerate all certificates (USE WITH CAUTION)"
lane :nuke_certs do
api_key = load_api_key
match_nuke(type: "development", api_key: api_key)
match_nuke(type: "appstore", api_key: api_key)
match(type: "development", api_key: api_key)
match(type: "appstore", api_key: api_key)
end
end# ios/fastlane/Appfile
app_identifier(ENV["APP_IDENTIFIER"] || "com.yourcompany.yourapp")
apple_id(ENV["APPLE_ID"] || "your@email.com")
team_id(ENV["TEAM_ID"] || "YOUR_TEAM_ID")
itc_team_id(ENV["ITC_TEAM_ID"] || "YOUR_ITC_TEAM_ID")Steps:
- Go to Google Play Console → Setup → API access
- Link to a Google Cloud Project (or create one)
- Click Create new service account
- In Google Cloud Console:
- Create service account with name
fastlane-deploy - Skip granting roles (Play Console handles permissions)
- Create JSON key → download it
- Create service account with name
- Back in Play Console: Grant the service account Release Manager access
- Base64-encode the JSON:
base64 -i play-store-service-account.json | pbcopy - Store as
PLAY_STORE_CONFIG_JSON(base64) in GitHub Secrets
Generate a keystore (one-time):
keytool -genkey -v \
-keystore ~/upload-keystore.jks \
-keyalg RSA -keysize 2048 \
-validity 10000 \
-alias uploadConfigure signing in android/app/build.gradle:
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
// ...
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias'] ?: System.getenv('KEY_ALIAS')
keyPassword keystoreProperties['keyPassword'] ?: System.getenv('KEY_PASSWORD')
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : (System.getenv('KEYSTORE_PATH') ? file(System.getenv('KEYSTORE_PATH')) : null)
storePassword keystoreProperties['storePassword'] ?: System.getenv('STORE_PASSWORD')
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}Local android/key.properties (DO NOT COMMIT):
storePassword=your_store_password
keyPassword=your_key_password
keyAlias=upload
storeFile=/Users/yourname/upload-keystore.jksAdd to .gitignore:
android/key.properties
*.jks
*.keystore
For CI: Base64-encode the keystore and store as a secret:
base64 -i ~/upload-keystore.jks | pbcopy
# Store as ANDROID_KEYSTORE_BASE64# android/fastlane/Fastfile
default_platform(:android)
platform :android do
# ─────────────────────────────────────────────
# Version Management
# ─────────────────────────────────────────────
desc "Bump version code"
lane :bump_version_code do
version_code_file = "fastlane/metadata/versionCode"
# Create file if it doesn't exist
unless File.exist?(version_code_file)
FileUtils.mkdir_p(File.dirname(version_code_file))
File.write(version_code_file, "0")
end
version_code = File.read(version_code_file).to_i + 1
File.write(version_code_file, version_code.to_s)
UI.message("Version code bumped to #{version_code}")
version_code
end
# ─────────────────────────────────────────────
# Build
# ─────────────────────────────────────────────
desc "Build Android App Bundle"
lane :build do |options|
# Flutter build (run from project root before, or use shell)
# sh("cd ../.. && flutter build appbundle --release")
UI.message("AAB built at: ../build/app/outputs/bundle/release/app-release.aab")
end
# ─────────────────────────────────────────────
# Internal Testing
# ─────────────────────────────────────────────
desc "Deploy to Google Play Internal Testing"
lane :internal do
bump_version_code
upload_to_play_store(
track: "internal",
aab: "../build/app/outputs/bundle/release/app-release.aab",
json_key_data: ENV["PLAY_STORE_CONFIG_JSON"],
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end
# ─────────────────────────────────────────────
# Beta (Closed Testing)
# ─────────────────────────────────────────────
desc "Deploy to Google Play Closed Testing (Beta)"
lane :beta do
bump_version_code
upload_to_play_store(
track: "beta",
aab: "../build/app/outputs/bundle/release/app-release.aab",
json_key_data: ENV["PLAY_STORE_CONFIG_JSON"],
skip_upload_metadata: true,
skip_upload_changelogs: false # Include changelogs for beta testers
)
end
# ─────────────────────────────────────────────
# Production
# ─────────────────────────────────────────────
desc "Deploy to Google Play Production"
lane :release do
bump_version_code
upload_to_play_store(
track: "production",
aab: "../build/app/outputs/bundle/release/app-release.aab",
json_key_data: ENV["PLAY_STORE_CONFIG_JSON"],
rollout: "0.1", # 10% staged rollout — change to "1.0" for full
skip_upload_metadata: true,
skip_upload_changelogs: false
)
end
# ─────────────────────────────────────────────
# Promote
# ─────────────────────────────────────────────
desc "Promote internal build to production"
lane :promote_to_production do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
json_key_data: ENV["PLAY_STORE_CONFIG_JSON"],
rollout: "1.0",
skip_upload_changelogs: true
)
end
# ─────────────────────────────────────────────
# Firebase App Distribution (Alternative Beta)
# ─────────────────────────────────────────────
desc "Deploy to Firebase App Distribution"
lane :firebase_beta do
# Requires: fastlane add_plugin firebase_app_distribution
firebase_app_distribution(
app: ENV["FIREBASE_APP_ID_ANDROID"],
groups: "internal-testers",
android_artifact_type: "AAB",
android_artifact_path: "../build/app/outputs/bundle/release/app-release.aab",
release_notes: "Bug fixes and improvements"
)
end
end# android/fastlane/Appfile
json_key_file(ENV["GOOGLE_PLAY_JSON_KEY_PATH"] || "")
package_name(ENV["APP_IDENTIFIER"] || "com.yourcompany.yourapp")Add these in your repo → Settings → Secrets and variables → Actions:
iOS Secrets:
| Secret | Description |
|---|---|
APPLE_KEY_ID |
App Store Connect API Key ID |
APPLE_ISSUER_ID |
App Store Connect Issuer ID |
APPLE_KEY_CONTENT |
Base64-encoded .p8 file content |
MATCH_PASSWORD |
Passphrase for Match encryption |
MATCH_GIT_BASIC_AUTHORIZATION |
Base64 of username:personal_access_token for cert repo |
APP_IDENTIFIER |
e.g., com.yourcompany.yourapp |
TEAM_ID |
Apple Developer Team ID |
DEVELOPER_APP_ID |
App Store Connect App ID (numeric) |
Android Secrets:
| Secret | Description |
|---|---|
PLAY_STORE_CONFIG_JSON |
Base64-encoded Google Play service account JSON |
ANDROID_KEYSTORE_BASE64 |
Base64-encoded .jks keystore file |
KEY_ALIAS |
Keystore key alias |
KEY_PASSWORD |
Key password |
STORE_PASSWORD |
Store password |
Generate MATCH_GIT_BASIC_AUTHORIZATION:
echo -n "your_github_username:ghp_your_personal_access_token" | base64name: iOS Build & Deploy
on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
inputs:
lane:
description: 'Fastlane lane to run'
required: true
default: 'beta'
type: choice
options:
- beta
- release
jobs:
build-ios:
runs-on: macos-14 # M1 runner (faster, ARM)
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.x' # Pin your version
channel: 'stable'
cache: true
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true # Caches gems automatically
- name: Flutter dependencies
run: flutter pub get
- name: Flutter build (no codesign)
run: flutter build ios --release --no-codesign
- name: Install Fastlane dependencies
run: |
cd ios
bundle install
- name: Run Fastlane
env:
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_IDENTIFIER: ${{ secrets.APP_IDENTIFIER }}
TEAM_ID: ${{ secrets.TEAM_ID }}
DEVELOPER_APP_ID: ${{ secrets.DEVELOPER_APP_ID }}
run: |
cd ios
bundle exec fastlane ${{ github.event.inputs.lane || 'beta' }}
- name: Upload IPA artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: ios-build
path: build/ios/**/*.ipa
retention-days: 14name: Android Build & Deploy
on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
inputs:
lane:
description: 'Fastlane lane to run'
required: true
default: 'internal'
type: choice
options:
- internal
- beta
- release
jobs:
build-android:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.x'
channel: 'stable'
cache: true
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Flutter dependencies
run: flutter pub get
- name: Decode keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
- name: Create key.properties
run: |
cat > android/key.properties <<EOF
storePassword=${{ secrets.STORE_PASSWORD }}
keyPassword=${{ secrets.KEY_PASSWORD }}
keyAlias=${{ secrets.KEY_ALIAS }}
storeFile=upload-keystore.jks
EOF
- name: Flutter build AAB
run: flutter build appbundle --release
- name: Decode Play Store credentials
run: |
echo "${{ secrets.PLAY_STORE_CONFIG_JSON }}" | base64 --decode > android/fastlane/play-store-key.json
- name: Run Fastlane
env:
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
run: |
cd android
bundle install
bundle exec fastlane ${{ github.event.inputs.lane || 'internal' }}
- name: Upload AAB artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: android-build
path: build/app/outputs/bundle/release/*.aab
retention-days: 14
- name: Cleanup secrets
if: always()
run: |
rm -f android/app/upload-keystore.jks
rm -f android/key.properties
rm -f android/fastlane/play-store-key.jsonname: Release (iOS + Android)
on:
push:
tags:
- 'v*' # Trigger on version tags: v1.0.0, v1.2.3, etc.
jobs:
ios:
uses: ./.github/workflows/build-ios.yml
secrets: inherit
with:
lane: release
android:
uses: ./.github/workflows/build-android.yml
secrets: inherit
with:
lane: release
notify:
needs: [ios, android]
runs-on: ubuntu-latest
if: always()
steps:
- name: Summary
run: |
echo "## Release Summary" >> $GITHUB_STEP_SUMMARY
echo "- iOS: ${{ needs.ios.result }}" >> $GITHUB_STEP_SUMMARY
echo "- Android: ${{ needs.android.result }}" >> $GITHUB_STEP_SUMMARYIf you want minimal setup, use the MTtankkeo/flutter-fastlane-action GitHub Action:
name: Deploy with flutter-fastlane-action
on:
push:
branches: [main]
jobs:
deploy:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: MTtankkeo/flutter-fastlane-action@v1
with:
platform: 'ios' # or 'android'
lane: 'beta'
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
apple-issuer-id: ${{ secrets.APPLE_ISSUER_ID }}
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
match-password: ${{ secrets.MATCH_PASSWORD }}
match-git-url: 'https://github.com/yourname/certificates.git'
match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}This action auto-configures Fastlane if your project doesn't have it. Only 6 secrets needed for iOS.
Add this to your root project or as a shared script:
# In either Fastfile — or a shared Fastfile
desc "Bump version from pubspec.yaml"
lane :bump_version do |options|
type = options[:type] || "patch" # major, minor, patch
pubspec = File.read("../../pubspec.yaml")
current_version = pubspec.match(/version:\s*(\d+\.\d+\.\d+)\+(\d+)/)
major, minor, patch = current_version[1].split('.').map(&:to_i)
build = current_version[2].to_i + 1
case type
when "major"
major += 1; minor = 0; patch = 0
when "minor"
minor += 1; patch = 0
when "patch"
patch += 1
end
new_version = "#{major}.#{minor}.#{patch}+#{build}"
new_pubspec = pubspec.gsub(/version:\s*\d+\.\d+\.\d+\+\d+/, "version: #{new_version}")
File.write("../../pubspec.yaml", new_pubspec)
UI.success("Version bumped to #{new_version}")
end# Bump version, commit, tag, push
flutter pub version patch # or manually edit pubspec.yaml
git add pubspec.yaml
git commit -m "chore: bump version to 1.0.1"
git tag v1.0.1
git push && git push --tags # Triggers release workflowFor apps with multiple environments (dev, staging, production):
# Define flavors in android/app/build.gradle and ios scheme
flutter build appbundle --flavor production --target lib/main_production.dart
flutter build ipa --flavor production --target lib/main_production.dart# ios/fastlane/Fastfile
platform :ios do
lane :deploy do |options|
env = options[:env] || "production"
scheme = case env
when "dev" then "Runner-Dev"
when "staging" then "Runner-Staging"
else "Runner"
end
identifier = case env
when "dev" then "com.yourcompany.yourapp.dev"
when "staging" then "com.yourcompany.yourapp.staging"
else "com.yourcompany.yourapp"
end
sync_certs(type: "appstore", app_identifier: identifier)
build_app(
workspace: "Runner.xcworkspace",
scheme: scheme,
export_method: "app-store"
)
upload_to_testflight(api_key: load_api_key)
end
endUsage:
fastlane deploy env:staging
fastlane deploy env:productionShorebird lets you push code updates to Flutter apps without going through App Store review. It's complementary to Fastlane/Codemagic — not a replacement.
- Hot-fixing bugs without waiting for App Store review (1–7 days)
- A/B testing features
- Updating business logic, UI tweaks
- Cannot change native code, assets, or platform channels
# Install Shorebird CLI
curl --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/shorebirdtech/install/main/install.sh -sSf | bash
# Initialize in your Flutter project
shorebird init
# Create a release (instead of flutter build)
shorebird release android
shorebird release ios
# Push a patch (OTA update)
shorebird patch android
shorebird patch ios# In your Fastfile
lane :patch do
sh("shorebird patch ios")
sh("shorebird patch android")
UI.success("OTA patch pushed!")
end- name: Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:
cache: true
- name: Push Shorebird Patch
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
run: shorebird patch android --force🔴 "No code signing identities found"
# Nuke and regenerate
fastlane match nuke development
fastlane match nuke appstore
fastlane match development
fastlane match appstore🔴 "Provisioning profile doesn't include signing certificate"
# Force profile regeneration
fastlane match appstore --force🔴 Match encryption error: "couldn't set additional authenticated data"
- Your
MATCH_PASSWORDis wrong or changed - Re-run
fastlane matchlocally with the correct passphrase - If lost, nuke and start over:
fastlane match nuke
🔴 "No provisioning profile provided" on CI
- Ensure
setup_ciis called (creates temporary keychain) - Check that
APP_IDENTIFIERmatches exactly (including case) - Verify Match repo has profiles for the right bundle ID
🔴 flutter build ipa fails with manual signing
- Use
--no-codesignflag:flutter build ios --release --no-codesign - Let Fastlane
build_app(gym) handle signing separately - Or provide custom
ExportOptions.plist
🟡 CI build hangs on "codesign" step
- Keychain is locked. Ensure
setup_ciruns first - If using custom keychain:
create_keychain( name: "ci_keychain", password: "ci_password", default_keychain: true, unlock: true, timeout: 3600 )
🟡 "Certificate has been revoked"
- Someone manually revoked it in Apple Developer Portal
- Run
fastlane match nukethen regenerate
🔴 "Keystore was tampered with, or password was incorrect"
- Base64 encoding/decoding corrupted the file
- Re-encode:
base64 -i keystore.jks | tr -d '\n'(remove newlines) - Verify:
base64 --decode < encoded.txt > test.jks && keytool -list -keystore test.jks
🔴 "Upload failed: APK specifies a version code that has already been used"
- Bump
versionCodeinpubspec.yaml(1.0.0+2→ the+2is the version code) - Or use the
bump_version_codelane
🟡 "Google Play API access not configured"
- Ensure service account has Release Manager permissions in Play Console
- Wait ~24 hours after creating the service account (propagation delay)
- First upload must be done manually via Play Console
🔴 "Could not find 'fastlane' gem"
bundle install
bundle exec fastlane [lane] # Always use bundle exec🔴 Ruby version conflicts
# Use rbenv or asdf for Ruby version management
rbenv install 3.2.0
rbenv local 3.2.0🟡 Slow Fastlane execution
- Add
skip_docsto your Fastfile:skip_docs - Use
--skip_waiting_for_build_processingfor TestFlight uploads - Cache gems in CI with
bundler-cache: true
🟡 "Lane not found"
- Ensure you're in the right directory (
ios/orandroid/) - Run
bundle exec fastlane lanesto list available lanes
| Repo | Stars | Description |
|---|---|---|
| fastlane/fastlane | 41K+ | Official Fastlane |
| microservicer/flutter-pipelines | 88 | Complete Flutter CI/CD pipeline |
| MTtankkeo/flutter-fastlane-action | 32 | Zero-config GitHub Action |
| dotdoom/fastlane-plugin-flutter | 40 | Official Flutter plugin |
| jtmuller5/Fastfile gist | — | Best combined Fastfile example |
| lukepighetti/nightly-testflight | — | Nightly TestFlight workflow |
| Resource | Link |
|---|---|
| Flutter CD Guide (Official) | flutter.dev/docs/deployment/cd |
| Fastlane Docs | docs.fastlane.tools |
| Fastlane Match Guide | docs.fastlane.tools/actions/match |
| Codemagic Docs | docs.codemagic.io |
| Shorebird Docs | docs.shorebird.dev |
| App Store Connect API | developer.apple.com |
| Google Play API | developers.google.com |
| Article | Source |
|---|---|
| Automate Flutter Deployments to Stores | Constant Solutions (2024) |
| CI/CD in Flutter with Match + GH Actions | Marvel Apps (2025) |
| Production Flutter CI/CD Pipeline | freeCodeCamp |
- Sign up at codemagic.io
- Connect your Git repository
- Add App Store Connect API key in dashboard
- Upload Android keystore in dashboard
- Add Google Play service account JSON
- Create
codemagic.yamlin project root - Push and build
- Install Fastlane (
brew install fastlane) - Create
Gemfilein project root - iOS: App Store Connect
- Generate API key (
.p8file) - Note Key ID and Issuer ID
- Base64-encode the
.p8file
- Generate API key (
- iOS: Match
- Create private Git repo for certificates
- Run
fastlane match initinios/ - Run
fastlane match development - Run
fastlane match appstore - Note the Match passphrase
- iOS: Fastlane
- Create
ios/fastlane/Fastfile - Create
ios/fastlane/Appfile - Test locally:
cd ios && bundle exec fastlane beta
- Create
- Android: Google Play
- Create service account + JSON key
- Grant Release Manager access in Play Console
- Upload first build manually to Play Console
- Android: Keystore
- Generate keystore (
keytool -genkey ...) - Configure
build.gradlesigning - Create
key.properties(local only) - Base64-encode keystore for CI
- Generate keystore (
- Android: Fastlane
- Create
android/fastlane/Fastfile - Create
android/fastlane/Appfile - Test locally:
cd android && bundle exec fastlane internal
- Create
- GitHub Actions
- Add all secrets to repo settings
- Create workflow YAML files
- Push and verify builds
- Optional: Shorebird
- Install CLI
- Run
shorebird init - Add
SHOREBIRD_TOKENto secrets
💡 Pro Tip: Start with Codemagic to ship fast. Migrate to Fastlane + GitHub Actions when you need custom lanes, multi-flavor builds, or fine-grained control. They're not mutually exclusive — you can use Codemagic as the CI runner and still use Fastlane for custom automation.
This document is a living reference. Update it as tools evolve and your workflow matures.