Shipping an iOS release by hand: build, archive, dSYM upload, TestFlight, metadata update, screenshots, release notes, submit. Every step is a separate click. A release takes 3 to 4 hours. When something goes wrong, a day or two disappears.
With Fastlane all of that runs on one command. This is the pipeline I use across 12 apps.
What Fastlane is, briefly
Fastlane is a Ruby-based automation tool for iOS and Android. You define “lanes” in a Fastfile, each lane is a workflow.
Install:
brew install fastlane
# or
gem install fastlaneFastfile (in fastlane/Fastfile at the project root):
platform :ios do
lane :beta do
# TestFlight release
end
lane :release do
# App Store release
end
endRun it: fastlane beta or fastlane release.
My basic beta pipeline
The beta lane from my Fastfile:
lane :beta do
# 1. Git check
ensure_git_status_clean
# 2. Certificates
match(type: "appstore", readonly: true)
# 3. Build
build_app(
scheme: "MyApp",
configuration: "Release",
export_method: "app-store",
output_directory: "./builds",
clean: true
)
# 4. TestFlight upload
upload_to_testflight(
skip_waiting_for_build_processing: true,
changelog: File.read("./CHANGELOG.md")
)
# 5. dSYM upload
upload_symbols_to_crashlytics(
dsym_path: lane_context[SharedValues::DSYM_OUTPUT_PATH],
gsp_path: "./GoogleService-Info.plist"
)
# 6. Slack notification
slack(
message: "Beta build uploaded to TestFlight",
success: true
)
# 7. Git tag
add_git_tag(tag: "beta-#{get_build_number}")
push_git_tags
endThose seven steps take 2 to 3 hours by hand. With Fastlane, fastlane beta is one command, 10 to 15 minutes.
Production release pipeline
lane :release do
# Pre-release checks
ensure_git_branch(branch: "main")
ensure_git_status_clean
# Version bump (minor)
increment_version_number(bump_type: "minor")
increment_build_number
# Build
match(type: "appstore", readonly: true)
build_app(scheme: "MyApp", configuration: "Release")
# Metadata update (App Store Connect)
deliver(
submit_for_review: false,
automatic_release: false,
force: true,
metadata_path: "./fastlane/metadata",
screenshots_path: "./fastlane/screenshots"
)
# Crashlytics dSYM
upload_symbols_to_crashlytics(
dsym_path: lane_context[SharedValues::DSYM_OUTPUT_PATH]
)
# Commit version changes
version = get_version_number(target: "MyApp")
build = get_build_number
commit_version_bump(message: "Release #{version} (#{build})")
add_git_tag(tag: "v#{version}")
push_to_git_remote
# Slack notification
slack(
message: "New version #{version} submitted to App Store",
channel: "#releases"
)
endWith this pipeline every production release is 20 to 30 minutes. The risk of getting it wrong is close to zero.
Match: code signing automation
Fastlane’s most powerful tool is match. Automated certificate and provisioning profile management.
Setup:
fastlane match initYou pick a Git repo (private). Match stores certificates there, encrypted. Everyone on the team uses the same cert.
Usage (Fastfile):
match(type: "appstore", readonly: true)readonly: true doesn’t generate new certs, it uses existing ones. Always readonly in CI.
Without match, certificate management is manual for every developer, onboarding takes 1 to 2 hours. With match, 5 minutes.
Metadata management
Entering metadata (description, keywords, what’s new, screenshots) in App Store Connect for every release is slow.
Fastlane deliver automates it:
fastlane/metadata/
en-US/
description.txt
keywords.txt
release_notes.txt
tr-TR/
description.txt
...
[36 languages]A text file per language. fastlane deliver uploads to App Store Connect.
You update the release notes and run fastlane beta. Metadata updates in every language.
Screenshots automation (snapshot)
The App Store wants 36 languages * 6 screenshots * 3 devices = 648 screenshots. Manually impossible.
Fastlane snapshot:
- Create a UI test target
- Snapfile config: supported devices, languages
- UI tests capture screens
fastlane snapshotgenerates all 648
# Snapfile
devices([
"iPhone 15 Pro",
"iPhone SE (3rd generation)",
"iPad Pro 12.9-inch"
])
languages([
"en-US", "tr-TR", "de-DE", ... # 36 languages
])A one to two hour job. I run it weekly so fresh screenshots are ready before a release.
CI/CD integration
Fastlane runs locally but should be automated in CI/CD:
GitHub Actions example:
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.1'
bundler-cache: true
- name: Run Fastlane
env:
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
run: bundle exec fastlane releasePush a Git tag (git tag v2.3.0 && git push origin v2.3.0) and the release pipeline runs.
Secrets management
The secrets Fastlane needs:
- Apple ID password / App Store Connect API key
- Match password (cert decryption)
- GitHub API token (for git push)
- Firebase API key (for Crashlytics)
- Slack webhook URL
Pass them as environment variables:
env FASTLANE_PASSWORD=xxx MATCH_PASSWORD=yyy fastlane betaGitHub Secrets in CI, .env locally (in gitignore).
Don’t:
– Hard-code secrets in the Fastfile
– Leave secrets in shell history
– Commit a secrets.yml to Git
Do:
– .env.default with dummy values committed, .env with real values gitignored
– Secrets management in CI (GitHub Secrets, AWS Secrets Manager)
– Rotation discipline: quarterly secret rotation
Error handling
What happens when the Fastlane pipeline fails?
error do |lane, exception|
slack(
message: "Fastlane fail: #{exception.message}",
success: false,
channel: "#alerts"
)
endThe error do block runs on failure. Slack alert fires, the team knows immediately.
Also rollback logic:
lane :release do
# ... pipeline steps ...
rescue => e
# Revert the version
git_revert
UI.user_error!("Release failed: #{e}")
endPrevents half-done deploys.
Plugin ecosystem
Fastlane has 400+ plugins. Most popular:
- fastlane-plugin-firebase_app_distribution: Firebase test distribution
- fastlane-plugin-sentry: Sentry dSYM upload
- fastlane-plugin-versioning: advanced version bumping
- fastlane-plugin-badge: add a badge to the icon (beta, staging)
- fastlane-plugin-changelog: changelog generation from git
Install:
fastlane add_plugin sentryCommon pitfalls
1. Fastfile too long. 500 lines is unreadable. Keep lanes modular, extract shared logic to methods.
2. Wrong version bump. Build number must increase monotonically. Sync with git tags by discipline.
3. Typo in metadata. App Store Connect rejects it. Pre-upload validation:
fastlane run deliver --metadata_only --submit_for_review false4. Expired certificates. Match readonly uses them but fails if they’ve expired. Check quarterly.
5. TestFlight beta expiry. Builds expire after 90 days. Add an expiry warning to your lanes.
Bundle versioning
Lock gems in Gemfile:
# Gemfile
source "https://rubygems.org"
gem "fastlane", "~> 2.215.0"Commit the lockfile. Team members and CI use the same Fastlane version, no surprise breaking changes.
Continuous improvement
The pipeline evolves over time. My refinements across 12 apps:
- Initial: basic build + TestFlight
- +1 month: dSYM upload automation
- +2 months: Slack notifications
- +3 months: automated screenshots (snapshot)
- +6 months: multi-environment pipelines (staging, production)
- +12 months: complex metadata management
After every release I find something to add to the pipeline. Continuous improvement mindset.
Bottom line
Fastlane is the gold standard for iOS release automation. One to two days to set up initially, but every release after drops from 3 to 4 hours to 20 to 30 minutes.
Match for code signing, deliver for metadata, snapshot for screenshots, CI/CD integration. All production ready.
Not overkill for a solo developer. Running a 12-app portfolio alone without Fastlane would be impossible. Even a 2 to 3 app portfolio earns the investment back.