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:

  1. A signing certificate — proves you (or your team) are the one building the app. Lives in your Mac’s Keychain.
  2. 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:

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:

Prerequisites

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 install segfaulted 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 own ruby formula ships as a binary, no compilation required:

brew install ruby

Homebrew installs this “keg-only” (not automatically on your PATH, since macOS already has its own ruby), so you’ll need to add it yourself. Find the path with brew --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 check ruby -v again — 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:

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:

  1. Check the Apple Developer portal for existing valid certificates of each type.
  2. Create any that are missing.
  3. Create matching provisioning profiles.
  4. Install everything into your local Keychain.
  5. Encrypt copies with your MATCH_PASSWORD and push them to your ios-certificates repo.

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:

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:

  1. 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.
  2. 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:
    security find-identity -v -p codesigning
    That’ll show you exactly what match installed, e.g. "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

Where this goes next

This is the foundation, not the whole pipeline. The natural next steps from here are:

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.