
Every iOS distribution build needs a valid distribution certificate plus a matching provisioning profile (Apple: Distributing your app for beta testing and releases). On a developer laptop that happens interactively: Xcode asks for Apple ID credentials, downloads the cert, and stores the private key in login.keychain. On a headless CI Mac none of that works. There is no active GUI session, so a security prompt silently blocks codesign, the job hangs, and the build eventually times out with a log that says nothing useful.
A second problem compounds it: when four engineers each manage their own certificates, they generate four separate ones for the same team. Apple caps distribution certificates per account, so teams eventually hit the limit and start revoking each other's certs by accident.
Fastlane Match solves both: it keeps a single encrypted set of certificates and profiles in a shared storage backend, and every machine — developer workstation or headless Figment CI runner — decrypts and installs the same set with no GUI interaction.
Match supports three storage backends per the match documentation: a private Git repository (most common), Google Cloud Storage, or Amazon S3. With Git storage, certificates and private keys are encrypted with OpenSSL using a passphrase before being committed. The passphrase travels separately as the MATCH_PASSWORD environment variable.
What actually lives in the storage repo:
| File type | Path in storage repo |
|---|---|
Distribution certificate (.cer) | certs/distribution/ |
Certificate private key (.p12, encrypted) | certs/distribution/ |
Provisioning profile (.mobileprovision) | profiles/appstore/ or profiles/adhoc/ |
When match runs in CI it clones the repo, decrypts each file with MATCH_PASSWORD, imports the .p12 into a keychain, and copies profiles into ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ on Xcode 16 and later (the pre-16 path was ~/Library/MobileDevice/Provisioning Profiles/, per the match docs).
Provisioning profiles are plain files copied from the repo — they are never exported from Keychain. Only the certificate's private key (.p12) is imported into the keychain.
match initgem install fastlane
cd path/to/your/ios/project
fastlane match init
The prompt asks for the storage type (git) and the private repo URL. It writes fastlane/Matchfile. Edit it so it is explicit:
# fastlane/Matchfile
git_url("https://github.com/YOUR_ORG/YOUR_CERTS_REPO")
git_branch("main")
storage_mode("git")
type("appstore") # default; lanes can override with type: "adhoc"
app_identifier(["com.example.MyApp"])
username("ci-apple-id@example.com")
Then, from a machine that can still authenticate interactively with Apple, generate the first certificate set:
fastlane match appstore
fastlane match adhoc # only if you distribute Ad Hoc builds
Match creates the certificate on Apple's developer portal, encrypts it, and pushes to the storage repo. Every subsequent CI build fetches that set instead of asking Apple for a new one.
This is the artifact you came for. Add the lane below to fastlane/Fastfile. It sets up a disposable keychain, pulls certs in readonly mode, and archives the IPA.
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Build a signed App Store IPA on a headless Mac"
lane :build_ipa do
# 1. Disposable keychain so codesign never shows a GUI prompt.
create_keychain(
name: ENV["MATCH_KEYCHAIN_NAME"],
password: ENV["MATCH_KEYCHAIN_PASSWORD"],
default_keychain: true,
unlock: true,
timeout: 3600,
lock_when_sleeps: false
)
# 2. Pull certs and profiles — never create new ones from CI.
match(
type: "appstore",
readonly: true,
keychain_name: ENV["MATCH_KEYCHAIN_NAME"],
keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"]
)
# 3. Archive. Use your real workspace and scheme names.
gym(
workspace: "YOUR_WORKSPACE.xcworkspace",
scheme: "YOUR_SCHEME",
configuration: "Release",
export_method: "app-store",
output_directory: "build/",
output_name: "MyApp.ipa",
clean: true
)
end
desc "Revoke and regenerate App Store certs — run from a dev machine"
lane :rotate_certs do
match(
type: "appstore",
force: true
)
end
end
readonly: true is not optional in CI. Without it, a transient fetch failure can make match create a new certificate, which both consumes an account slot and can invalidate one a teammate is still using. The match docs call this behaviour out directly.
This file is an example, not a benchmark. Point workspace and scheme at the real values in your project before running — the lane will fail fast if either is wrong, which is what you want.
Four secrets are enough. Never dump the whole environment to a file; that happily serialises API tokens next to the keychain password.
| Secret name | Value |
|---|---|
MATCH_PASSWORD | Passphrase chosen when the storage repo was initialised |
MATCH_KEYCHAIN_NAME | A short name, e.g. ci-build.keychain |
MATCH_KEYCHAIN_PASSWORD | A random strong password for the temporary keychain |
MATCH_GIT_BASIC_AUTHORIZATION | Base64 of username:token with read access to the certs repo |
In GitHub Actions, store each as a repository or organisation secret and expose them to the step that calls Fastlane:
- name: Build signed IPA
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_KEYCHAIN_NAME: ci-build.keychain
MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
run: bundle exec fastlane build_ipa
Secret masking in CI logs applies to the exact secret string. Transformed, trimmed, or base64-decoded substrings can still appear in logs, so never echo a secret and never write env > .env as a debug step.
Apple distribution certificates eventually expire, and sometimes a key is leaked and must be revoked out of cycle. A clean rotation looks like this:
fastlane rotate_certs from a developer machine with Apple portal access, never from CI.force: true revokes the old cert, issues a new one, re-signs the existing profiles, and pushes encrypted replacements to the storage repo.readonly: true, the next pipeline run picks up the new cert automatically. No workflow edits, no secret rotation (the MATCH_PASSWORD is unchanged).If two developers both run match without readonly: true at the same time, they can trigger concurrent certificate creation and revoke each other's work. Keep readonly: true everywhere except the one explicit rotation lane above.
Match is storage-agnostic, so the Mac tier is really a question of how many parallel pipelines you need and how much of the CI setup you want to run yourself.
| Scenario | Figment plan | What you get |
|---|---|---|
| Single developer, occasional releases | M-series Mac, from $95/month (entry price; exact chip, RAM and disk are confirmed in the quote) | One Mac you set up and manage yourself |
| Small team, daily App Store or TestFlight builds | Managed Build Mac, $249/month | One M-series Mac with CI setup, backups, monitoring, and 30 minutes of support per month |
| Multiple parallel signing pipelines | Managed Mac Team, $999/month | Two M-series Macs, two CI runners, team setup, backups, monitoring, and one hour of support per month |
| Fleet, many apps, hard delivery dates | Enterprise quote | Capacity and delivery confirmed in writing |
For a typical iOS team already using Fastlane, Managed Build Mac is the direct recommendation: Figment configures the CI runner, keychain, and Fastlane toolchain on an M-series Mac, so the only work left on your side is wiring the four secrets above into your CI provider. Taxes and any fees apply as stated in the actual offer; physical M-series Macs ship against confirmed availability — ask for a timeline with your quote rather than assuming stock.
Start on Figment Mac hosting →
Related guides: