A practical iOS CI/CD setup in 2026: Fastlane, GitLab CI, and what ships well
All Articles
· iOS DevOps CI/CD Fastlane

A practical iOS CI/CD setup in 2026: Fastlane, GitLab CI, and what ships well

If you want a production-ready iOS release pipeline with Fastlane and GitLab CI, this is a pragmatic setup that balances control, repeatability, and maintenance cost.


Manual iOS release processes usually fail in the same places: build numbers are updated by hand, signing credentials drift across machines, CI behaves differently from local archives, and production releases depend on remembering a sequence of small steps at the end of a long day.

The setup below is a pragmatic way to remove most of that fragility. It’s opinionated, but each opinion is there to reduce one of the failure modes above.

The Stack

  • Fastlane for code signing, building, and uploading
  • GitLab CI/CD for the pipeline runner (self-hosted macOS runners)
  • App Store Connect API keys for authentication (service accounts, not your personal Apple ID)
  • Fastlane Match with a private GitLab repo for certificate and profile storage

Xcode Cloud is a reasonable option for smaller setups or teams that want the most Apple-native workflow possible. But once a project has multiple targets, environment-specific steps, custom signing requirements, or non-Apple dependencies in the pipeline, Fastlane usually gives more control in exchange for more setup. GitHub Actions is also perfectly viable, but GitLab with self-hosted macOS runners is a strong fit when controlling the machine matters more than minimizing operational overhead.

Why GitLab + Self-Hosted Runners

GitLab CI with self-hosted runners has a specific advantage for iOS: you control the machine. That means:

  • Xcode versions are pinned exactly. No surprises when the cloud provider updates.
  • Derived data caches can persist between builds, which often cuts incremental build times substantially.
  • Code signing keychains are pre-configured. No per-build keychain setup overhead.
  • You can run Instruments profiles and device tests on physical hardware attached to the runner.

The tradeoff is maintenance — you’re responsible for updating macOS, Xcode, and the runner software. For teams with frequent iOS builds, a dedicated Apple Silicon machine is often worth it. For lower-volume teams, the maintenance burden may outweigh the benefits.

Code Signing with Match

Code signing is where most manual CI setups fall apart. Certificates expire, provisioning profiles go stale, and the developer who set it all up leaves the company taking their login with them.

Match solves this by storing encrypted certificates and profiles in a git repo. Every machine, every CI runner, authenticates with the same managed set of credentials. When a profile expires, regenerate it once and Match distributes it everywhere automatically.

# Matchfile
git_url("https://gitlab.com/yourgroup/ios-certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["com.yourapp.ios"])

The initial setup is front-loaded, but after that, adding a new developer or CI machine is one command:

fastlane match appstore --readonly

--readonly means the machine fetches credentials but can’t modify them. Your CI runners should always use --readonly. Only designated machines (typically a lead’s local machine) can regenerate.

The Fastfile

Here’s the core of a setup like this:

default_platform(:ios)

platform :ios do

  desc "Run tests"
  lane :test do
    run_tests(
      scheme: "YourApp",
      devices: ["iPhone 16"],
      reset_simulator: true,
      output_directory: "./test-results",
      output_files: "report.junit"
    )
  end

  desc "Build and upload to TestFlight"
  lane :beta do
    setup_ci if ENV['CI']

    api_key = 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"]
    )

    match(type: "appstore", readonly: is_ci)

    version = get_version_number(
      xcodeproj: "YourApp.xcodeproj"
    )

    increment_build_number(
      build_number: latest_testflight_build_number(
        api_key: api_key,
        version: version
      ) + 1
    )

    build_app(
      scheme: "YourApp",
      export_method: "app-store"
    )

    upload_to_testflight(
      api_key: api_key,
      skip_waiting_for_build_processing: true
    )
  end

  desc "Release to App Store"
  lane :release do
    beta  # builds and uploads
    api_key = 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"]
    )

    upload_to_app_store(
      api_key: api_key,
      submit_for_review: true,
      automatic_release: false,
      force: true  # skip the HTML preview confirmation step
    )
  end

end

Using latest_testflight_build_number(version: version) + 1 removes one of the most common sources of release friction: manual build-number management. For a single mainline deploy pipeline, it keeps the build number aligned with the current marketing version without any manual bumping. If you run concurrent deploy jobs, though, you still need to serialize releases or use a different build-number strategy.

GitLab CI: The Pipeline File

stages:
  - test
  - deploy

variables:
  LC_ALL: "en_US.UTF-8"
  LANG: "en_US.UTF-8"

test:
  stage: test
  tags:
    - macos
    - xcode-16
  script:
    - bundle exec fastlane test
  artifacts:
    when: always
    paths:
      - test-results/
    reports:
      junit: test-results/report.junit
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

beta:
  stage: deploy
  tags:
    - macos
    - xcode-16
  script:
    - bundle exec fastlane beta
  environment:
    name: testflight
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: on_success

Pushes to main run tests and then automatically deploy to TestFlight. Merge requests only run tests. App Store releases are a separate manual trigger, which is usually the right default for production apps.

A few GitLab-specific things worth noting:

tags: [macos, xcode-16] routes the job to a runner with those tags. Tagging runners by Xcode version makes it explicit which toolchain the build uses, and it also makes it easier to run multiple runners with different Xcode versions when compatibility testing is needed.

bundle exec fastlane instead of bare fastlane — this ensures Fastlane runs from the project’s Gemfile, not whatever version is installed globally. Version pinning matters, especially in CI, where toolchain drift is an avoidable source of breakage.

JUnit artifacts: GitLab parses the JUnit report and displays test results directly in the merge request UI. This means reviewers see failing tests inline without opening the pipeline log. Small detail, big impact on review quality.

The Variables You Actually Need

Store these in GitLab CI/CD settings (Settings → CI/CD → Variables). Mark all of them as Protected and Masked:

VariableWhere to get it
MATCH_PASSWORDThe passphrase you used when setting up Match
MATCH_GIT_BASIC_AUTHORIZATIONBase64-encoded username:personal_access_token for the certificates repo
APP_STORE_CONNECT_API_KEY_CONTENTThe .p8 file content from App Store Connect
APP_STORE_CONNECT_API_KEY_IDThe Key ID from App Store Connect
APP_STORE_CONNECT_API_ISSUER_IDThe Issuer ID from App Store Connect

The App Store Connect API key (.p8) is the single most important thing to handle correctly. You don’t “rotate” the same private key in place: if you leak it, revoke that key and create a new one. Store it only in CI variables, never in the repository, and never on a shared Slack channel “just this once.”

GitLab’s variable protection is still worth using, because protected variables are only exposed in tightly controlled cases. But it’s not a substitute for reviewing CI changes carefully: if someone runs an unsafe MR pipeline with access to protected resources, malicious job code can still exfiltrate secrets. Protect main, scope secrets to protected refs, and treat CI config changes as sensitive review material.

What This Optimizes For

The main benefit of a setup like this is not just speed. It’s repeatability.

Instead of relying on a developer to remember build numbers, signing state, archive settings, and upload steps, the pipeline turns those into code. TestFlight releases become routine, and production releases become deliberate rather than improvised.

In practice, the payoff usually shows up as fewer release-day mistakes, faster iteration on beta builds, and less institutional knowledge trapped in one person’s machine. Self-hosted runners can also improve build times when caches persist across jobs, though the exact gain depends on the project and hardware.

The cost is operational complexity. Someone still has to maintain the runner, update Xcode, rotate credentials, and keep the pipeline healthy. If the team can’t support that consistently, a more managed CI option may be the better tradeoff.

Two Practical Notes

One design choice that’s worth making early is whether certificates should live in one Match repo or be split per environment. Separate repos add some overhead up front, but they make access control much cleaner when different people need different levels of signing access.

The other practical rule is simple: don’t remove the test step just to speed up the pipeline. A slightly slower pipeline is usually cheaper than a faster path to regression.