Mobile App Deployment Automation with Fastlane

Deploying a mobile app should not take an entire afternoon. Yet many teams still follow manual processes: bump the version number, update screenshots, build the archive, wait for code signing to cooperate, upload to App Store Connect, fill in release notes, submit for review, then repeat the entire dance for Google Play.

Fastlane eliminates this pain. It is an open-source automation tool that handles everything from code signing to store submission. After setting up Fastlane across dozens of projects, I can tell you that the initial investment of a few hours saves hundreds of hours over a project’s lifetime.

Why Fastlane in 2023

Fastlane has been the de facto mobile deployment tool for years, and its position is stronger than ever. With over 40,000 GitHub stars and active maintenance, it integrates with every major CI/CD platform (GitHub Actions, Bitrise, CircleCI, GitLab CI) and handles both iOS and Android from a single configuration.

The alternatives — manual deployment, Xcode Cloud, and custom scripts — each have limitations. Xcode Cloud is Apple-only and limited in customisation. Custom shell scripts become unmaintainable as complexity grows. Fastlane provides a structured, tested, and well-documented approach that scales with your team.

Setting Up Fastlane

Install Fastlane using Bundler (recommended) to lock versions across your team:

# Gemfile
source "https://rubygems.org"

gem "fastlane", "~> 2.212"
gem "cocoapods", "~> 1.12" # if using CocoaPods
bundle install
bundle exec fastlane init

Fastlane creates a fastlane/ directory with two key files: Appfile (app identifiers) and Fastfile (automation lanes).

Appfile Configuration

# fastlane/Appfile

# iOS
app_identifier("com.au.yourcompany.app")
apple_id("[email protected]")
team_id("ABCDE12345")
itc_team_id("123456789")

# Android
json_key_file("./fastlane/google-play-key.json")
package_name("com.au.yourcompany.app")

iOS Deployment Lane

Her

e is a production-ready iOS deployment lane that handles versioning, code signing, building, and uploading:

# fastlane/Fastfile

platform :ios do
  desc "Deploy a new version to the App Store"
  lane :release do |options|
    # Ensure we are on a clean branch
    ensure_git_status_clean

    # Run tests first
    run_tests(
      scheme: "MyApp",
      devices: ["iPhone 14"],
      clean: true
    )

    # Increment version
    increment_build_number(
      build_number: latest_testflight_build_number + 1
    )

    if options[:bump]
      increment_version_number(bump_type: options[:bump])
    end

    # Handle code signing with Match
    sync_code_signing(
      type: "appstore",
      readonly: is_ci
    )

    # Build the app
    build_app(
      scheme: "MyApp",
      export_method: "app-store",
      output_directory: "./build",
      output_name: "MyApp.ipa",
      clean: true,
      include_bitcode: false
    )

    # Upload to App Store Connect
    upload_to_app_store(
      skip_metadata: false,
      skip_screenshots: true,
      submit_for_review: false,
      automatic_release: false,
      precheck_include_in_app_purchases: false,
      force: true,
      metadata_path: "./fastlane/metadata",
      release_notes: {
        "en-AU" => options[:notes] || changelog_from_git_commits
      }
    )

    # Tag the release
    add_git_tag(
      tag: "ios/v#{get_version_number}"
    )

    # Notify the team
    slack(
      message: "iOS #{get_version_number} submitted to App Store Connect",
      channel: "#releases",
      success: true
    )

    commit_version_bump(
      message: "Bump iOS version to #{get_version_number}"
    )
    push_to_git_remote
  end

  desc "Deploy to TestFlight for beta testing"
  lane :beta do
    sync_code_signing(type: "appstore", readonly: is_ci)

    increment_build_number(
      build_number: latest_testflight_build_number + 1
    )

    build_app(
      scheme: "MyApp",
      export_method: "app-store"
    )

    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )

    slack(
      message: "New iOS beta build uploaded to TestFlight",
      channel: "#releases"
    )
  end
end

Code Signing with Match

Code signing is the most painful part of iOS deployment. Fastlane’s match tool solves this by storing certificates and profiles in a Git repository or cloud storage:

# fastlane/Matchfile
git_url("https://github.com/yourcompany/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.au.yourcompany.app"])
username("[email protected]")

Initial setup creates and stores the certificates:

bundle exec fastlane match appstore
bundle exec fastlane match development

After initial setup, new team members and CI servers pull certificates automatically. No more “missing provisioning profile” nightmares.

Android Deployment Lane

The An

droid lane handles signing, building, and uploading to Google Play:

platform :android do
  desc "Deploy a new version to Google Play"
  lane :release do |options|
    ensure_git_status_clean

    # Run tests
    gradle(
      task: "test",
      project_dir: "./android"
    )

    # Increment version code
    android_set_version_code(
      version_code: google_play_track_version_codes(
        track: "production"
      ).max + 1,
      gradle_file: "./android/app/build.gradle"
    )

    if options[:version]
      android_set_version_name(
        version_name: options[:version],
        gradle_file: "./android/app/build.gradle"
      )
    end

    # Build the AAB (Android App Bundle)
    gradle(
      task: "bundle",
      build_type: "Release",
      project_dir: "./android",
      properties: {
        "android.injected.signing.store.file" => ENV["KEYSTORE_PATH"],
        "android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
        "android.injected.signing.key.alias" => ENV["KEY_ALIAS"],
        "android.injected.signing.key.password" => ENV["KEY_PASSWORD"]
      }
    )

    # Upload to Google Play
    upload_to_play_store(
      track: options[:track] || "production",
      aab: "./android/app/build/outputs/bundle/release/app-release.aab",
      skip_upload_metadata: false,
      skip_upload_images: true,
      skip_upload_screenshots: true,
      release_status: "draft",
      metadata_path: "./fastlane/metadata/android"
    )

    add_git_tag(tag: "android/v#{android_get_version_name}")

    slack(
      message: "Android #{android_get_version_name} uploaded to Google Play",
      channel: "#releases",
      success: true
    )
  end

  desc "Deploy to internal testing track"
  lane :beta do
    gradle(
      task: "bundle",
      build_type: "Release",
      project_dir: "./android"
    )

    upload_to_play_store(
      track: "internal",
      aab: "./android/app/build/outputs/bundle/release/app-release.aab",
      release_status: "completed"
    )
  end
end

Automating Screenshots

App Store screenshots are a marketing necessity but a maintenance headache. Fastlane’s snapshot (iOS) and screengrab (Android) automate this process:

# fastlane/Snapfile
devices([
  "iPhone 14 Pro Max",
  "iPhone 14",
  "iPhone SE (3rd generation)",
  "iPad Pro (12.9-inch) (6th generation)"
])

languages(["en-AU"])

scheme("MyAppScreenshots")
output_directory("./fastlane/screenshots")
clear_previous_screenshots(true)

Write UI tests that navigate to each screenshot-worthy screen:

class ScreenshotTests: XCTestCase {
    func testScreenshots() {
        let app = XCUIApplication()
        setupSnapshot(app)
        app.launch()

        snapshot("01_HomeScreen")

        app.buttons["Browse Products"].tap()
        snapshot("02_ProductList")

        app.cells.firstMatch.tap()
        snapshot("03_ProductDetail")

        app.buttons["Add to Cart"].tap()
        app.tabBars.buttons["Cart"].tap()
        snapshot("04_ShoppingCart")
    }
}

Run bundle exec fastlane snapshot and Fastlane generates screenshots across all configured devices and languages.

CI/CD Integration with GitHub Actions

Here is a production GitHub Actions workflow for both platforms:

name: Deploy Mobile App

on:
  push:
    tags:
      - 'release/*'

jobs:
  ios-release:
    runs-on: macos-13
    steps:
      - uses: actions/checkout@v3

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.1'
          bundler-cache: true

      - name: Install CocoaPods
        run: bundle exec pod install
        working-directory: ios

      - name: Deploy to App Store
        run: bundle exec fastlane ios release
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}

  android-release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.1'
          bundler-cache: true

      - name: Decode Keystore
        run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks

      - name: Deploy to Google Play
        run: bundle exec fastlane android release
        env:
          KEYSTORE_PATH: ${{ github.workspace }}/android/app/keystore.jks
          KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
          KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
          KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
          SUPPLY_JSON_KEY_DATA: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}

Managing Metadata

Store your App Store and Google Play metadata in version control alongside your code:

fastlane/
  metadata/
    en-AU/
      name.txt
      subtitle.txt
      description.txt
      keywords.txt
      privacy_url.txt
      release_notes.txt
    android/
      en-AU/
        full_description.txt
        short_description.txt
        title.txt
        changelogs/
          default.txt

This approach ensures metadata changes go through code review, are traceable in version control, and deploy automatically with your app updates.

Error Handling and Recovery

Production deployments fail. Network timeouts, expired certificates, and API changes all cause issues. Build resilience into your lanes:

lane :release do
  begin
    # ... deployment steps
  rescue => e
    slack(
      message: "Deployment failed: #{e.message}",
      channel: "#releases",
      success: false
    )
    raise e
  ensure
    # Clean up build artefacts
    clean_build_artifacts
  end
end

For transient failures, Fastlane supports retries on individual actions. For persistent issues, the error messages are clear enough to diagnose and fix quickly.

Getting Started

If you are currently deploying manually, here is your migration path:

  1. Week 1: Install Fastlane, configure Appfile, set up Match for iOS code signing
  2. Week 2: Create beta lanes for both platforms, test with internal builds
  3. Week 3: Create release lanes, automate metadata management
  4. Week 4: Integrate with your CI/CD platform, document the process

The first automated deployment is a revelation. What used to take an afternoon takes minutes, and the consistency eliminates an entire category of human errors. Your team can ship with confidence, every time.


Need help setting up mobile deployment automation? Our team at eawesome builds robust CI/CD pipelines for Australian app development teams.