CI/CD Setup for iOS Applications via Fastlane
Fastlane is the de facto standard for iOS build automation outside the Apple ecosystem. It runs on any CI: GitHub Actions, GitLab CI, Jenkins, Bitrise. Main advantage over Xcode Cloud—complete control over each step and the ability to run the same command locally that CI will execute.
Why Code Signing Is the Main Problem
iOS requires a valid provisioning profile and certificate for any build not in simulator. On local machines they exist in Keychain. On CI server—they do not. Without proper fastlane setup, each build fails with Code Signing Error: No profiles for bundle ID found.
fastlane match solves this: all certificates and profiles are stored encrypted in a separate Git repository (or S3/Google Cloud). On CI—one command fastlane match adhoc downloads and installs the needed profile. Passphrase to match—a secret CI variable.
match workflow:
Git repository (encrypted) ←→ fastlane match ←→ Apple Developer Portal
↓
CI Keychain (temporary)
For teams of 5+ developers, match in readonly mode on CI and normal mode on development machines—standard configuration.
Fastfile Structure
default_platform(:ios)
platform :ios do
before_all do
setup_ci if ENV['CI'] # Creates temporary Keychain on CI
end
lane :test do
run_tests(
scheme: "MyApp",
devices: ["iPhone 15", "iPhone SE (3rd generation)"],
code_coverage: true
)
end
lane :beta do
match(type: "adhoc", readonly: true)
increment_build_number(
build_number: ENV["CI_PIPELINE_ID"] || Time.now.to_i.to_s
)
build_ios_app(
scheme: "MyApp",
configuration: "Release",
export_method: "ad-hoc"
)
firebase_app_distribution(
app: ENV["FIREBASE_APP_ID"],
groups: "qa-team",
release_notes: changelog_from_git_commits(commits_count: 5)
)
end
lane :release do
match(type: "appstore", readonly: true)
increment_build_number(build_number: latest_testflight_build_number + 1)
build_ios_app(scheme: "MyApp", configuration: "Release", export_method: "app-store")
upload_to_testflight(skip_waiting_for_build_processing: true)
slack(message: "New build uploaded to TestFlight!", channel: "#releases")
end
end
setup_ci creates a temporary Keychain within the CI job. Without this, fastlane attempts to open the user Keychain, which is inaccessible on headless CI.
Build Number Management
increment_build_number without arguments reads the current number from Info.plist and increments by 1. But with parallel CI jobs, collisions are possible—two PRs build simultaneously and both get the same number. Solution: use CI_PIPELINE_ID (GitLab) or github.run_number (GitHub Actions) as build number. This guarantees uniqueness.
GitHub Actions Integration
- name: Run Fastlane Beta
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
FIREBASE_TOKEN: ${{ secrets.FIREBASE_CLI_TOKEN }}
run: bundle exec fastlane beta
Runner must be macOS (runs-on: macos-14). Linux runners are not suitable for iOS builds—Xcode runs only on macOS.
Dependency Caching
The longest step—pod install or swift package resolve. On GitHub Actions cache:
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: Pods
key: ${{ runner.os }}-pods-${{ hashFiles('Podfile.lock') }}
Saves 3–8 minutes per run when Podfile.lock is unchanged.
Timeline
Basic Fastlane setup with match, test, and beta lanes on GitHub Actions: 3–5 days. Full configuration with release lane, changelog, Slack notifications, caching, multi-scheme support: 1–2 weeks. Cost calculated individually.







