Skip to main content
mobileMarch 1, 202611 min read

Automating iOS Deployments: App Store Connect and TestFlight

A complete guide to automating iOS app deployments — from Fastlane setup to TestFlight distribution and App Store submission.

iosfastlanecicd
Automating iOS Deployments: App Store Connect and TestFlight

Every iOS developer has lived through the ritual: open Xcode, bump the build number, select the archive scheme, wait fifteen minutes, click through the Organizer, upload to App Store Connect, wait some more, then manually add testers in TestFlight. Do that twice a week across multiple apps and you start losing hours that should be spent writing code. The worst part is not the time — it is the inconsistency. One forgotten step, one wrong provisioning profile, and the build fails silently or gets rejected by Apple's automated checks hours later.

I automated this entire pipeline for a Flutter-based iOS app and have since applied the same patterns to other projects. The investment pays for itself after the third or fourth release cycle.

Why Manual Deployments Break Down

The manual process has three fundamental problems beyond the obvious time waste.

First, knowledge concentration. When only one person on the team knows how to deploy, they become a bottleneck. Vacations, sick days, or just being in a different timezone means releases stall. Documentation helps, but a 15-step deployment guide is itself a liability — it drifts out of date the moment someone changes a build setting.

Second, human error at the worst time. Releases tend to happen under pressure. A critical bug fix, a deadline from a client, a feature that needs to ship before a competitor. These are exactly the conditions where someone forgets to increment the build number, selects the wrong scheme, or uploads a debug build instead of release.

Third, lack of auditability. When something goes wrong with a release, you want to know exactly what was built, from which commit, with which configuration. Manual processes leave no reliable trail.

Fastlane: The Foundation

Fastlane is the de facto standard for iOS deployment automation, and for good reason. It handles code signing, building, uploading, and metadata management through a Ruby-based DSL that reads like a deployment checklist.

Initial Setup

Setting up Fastlane in an existing project is straightforward.

# Install Fastlane
brew install fastlane

# Initialize in your project directory (for Flutter, run in ios/)
cd ios
fastlane init

During initialization, Fastlane asks what you want to automate. I recommend choosing manual setup and configuring everything explicitly — the automated setup makes assumptions that do not always match your project structure, especially for Flutter apps.

The result is a fastlane/ directory containing an Appfile and a Fastfile.

# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("your@email.com")
itc_team_id("123456789")
team_id("ABCD1234EF")

The Appfile stores your Apple Developer account details. The team IDs are important if your Apple ID belongs to multiple teams — without them, Fastlane will prompt you interactively, which breaks CI.

Code Signing with Match

Code signing is where most iOS automation efforts die. Provisioning profiles, certificates, entitlements — the entire system feels designed to punish automation. Fastlane's match tool solves this by storing your certificates and profiles in a private Git repository or cloud storage, making them accessible to any machine that needs to build.

Setting Up Match

fastlane match init

This creates a Matchfile where you configure your storage backend.

# fastlane/Matchfile
git_url("https://github.com/your-org/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourcompany.yourapp"])

Then generate and store your certificates:

# Generate development certificates and profiles
fastlane match development

# Generate App Store distribution certificates and profiles
fastlane match appstore

Match encrypts everything before pushing to the repository. You set a passphrase during setup that you will need on every machine — including CI.

Why Match Over Manual Signing

The alternative — manually managing certificates through the Apple Developer portal and distributing .p12 files — works until it does not. Common failure modes include:

  • A developer's certificate expires and nobody notices until a release build fails.
  • Someone revokes a certificate while debugging a signing issue, invalidating all profiles that depend on it.
  • A new CI machine needs provisioning and the person who set up the original machine has left the team.

Match eliminates all of these by being the single source of truth. If a certificate expires, you run fastlane match nuke and regenerate. Every machine pulls from the same repository.

CI Code Signing

On CI, you need to handle the macOS Keychain since there is no GUI to unlock it. Fastlane provides helpers for this:

lane :ci_setup do
  create_keychain(
    name: "ci_keychain",
    password: ENV["KEYCHAIN_PASSWORD"],
    default_keychain: true,
    unlock: true,
    timeout: 3600,
    lock_when_sleeps: false
  )

  match(
    type: "appstore",
    keychain_name: "ci_keychain",
    keychain_password: ENV["KEYCHAIN_PASSWORD"],
    readonly: true
  )
end

The readonly: true flag is critical for CI. Without it, match might try to create new certificates if it cannot find existing ones, which will fail without interactive authorization and can revoke existing certificates.

Building with Gym

Gym is Fastlane's build tool. It wraps xcodebuild with sensible defaults and better error output.

lane :build do
  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    output_directory: "./build",
    output_name: "YourApp.ipa",
    clean: true,
    include_bitcode: false,
    export_options: {
      provisioningProfiles: {
        "com.yourcompany.yourapp" => "match AppStore com.yourcompany.yourapp"
      }
    }
  )
end

For Flutter projects, you have two options: run flutter build ipa and handle the output, or use gym directly on the Xcode workspace that Flutter generates. I prefer gym because it gives more control over signing and export options, but both work.

# Flutter-specific build lane
lane :build_flutter do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign")
  end

  gym(
    scheme: "Runner",
    workspace: "Runner.xcworkspace",
    export_method: "app-store",
    clean: false,  # Flutter already built, no need to clean
    include_bitcode: false
  )
end

The --no-codesign flag in the Flutter build step is intentional — we let gym handle signing with the match-managed profiles rather than relying on Xcode's automatic signing.

Uploading to TestFlight with Pilot

Pilot handles TestFlight uploads and beta tester management.

lane :beta do
  ci_setup
  build_flutter

  pilot(
    skip_waiting_for_build_processing: true,
    apple_id: "1234567890",
    distribute_external: false,
    notify_external_testers: false
  )
end

The skip_waiting_for_build_processing flag matters a lot for CI. Without it, Fastlane will poll App Store Connect until Apple finishes processing your build — which can take 15 to 45 minutes. Your CI runner would sit idle that entire time, burning compute credits. Better to upload and let the processing happen asynchronously.

Managing TestFlight Testers

You can also automate tester group management:

lane :distribute_to_testers do
  pilot(
    distribute_external: true,
    groups: ["External Beta Testers"],
    changelog: "Bug fixes and performance improvements",
    notify_external_testers: true
  )
end

For external testers, remember that Apple requires Beta App Review for the first build sent to external groups and whenever you add new external testing groups. This is a manual gate you cannot automate around — plan for a 24-48 hour delay on first external distributions.

App Store Submission with Deliver

Once your app is ready for production, deliver handles the App Store submission.

lane :release do
  ci_setup
  build_flutter

  deliver(
    submit_for_review: true,
    automatic_release: false,
    force: true,  # Skip HTML preview verification
    submission_information: {
      add_id_info_uses_idfa: false
    },
    precheck_include_in_app_purchases: false
  )
end

Deliver can also manage your App Store metadata — screenshots, descriptions, keywords, release notes — all stored as files in your repository.

fastlane/metadata/en-US/
├── description.txt
├── keywords.txt
├── name.txt
├── release_notes.txt
├── subtitle.txt
└── privacy_url.txt

fastlane/screenshots/en-US/
├── iPhone 6.5" Display/
│   ├── 01_home.png
│   ├── 02_detail.png
│   └── 03_settings.png
└── iPad Pro 12.9" Display/
    ├── 01_home.png
    └── 02_detail.png

This is one of the most underrated Fastlane features. Having your App Store listing in version control means you can review metadata changes in pull requests, track when descriptions were updated, and roll back if a copywriter makes an error.

Version Bumping Strategies

Version management is surprisingly contentious. The approach I have settled on uses a combination of semantic versioning for the marketing version and an auto-incrementing build number.

lane :bump_patch do
  increment_version_number(bump_type: "patch")
  increment_build_number
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

lane :bump_minor do
  increment_version_number(bump_type: "minor")
  increment_build_number(build_number: 1)
  commit_version_bump(message: "Bump version to #{get_version_number} (#{get_build_number})")
end

For the build number specifically, I recommend using the CI build number or a timestamp rather than a simple incrementing integer. This avoids conflicts when multiple branches are being built simultaneously.

lane :set_ci_build_number do
  build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
  increment_build_number(build_number: build_number)
end

For Flutter projects, you also need to keep pubspec.yaml in sync with the Xcode project:

lane :sync_flutter_version do
  version = get_version_number
  build = get_build_number
  Dir.chdir("..") do
    sh("sed -i '' 's/^version: .*/version: #{version}+#{build}/' pubspec.yaml")
  end
end

The Complete Fastfile

Here is a production Fastfile that combines everything:

default_platform(:ios)

platform :ios do
  before_all do
    setup_ci if ENV["CI"]
  end

  desc "Setup CI environment"
  lane :ci_setup do
    create_keychain(
      name: "ci_keychain",
      password: ENV["KEYCHAIN_PASSWORD"],
      default_keychain: true,
      unlock: true,
      timeout: 3600,
      lock_when_sleeps: false
    )

    match(
      type: "appstore",
      keychain_name: "ci_keychain",
      keychain_password: ENV["KEYCHAIN_PASSWORD"],
      readonly: true
    )
  end

  desc "Build the Flutter iOS app"
  lane :build_flutter do
    Dir.chdir("..") do
      sh("flutter build ios --release --no-codesign")
    end

    gym(
      scheme: "Runner",
      workspace: "Runner.xcworkspace",
      export_method: "app-store",
      output_directory: "./build",
      clean: false,
      include_bitcode: false
    )
  end

  desc "Deploy to TestFlight"
  lane :beta do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    pilot(
      skip_waiting_for_build_processing: true,
      distribute_external: false
    )

    slack(
      message: "New TestFlight build uploaded!",
      slack_url: ENV["SLACK_WEBHOOK"]
    ) if ENV["SLACK_WEBHOOK"]
  end

  desc "Deploy to App Store"
  lane :release do
    ci_setup if ENV["CI"]
    set_ci_build_number
    build_flutter

    deliver(
      submit_for_review: true,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
  end

  desc "Set build number from CI"
  private_lane :set_ci_build_number do
    build_number = ENV["GITHUB_RUN_NUMBER"] || Time.now.strftime("%Y%m%d%H%M")
    increment_build_number(build_number: build_number)
  end
end

GitHub Actions Integration

The CI workflow ties everything together. Here is a complete GitHub Actions workflow for TestFlight deployment:

name: Deploy to TestFlight

on:
  push:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: macos-14
    timeout-minutes: 45

    steps:
      - uses: actions/checkout@v4

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.27.x'
          channel: stable
          cache: true

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
          working-directory: ios

      - name: Flutter dependencies
        run: flutter pub get

      - name: Deploy to TestFlight
        working-directory: ios
        env:
          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: ${{ secrets.ASC_API_KEY }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        run: bundle exec fastlane beta

App Store Connect API Keys

Notice that I am using API keys rather than Apple ID credentials. This is important for CI — Apple ID authentication requires two-factor authentication, which does not work in non-interactive environments. API keys are created in App Store Connect under Users and Access, and they never expire unless you revoke them.

# In your Appfile or Fastfile
app_store_connect_api_key(
  key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_API_ISSUER_ID"],
  key_content: ENV["APP_STORE_CONNECT_API_KEY"],
  is_key_content_base64: true,
  in_house: false
)

Store the .p8 key file contents as a base64-encoded secret in GitHub Actions. This avoids file path issues and keeps the key out of your repository.

Common Pitfalls and How to Fix Them

Provisioning Profile Mismatches

The most common build failure is a mismatch between the provisioning profile and the entitlements in your app. Symptoms include errors like "no provisioning profile matching" or "code signing entitlements not valid."

The fix is almost always to regenerate your match profiles:

fastlane match nuke appstore
fastlane match appstore

Then verify that your Xcode project's bundle identifier exactly matches what is in your Matchfile and Appfile.

Entitlements Issues

If your app uses push notifications, associated domains, Sign in with Apple, or other capabilities, these must be configured both in the Apple Developer portal and in your Runner.entitlements file. A mismatch between the two causes signing failures that produce misleading error messages.

<!-- ios/Runner/Runner.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>aps-environment</key>
    <string>production</string>
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:yourdomain.com</string>
    </array>
</dict>
</plist>

Build Number Rejection

App Store Connect rejects uploads where the build number is not strictly greater than the previous upload for the same version. If you use CI build numbers, this usually is not a problem. But if you reset your CI or switch CI providers, you can hit this. The fix is to use app_store_build_number to fetch the latest build number and increment from there:

lane :smart_build_number do
  current = app_store_build_number(live: false)
  increment_build_number(build_number: current + 1)
end

Xcode Version Mismatches

Apple regularly drops support for builds made with older Xcode versions. Your CI runner needs to match the Xcode version you develop with. On GitHub Actions, you can specify this:

- name: Select Xcode version
  run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer

Check Apple's documentation for the minimum Xcode version required for App Store submissions — this changes with every major iOS release.

Environment-Specific Configurations

Most apps need different configurations for different environments — different API endpoints, different Firebase projects, different feature flags. Handle this in your Fastfile:

lane :beta_staging do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=staging")
  end
  # ... rest of build and upload
end

lane :beta_production do
  Dir.chdir("..") do
    sh("flutter build ios --release --no-codesign --dart-define=ENV=production")
  end
  # ... rest of build and upload
end

For Firebase, use different GoogleService-Info.plist files per environment and copy the right one during the build:

lane :configure_firebase do |options|
  env = options[:env] || "production"
  sh("cp ../firebase/#{env}/GoogleService-Info.plist ../Runner/GoogleService-Info.plist")
end

Monitoring Build Health

Once your pipeline is running, you want visibility into its health. I add a few things to every deployment pipeline:

Build time tracking — if your builds start taking longer, you want to know before they hit the timeout limit.

Slack notifications — success and failure notifications keep the team informed without anyone needing to watch the CI dashboard.

error do |lane, exception|
  slack(
    message: "Build failed: #{exception.message}",
    success: false,
    slack_url: ENV["SLACK_WEBHOOK"]
  )
end

Artifact retention — keep dSYM files for crash symbolication. Upload them to your crash reporting service automatically:

lane :upload_symbols do
  upload_symbols_to_crashlytics(
    dsym_path: "./build/YourApp.app.dSYM.zip",
    gsp_path: "./Runner/GoogleService-Info.plist"
  )
end

The Return on Investment

Setting up this entire pipeline takes about a day for someone who has done it before, and two to three days for a first-timer — most of the time is spent fighting code signing issues, not writing Fastlane configuration. After that initial investment, every subsequent release goes from a 30-60 minute manual process to a git push.

The real value is not even the time savings. It is the confidence. When deploying is a single command or an automatic trigger, you deploy more often. Smaller deployments mean less risk per release, faster feedback from testers, and quicker iteration on features. The pipeline pays for itself not in hours saved, but in a fundamentally better development workflow.

DU

Danil Ulmashev

Full Stack Developer

Need a senior developer to build something like this for your business?