Code signing Flutter iOS apps in CI
By Charlin Joe · 23 October 2024
Code signing for iOS is the most common reason Flutter CI pipelines fail in ways that work perfectly on a developer's laptop. The laptop has certificates and provisioning profiles installed in the Keychain; the CI runner starts clean every time. This guide closes that gap.
What code signing actually does
Apple requires every app binary to be signed with a certificate that proves identity (who built this) and a provisioning profile that proves authorization (what devices can run this, what capabilities it uses). The private key lives in your Keychain; the certificate and profile live in the Apple Developer portal.
For CI you need to get the private key, certificate, and profile onto the runner without storing secrets in the repo.
Two approaches: Fastlane match vs manual
Fastlane match (recommended)
match stores encrypted certificates and profiles in a private Git repo (or S3/GCS). The CI runner clones that repo, decrypts the credentials, and installs them. Everyone on the team shares the same set.
Setup (one time):
bundle exec fastlane match init
# Choose storage: git
# Enter your private certs repo URL
bundle exec fastlane match appstore
# This generates and stores certificates + profiles
Fastfile:
platform :ios do
lane :beta do
setup_ci # Creates a temporary Keychain on CI
match(
type: 'appstore',
readonly: true, # Never regenerate on CI
git_url: ENV['MATCH_GIT_URL'],
password: ENV['MATCH_PASSWORD'],
app_identifier: 'com.example.myapp',
)
build_app(
workspace: 'ios/Runner.xcworkspace',
scheme: 'production',
export_method: 'app-store',
)
upload_to_testflight(skip_waiting_for_build_processing: true)
end
end
Manual certificate export
If match is too much setup, export the certificate as a .p12 and store it as a base64-encoded secret:
# On your Mac, export the certificate from Keychain Access
openssl pkcs12 -export -out cert.p12 -inkey key.pem -in cert.pem
base64 -i cert.p12 | pbcopy # Copy to clipboard, paste into GitHub secret
In CI, decode and install:
- name: Install certificate
run: |
echo "$IOS_CERT_BASE64" | base64 --decode > cert.p12
security create-keychain -p "" build.keychain
security import cert.p12 -k build.keychain -P "$IOS_CERT_PASSWORD" -T /usr/bin/codesign
security list-keychains -s build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security unlock-keychain -p "" build.keychain
security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
Complete GitHub Actions workflow
name: iOS Release
on:
push:
tags: ['v*']
jobs:
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.22.0'
channel: 'stable'
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Flutter dependencies
run: flutter pub get
- name: iOS pod install
run: cd ios && pod install --repo-update
- name: Build & sign
env:
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
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 }}
run: bundle exec fastlane ios beta
App Store Connect API key (replacing Apple ID auth)
App-specific passwords and Apple ID authentication break on CI. Use an API key instead:
- Go to App Store Connect → Users and Access → Keys
- Create a key with App Manager role
- Download the
.p8file and store its content asASC_KEY_CONTENTsecret
In 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_CONTENT'],
)
Multiple flavors
For apps with dev/staging/production flavors, each flavor usually has a different bundle ID and provisioning profile:
lane :release_production do
setup_ci
match(type: 'appstore', app_identifier: 'com.example.myapp')
build_app(scheme: 'production')
upload_to_testflight
end
lane :release_staging do
setup_ci
match(type: 'adhoc', app_identifier: 'com.example.myapp.staging')
build_app(scheme: 'staging')
firebase_app_distribution(app: ENV['FIREBASE_APP_ID_STAGING'])
end
Common pitfalls
setup_ci missing. Without it, match tries to use the default login Keychain, which doesn't exist on a CI runner. Always call setup_ci first.
Using readonly: false on CI. If the profile is expired and CI tries to regenerate it, it may hit Apple's rate limits or fail with MFA. Set readonly: true and regenerate profiles locally when needed.
Certificate expiry. Apple certificates expire after 1 year. Set a calendar reminder 2 weeks before expiry to run match nuke and recreate. An expired cert on CI silently fails the build.
Xcode version mismatch. The macos-latest runner Xcode version changes without notice. Pin it: xcode-select --install or use the maxim-lobanov/setup-xcode action.
CocoaPods cache miss. pod install takes 2–4 minutes. Cache ~/.cocoapods and ios/Pods keyed on ios/Podfile.lock.
Sign in to like, dislike, or report.