iOS code signing is a nightmare. Provisioning profiles expire, certificates get revoked, new teammates can't build, CI machines lose access — and Apple's developer portal UI makes all of it worse. Fastlane solves this with two tools: match for signing identity management and gym for building. Together, they turn a fragile manual process into a deterministic, automated pipeline.
The Code Signing Problem
Every iOS build requires two things: a signing certificate (.p12) and a provisioning profile (.mobileprovision). The certificate proves identity. The profile ties that identity to an app ID, entitlements, and (for development) specific device UDIDs.
Without Fastlane, teams hit these problems constantly:
- Developer A creates the distribution certificate. Developer B can't sign release builds because they don't have the private key
- Someone clicks “Reset” in the developer portal and invalidates every existing profile
- CI machines need manually exported .p12 files and profiles, often stored insecurely
- Profiles expire every 12 months. Someone has to remember to renew them
match eliminates all of this by storing certificates and profiles in a shared, encrypted Git repository. Every developer and CI machine pulls from the same source of truth.
Setting Up From Scratch
Step 1: Install Fastlane
# Using Bundler (recommended for reproducible builds)
# Create a Gemfile in your project root
cd ~/Projects/YourApp
cat > Gemfile <<'EOF'
source "https://rubygems.org"
gem "fastlane"
EOF
bundle install
# Initialize Fastlane
bundle exec fastlane initDuring init, choose Option 4: Manual setup. This creates the fastlane/ directory with an Appfile and Fastfile. Don't let it auto-detect — you want full control.
Step 2: Configure the Appfile
# fastlane/Appfile
app_identifier("com.yourcompany.yourapp")
apple_id("[email protected]")
team_id("ABCDE12345") # Apple Developer Team ID
itc_team_id("123456789") # App Store Connect Team IDStep 3: Create the Private Git Repository
Before initializing match, create a dedicated private repository for certificates. This is separate from your app repo:
# On GitHub, GitLab, or Bitbucket
# Create: yourcompany/ios-certificates (private)
# If using GitHub CLI:
gh repo create yourcompany/ios-certificates --private --confirmThis repo will hold encrypted certificates and profiles. We'll cover the security implications in depth later.
Step 4: Initialize match
# Initialize match configuration
bundle exec fastlane match initChoose git as the storage mode when prompted. Enter your certificates repo URL. This creates fastlane/Matchfile:
# fastlane/Matchfile
git_url("[email protected]:yourcompany/ios-certificates.git")
# Use SSH for CI (no interactive password prompts)
# For HTTPS, set MATCH_GIT_BASIC_AUTHORIZATION env var instead
storage_mode("git")
# The encryption password for the repo
# Set via MATCH_PASSWORD env var in CI (never commit this)
type("appstore") # default type
app_identifier(["com.yourcompany.yourapp"])
# Optional: specific branch for different teams/environments
# git_branch("team-alpha")Step 5: Generate Certificates and Profiles
# Generate development certificate + profile
bundle exec fastlane match development
# Generate App Store distribution certificate + profile
bundle exec fastlane match appstore
# Generate Ad Hoc distribution (for TestFlight alternatives, Firebase, etc.)
bundle exec fastlane match adhocOn first run, match will:
- Ask for an encryption passphrase (save this — it's your master key)
- Connect to the Apple Developer Portal via your Apple ID
- Create a new signing certificate and private key
- Create the provisioning profile
- Encrypt everything with OpenSSL AES-256-CBC
- Commit the encrypted files to your certificates repo
- Install the certificate in your local Keychain and profile in
~/Library/MobileDevice/Provisioning Profiles/
After this, your certificates repo looks like:
ios-certificates/
├── certs/
│ ├── development/
│ │ └── ABCDEF1234.p12 # encrypted
│ └── distribution/
│ └── FEDCBA4321.p12 # encrypted
├── profiles/
│ ├── development/
│ │ └── Development_com.yourcompany.yourapp.mobileprovision # encrypted
│ ├── appstore/
│ │ └── AppStore_com.yourcompany.yourapp.mobileprovision
│ └── adhoc/
│ └── AdHoc_com.yourcompany.yourapp.mobileprovision
├── match_version.txt
└── README.mdSetting Up gym for Builds
gym (aliased as build_app) wraps xcodebuild with sane defaults, automatic code signing resolution, and cleaner output. Configure it in a Gymfile or directly in the Fastfile:
# fastlane/Gymfile
scheme("YourApp")
workspace("YourApp.xcworkspace") # or project: "YourApp.xcodeproj"
output_directory("./build")
output_name("YourApp")
export_method("app-store") # app-store | ad-hoc | development
clean(true)
# Automatic code signing via match
export_options({
signingStyle: "manual",
provisioningProfiles: {
"com.yourcompany.yourapp" => "match AppStore com.yourcompany.yourapp"
}
})The Complete Fastfile
Here's a production-ready Fastfile that ties match and gym together with proper lane structure:
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
#---------------------------------------
# Signing
#---------------------------------------
desc "Sync development certificates"
lane :sync_dev do
match(
type: "development",
readonly: is_ci, # CI should never create new certs
force_for_new_devices: true
)
end
desc "Sync App Store certificates"
lane :sync_appstore do
match(
type: "appstore",
readonly: is_ci
)
end
#---------------------------------------
# Testing
#---------------------------------------
desc "Run unit and UI tests"
lane :test do
scan(
scheme: "YourApp",
devices: ["iPhone 15 Pro"],
clean: true,
code_coverage: true,
output_types: "html,junit"
)
end
#---------------------------------------
# Builds
#---------------------------------------
desc "Build for App Store"
lane :build_release do
# Ensure signing is current
match(type: "appstore", readonly: true)
# Increment build number (uses current timestamp)
increment_build_number(
build_number: Time.now.strftime("%Y%m%d%H%M")
)
# Build the IPA
gym(
scheme: "YourApp",
export_method: "app-store",
clean: true,
include_bitcode: false,
export_options: {
signingStyle: "manual",
provisioningProfiles: {
"com.yourcompany.yourapp" =>
ENV["sigh_com.yourcompany.yourapp_appstore_profile-name"]
}
}
)
end
desc "Build Ad Hoc for internal testing"
lane :build_adhoc do
match(type: "adhoc", readonly: true)
gym(
scheme: "YourApp",
export_method: "ad-hoc",
output_name: "YourApp-AdHoc"
)
end
#---------------------------------------
# Distribution
#---------------------------------------
desc "Ship to TestFlight"
lane :beta do
build_release
pilot(
skip_waiting_for_build_processing: true,
skip_submission: true,
changelog: "Internal beta build"
)
# Notify the team
slack(
message: "New beta uploaded to TestFlight!",
success: true
) if ENV["SLACK_URL"]
end
desc "Ship to App Store"
lane :release do
# Run tests first
test
# Build
build_release
# Upload to App Store Connect
deliver(
submit_for_review: false, # manual review submission
force: true, # skip HTML report confirmation
automatic_release: false
)
end
#---------------------------------------
# Maintenance
#---------------------------------------
desc "Nuke and recreate all certificates (emergency only)"
lane :nuke_and_rebuild do
match_nuke(type: "development")
match_nuke(type: "distribution")
match(type: "development", force: true)
match(type: "appstore", force: true)
match(type: "adhoc", force: true)
end
#---------------------------------------
# Error Handling
#---------------------------------------
error do |lane, exception|
slack(
message: "Fastlane failed: #{exception.message}",
success: false
) if ENV["SLACK_URL"]
end
endCI/CD Integration (GitHub Actions)
Here's a production GitHub Actions workflow. The critical parts are secrets management and Keychain handling:
# .github/workflows/ios-release.yml
name: iOS Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: macos-14
timeout-minutes: 30
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_AUTH }}
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 }}
steps:
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
- name: Create Keychain
run: |
security create-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
- name: Build and Upload
run: bundle exec fastlane beta
env:
MATCH_KEYCHAIN_NAME: build.keychain
MATCH_KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: app-ipa
path: build/*.ipaRequired GitHub secrets:
MATCH_PASSWORD— The encryption passphrase from match initMATCH_GIT_AUTH— Base64-encodedusername:PATfor HTTPS access to the certificates repoKEYCHAIN_PASSWORD— Any random string for the temporary CI keychainASC_KEY_ID,ASC_ISSUER_ID,ASC_KEY_CONTENT— App Store Connect API key (avoids Apple ID 2FA issues on CI)
# Generate MATCH_GIT_AUTH value:
echo -n "your-github-username:ghp_your_personal_access_token" | base64Security: The Match Repository
Your certificates repo contains your signing identity. If it leaks, an attacker can sign malicious builds as your company. Here's the security model and how to harden it.
What match Encrypts
Match uses OpenSSL AES-256-CBC to encrypt every .p12 and .mobileprovision file before committing. The encryption key is derived from MATCH_PASSWORD via PBKDF2. The Git repository only ever contains ciphertext. Even if someone gains read access to the repo, they cannot extract usable certificates without the passphrase.
What this means in practice:
- The
.p12files contain your private signing key — this is the crown jewel. With it, anyone can sign binaries as your team - The provisioning profiles are less sensitive (they're essentially signed XML from Apple) but still contain your app ID, team ID, and entitlements
- The encryption is only as strong as your
MATCH_PASSWORD
Hardening the Certificates Repo
1. Repository access control:
- Make the repo private (obvious, but verify it)
- Restrict access to only developers who need to sign builds
- On GitHub, use a machine user (bot account) with a fine-grained PAT scoped to only the certificates repo. Don't use a developer's personal token
- Enable branch protection on
main— no force pushes, require PR reviews for any changes
2. Use SSH deploy keys instead of PATs:
# Generate a dedicated deploy key for the certificates repo
ssh-keygen -t ed25519 -C "fastlane-match" -f ~/.ssh/match_deploy_key -N ""
# Add the public key as a deploy key on the certificates repo (read-only)
# Add the private key as a CI secret
# In Matchfile, use SSH URL:
# git_url("[email protected]:yourcompany/ios-certificates.git")Deploy keys are scoped to a single repo. If compromised, the blast radius is limited to that one repository, not your entire GitHub organization.
3. Passphrase strength:
# Generate a strong MATCH_PASSWORD
openssl rand -base64 32
# Output: something like K8xQ3m7v+RjN2pL0wF5YbHc9T1uA6sDf4gEi=
# Store it in a secrets manager (1Password, Vault, AWS Secrets Manager)
# Never in plain text, never in Slack, never in .env files committed to git4. Audit and rotation:
- Enable Git audit logs on the certificates repo. Know who cloned it and when
- Rotate the
MATCH_PASSWORDannually. Runmatch change_passwordto re-encrypt all files with the new passphrase - When a team member leaves, rotate both the match password and the Apple distribution certificate. Run
match nuke+matchto regenerate - Periodically review who has access to the repo and revoke stale permissions
# Rotate the encryption password
bundle exec fastlane match change_password
# This re-encrypts every file in the repo with the new password.
# Update the MATCH_PASSWORD secret everywhere (CI, team vault).
# Nuclear option: revoke everything and start fresh
bundle exec fastlane match nuke type:development
bundle exec fastlane match nuke type:distribution
bundle exec fastlane match development
bundle exec fastlane match appstoreAlternative: match with S3 or Google Cloud Storage
If you don't want certificates in Git at all, match supports cloud storage backends. This avoids the clone-and-decrypt model entirely:
# Matchfile with S3 backend
storage_mode("s3")
s3_bucket("your-company-ios-certs")
s3_region("us-east-1")
s3_access_key(ENV["AWS_ACCESS_KEY_ID"])
s3_secret_access_key(ENV["AWS_SECRET_ACCESS_KEY"])
# Files are still encrypted with MATCH_PASSWORD
# S3 bucket should have:
# - Versioning enabled
# - Server-side encryption (AES-256 or KMS)
# - Bucket policy restricting access to specific IAM roles
# - No public access (obviously)
# - Access logging enabledWith S3, you get IAM-based access control, CloudTrail audit logs, automatic versioning, and no Git history of encrypted blobs. For security-conscious teams, this is often the better choice.
Match with Multiple Apps and Targets
Most real projects have multiple targets — the main app, a widget extension, a notification service extension, a share extension. Each needs its own profile:
# Matchfile
app_identifier([
"com.yourcompany.yourapp",
"com.yourcompany.yourapp.widget",
"com.yourcompany.yourapp.notification-service",
"com.yourcompany.yourapp.share-extension"
])
# In the Fastfile, gym needs all profiles mapped:
gym(
scheme: "YourApp",
export_method: "app-store",
export_options: {
signingStyle: "manual",
provisioningProfiles: {
"com.yourcompany.yourapp" =>
"match AppStore com.yourcompany.yourapp",
"com.yourcompany.yourapp.widget" =>
"match AppStore com.yourcompany.yourapp.widget",
"com.yourcompany.yourapp.notification-service" =>
"match AppStore com.yourcompany.yourapp.notification-service",
"com.yourcompany.yourapp.share-extension" =>
"match AppStore com.yourcompany.yourapp.share-extension"
}
}
)Onboarding a New Developer
With match, onboarding is exactly three commands:
# New developer joins the team:
# 1. Clone the app
git clone [email protected]:yourcompany/yourapp.git
cd yourapp
# 2. Install dependencies
bundle install
# 3. Pull signing identities (readonly — won't create new certs)
bundle exec fastlane match development --readonly
# They enter the MATCH_PASSWORD when prompted (or set the env var).
# Certificates and profiles are installed. They can build immediately.
# That's it. No Keychain exports, no .p12 files shared over Slack,
# no "ask David, he has the distribution cert on his machine."Troubleshooting
- “No matching provisioning profiles found”: Run
matchwithout--readonlyto create/renew. Then run with--readonlyon CI - “Could not find a matching code signing identity”: The certificate might not be in your Keychain. Run
match developmentormatch appstoreagain - Expired profiles:
matchautomatically renews expired profiles on run. Set up a monthly CI cron job to runmatch developmentandmatch appstore - 2FA issues on CI: Use an App Store Connect API key instead of Apple ID authentication. Create one at
appstoreconnect.apple.com/access/apiand configure it in Fastlane
# Using ASC API key instead of Apple ID (recommended for CI)
# In Fastfile or via environment variables:
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"], # .p8 file contents
is_key_content_base64: true
)Project Structure
Here's what your project should look like when everything is set up:
YourApp/
├── YourApp.xcodeproj/
├── YourApp/
├── fastlane/
│ ├── Appfile # Team + app identifiers
│ ├── Matchfile # Certificate repo + storage config
│ ├── Gymfile # Build settings
│ └── Fastfile # Lane definitions
├── Gemfile # Ruby dependencies (fastlane version pinned)
├── Gemfile.lock # Committed — ensures reproducible runs
└── .github/
└── workflows/
└── ios-release.yml # CI pipelineKey Takeaways
- match stores encrypted certificates and profiles in a shared Git repo or S3 bucket — single source of truth for the entire team
- gym wraps
xcodebuildwith automatic profile resolution and sane defaults - Always use
--readonlyon CI to prevent accidental certificate regeneration - The
MATCH_PASSWORDis your master key — treat it like a production database password - Use SSH deploy keys scoped to the certificates repo, not personal access tokens
- Use App Store Connect API keys on CI to avoid 2FA issues
- Rotate the match password when team members leave, and run
match change_password - For maximum security, use S3 with IAM + KMS instead of Git
- New developer onboarding is literally three commands