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.

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.
Danil Ulmashev
Full Stack Developer
Need a senior developer to build something like this for your business?