If you’ve ever built an iOS app, you’ve probably felt this pain: you open the Apple Developer portal, click through five screens to create a certificate, download a .cer file, double-click it, then go create a provisioning profile, download that, drag it into Xcode, and pray it all still works next time you build on a different machine. Do this across a team, or on a CI server, and it turns into a special kind of hell — expired certificates, “no matching provisioning profile found” errors, and someone’s laptop being the only place that can actually build a release.
I just went through setting this up properly for my Flutter app’s iOS side, using a tool called fastlane match. This post walks through exactly what I did, from zero, so if you’re a complete beginner to fastlane you can follow along and end up with a one-command way to set up code signing on any machine: bundle exec fastlane sync.
The problem, in plain terms
To build and sign an iOS app, Apple requires two things:
- A signing certificate — proves you (or your team) are the one building the app. Lives in your Mac’s Keychain.
- A provisioning profile — a file that ties together your certificate, your App ID, and (for development builds) the specific devices allowed to run the app.
Both are normally created by hand in the Apple Developer portal. The pain points:
- Every developer on a team needs the same certificate and profile installed locally, or they can’t build.
- Certificates expire and need regenerating.
- CI servers need these files too, but can’t “click around a website.”
- If you lose your Mac’s Keychain (or wipe your laptop), you can lose your only copy of the private key and have to revoke and recreate everything.
Fastlane match solves this by generating these certificates/profiles once, encrypting them, and storing them in a private Git repository. From then on, any machine — your laptop, a teammate’s laptop, a CI runner — runs one command to fetch and install them. No more manual clicking, no more “works on my machine.”
What we’re building
By the end of this post you’ll have:
- A
Gemfilethat pins fastlane to a specific version. fastlane/Appfile,fastlane/Matchfile, andfastlane/Fastfile— fastlane’s configuration files.- A separate, private Git repo that holds your encrypted certificates and profiles.
- A single fastlane lane called
syncthat creates-or-fetches everything:bundle exec fastlane sync. - An Xcode project correctly wired to use those synced profiles for Debug, Profile (used for performance testing builds), and Release configurations.
Prerequisites
- A Mac with Xcode installed.
- An Apple Developer account (paid, $99/year — you need this either way to sign real apps).
- Your app’s bundle identifier (e.g.
com.yourcompany.yourapp) and your Apple Developer Team ID (a 10-character code, found in the Apple Developer portal under Membership, or in Xcode’s project settings underDEVELOPMENT_TEAM). - A GitHub (or GitLab/Bitbucket) account, to host the private certificates repo.
Step 1 — Get a working Ruby
Fastlane is a Ruby tool. macOS ships with a Ruby interpreter, but it’s usually ancient (I had 2.6.10 from 2022) and too old for current fastlane. Check yours:
ruby -v
If it’s older than roughly 2.7, you need a newer one. The cleanest way is usually a Ruby version manager like rbenv:
brew install rbenv
rbenv install 3.3.10 # or whatever recent stable version
A real gotcha I hit: compiling Ruby from source via
rbenv installsegfaulted on my machine because I was running a very new macOS/Xcode combo that the Ruby build toolchain didn’t like yet. If that happens to you, don’t burn hours debugging the C compiler — just use a prebuilt Ruby instead. Homebrew’s ownrubyformula ships as a binary, no compilation required:brew install rubyHomebrew installs this “keg-only” (not automatically on your
PATH, since macOS already has its ownruby), so you’ll need to add it yourself. Find the path withbrew --prefix ruby, then add this to your~/.zshrc:export PATH="$(brew --prefix ruby)/bin:$PATH"Open a new terminal tab (or run
source ~/.zshrc) and checkruby -vagain — it should now show the newer version.
Step 2 — Add a Gemfile and install fastlane
In your project’s root folder, create a file named Gemfile (no extension):
source "https://rubygems.org"
gem "fastlane"
Then install it:
bundle config set --local path 'vendor/bundle'
bundle install
The first line tells Bundler (Ruby’s dependency manager) to install gems into a local vendor/bundle folder inside your project instead of system-wide — keeps things self-contained and avoids needing sudo. This will take a minute or two and pull in fastlane plus all its dependencies.
From now on, you always run fastlane as bundle exec fastlane ... — the bundle exec part makes sure you’re using the exact fastlane version pinned in your Gemfile.lock, not whatever random version might be installed elsewhere.
Add this to your project’s .gitignore so the downloaded gems and local secrets never get committed:
# Ruby / fastlane
/vendor/bundle/
/.bundle/
fastlane/.env
fastlane/report.xml
*.mobileprovision
*.cer
*.p12
Step 3 — Create a private repo for your certificates
This is the piece that makes match different from just “generate a cert once.” Match needs somewhere to store the encrypted certificates and profiles so every machine can pull the same ones.
Go to GitHub → New repository → give it a name like ios-certificates → set it to Private → don’t add a README or .gitignore (leave it completely empty) → Create.
Grab its SSH URL — it’ll look like:
[email protected]:your-username/ios-certificates.git
Keep this repo separate from your app’s code. It’s reusable: if you build more iOS apps later, they can all store their certificates in this same repo — match keeps each app’s files in its own subfolder automatically, based on the bundle identifier.
Step 4 — Set up fastlane’s configuration files
Inside your project, create a fastlane/ folder with three files.
fastlane/Appfile — tells fastlane which app and Apple account you’re working with:
app_identifier("com.yourcompany.yourapp")
apple_id("[email protected]")
team_id("YOUR_TEAM_ID")
fastlane/Matchfile — tells match where to store/find the encrypted certificates:
git_url("[email protected]:your-username/ios-certificates.git")
storage_mode("git")
type("development") # the default type when none is specified
app_identifier(["com.yourcompany.yourapp"])
username("[email protected]")
fastlane/Fastfile — this is where you define “lanes,” which are just named scripts. We want one lane called sync:
default_platform(:ios)
desc "Sync iOS signing certificates and provisioning profiles from the certificates repo (creates them if missing)"
lane :sync do
match(type: "development")
match(type: "adhoc")
match(type: "appstore")
end
A quick note on why it’s a top-level lane and not nested inside a platform :ios do ... end block: if you nest it, you’d have to run fastlane ios sync. Defining it at the top level lets you run the shorter fastlane sync.
The three match calls cover the three kinds of signing you’ll actually need:
- development — for running debug builds on your own registered test devices.
- adhoc — for distributing test builds to a fixed list of devices outside the App Store (e.g. via Firebase App Distribution or TestFlight-adjacent tools).
- appstore — for archiving a release build to submit to the App Store / TestFlight.
Step 5 — Set an encryption passphrase
Match encrypts everything it stores in the certificates repo using a passphrase you choose. This passphrase is the single most important secret in this whole setup — write it down somewhere safe (a password manager), because if you lose it, you cannot decrypt your own certificates on a new machine (you’d have to nuke and regenerate everything).
Generate a strong random one:
openssl rand -base64 32
Create fastlane/.env (already gitignored above) with:
MATCH_PASSWORD=the-passphrase-you-just-generated
Fastlane automatically loads this file, so you never have to type the passphrase manually, and it never ends up in your shell history or in Git.
Step 6 — Run it
bundle exec fastlane sync
The first time you run this, fastlane will ask you to log into your Apple ID (interactively — you’ll need to enter your password and a 2FA code, since Apple requires it). It’ll then:
- Check the Apple Developer portal for existing valid certificates of each type.
- Create any that are missing.
- Create matching provisioning profiles.
- Install everything into your local Keychain.
- Encrypt copies with your
MATCH_PASSWORDand push them to yourios-certificatesrepo.
If it finishes with a line like All required keys, certificates and provisioning profiles are installed 🙌, you’re done. Peek into your certificates repo on GitHub — you’ll see encrypted files sitting there, organized by type (development/adhoc/appstore) and by your app’s bundle identifier.
From here on, running the exact same command on any machine — a teammate’s laptop, a fresh clone, a CI runner — fetches those same certificates instead of creating new ones. No more “which cert do I need” guessing.
Step 7 — Point Xcode at the synced profiles
Running match gets the certificates and profiles onto your machine, but Xcode still needs to be told to actually use them for each build type, instead of trying to auto-manage signing itself.
Open your project’s build settings (or edit project.pbxproj directly, which is what I did) and for each configuration — Debug, Profile, and Release — set:
CODE_SIGN_STYLE→ManualDEVELOPMENT_TEAM→ your team IDPROVISIONING_PROFILE_SPECIFIER→ the name match generated, which always follows the patternmatch <Type> <bundle identifier>
So for our example bundle ID, that’s:
| Build config | Provisioning profile specifier | Code sign identity |
|---|---|---|
| Debug | match Development com.yourcompany.yourapp |
Apple Development (or the legacy alias iPhone Developer) |
| Profile | match Development com.yourcompany.yourapp |
Apple Development |
| Release | match AppStore com.yourcompany.yourapp |
Apple Distribution (or the legacy alias iPhone Distribution) |
Two easy-to-miss details that will bite you if you skip them:
- Profile builds need a development profile, not an App Store one. “Profile” mode in Xcode/Flutter is for running performance-profiling builds on a real, connected device — it’s not a distribution build — so it needs the same kind of profile as Debug.
- The code signing identity has to match the profile’s category. A development profile needs a development-type certificate identity, and a distribution profile (App Store / ad-hoc) needs a distribution-type identity. If your Release config was previously using a development identity by default (very easy to end up with, since Xcode’s project template defaults everything the same way), switching only the profile and leaving the identity behind will fail — you have to change both together. You can check which identities are actually in your Keychain with:
That’ll show you exactly what match installed, e.g.security find-identity -v -p codesigning"Apple Distribution: Your Name (TEAMID)".
Step 8 — Verify it actually resolves correctly
Before trying a real archive, sanity check that Xcode picked everything up correctly:
cd ios
xcodebuild -showBuildSettings -project Runner.xcodeproj -target Runner -configuration Release \
| grep -E "CODE_SIGN_IDENTITY|CODE_SIGN_STYLE|PROVISIONING_PROFILE_SPECIFIER|DEVELOPMENT_TEAM"
You want to see something like:
CODE_SIGN_IDENTITY = Apple Distribution
CODE_SIGN_STYLE = Manual
DEVELOPMENT_TEAM = YOUR_TEAM_ID
PROVISIONING_PROFILE_SPECIFIER = match AppStore com.yourcompany.yourapp
If that all lines up, an actual Product → Archive in Xcode (or fastlane driving it later) should sign without a single manual prompt.
What you get out of this
- Onboarding a new machine (or teammate) is one command, not an afternoon in the Apple Developer portal.
- CI can build signed release binaries without a human ever touching a certificate.
- Nothing sensitive lives in your app’s Git history — the certificates live encrypted in a separate, dedicated repo, and the decryption passphrase lives only in a local, gitignored
.envfile. - One certificates repo can serve multiple apps going forward, since match organizes everything by bundle identifier automatically.
Where this goes next
This is the foundation, not the whole pipeline. The natural next steps from here are:
- A fastlane lane that builds the release IPA and uploads it straight to Firebase App Distribution or TestFlight, so testers get new builds without you manually exporting and uploading anything.
- Wiring the same
synclane into a CI workflow (GitHub Actions, etc.) so pull requests or tags can trigger fully automated signed builds. - Doing the equivalent setup on the Android side (keystore management is simpler, but still worth automating the same way).
But even on its own, this one sync lane already deletes the single most annoying, most manual, most “wait, which cert do I need again” part of iOS development. If you only automate one thing about mobile release engineering, make it this.