---
description: Build and deploy iOS apps from GitLab CI/CD without hosting a macOS runner. Offload the build, signing, and TestFlight upload to Capawesome Cloud.
title: Build and Deploy iOS Apps with GitLab CI/CD - Capawesome
image: https://capawesome.io/docs/assets/images/social/blog/build-and-deploy-ios-apps-with-gitlab-ci-cd.png
---

[ Skip to content](#build-and-deploy-ios-apps-with-gitlab-cicd) 

[ 🔐 Introducing the **Capacitor Vault** plugin — store secrets behind biometrics or a device passcode.](/blog/announcing-the-capacitor-vault-plugin/) 

* [  Formbricks ](/docs/plugins/formbricks/)
* [  Geocoder ](/docs/plugins/geocoder/)
* [  Google Sign-In ](/docs/plugins/google-sign-in/)
* [  Grafana Faro ](/docs/plugins/grafana-faro/)
* [  libSQL ](/docs/plugins/libsql/)
* [  Live Update ](/docs/plugins/live-update/)
* [  Managed Configurations ](/docs/plugins/managed-configurations/)
* [  Media Session ](/docs/plugins/media-session/)
* [  ML Kit ](/docs/plugins/mlkit/)
* [  Navigation Bar ](/docs/plugins/navigation-bar/)
* [  NFC ](/docs/plugins/nfc/)
* [  OAuth ](/docs/plugins/oauth/)
* [  Pedometer ](/docs/plugins/pedometer/)
* [  Photo Editor ](/docs/plugins/photo-editor/)
* [  PostHog ](/docs/plugins/posthog/)
* [  Printer ](/docs/plugins/printer/)
* [  Purchases ](/docs/plugins/purchases/)
* [  RealtimeKit ](/docs/plugins/realtimekit/)
* [  Screen Orientation ](/docs/plugins/screen-orientation/)
* [  Screenshot ](/docs/plugins/screenshot/)
* [  Secure Preferences ](/docs/plugins/secure-preferences/)
* [  Speech Recognition ](/docs/plugins/speech-recognition/)
* [  Speech Synthesis ](/docs/plugins/speech-synthesis/)
* [  Share Target ](/docs/plugins/share-target/)
* [  Square Mobile Payments ](/docs/plugins/square-mobile-payments/)
* [  SQLite ](/docs/plugins/sqlite/)
* [  Superwall ](/docs/plugins/superwall/)
* [  Torch ](/docs/plugins/torch/)
* [  Vault ](/docs/plugins/vault/)
* [  Wifi ](/docs/plugins/wifi/)
* [  Zip ](/docs/plugins/zip/)
* [  Cloud ](/docs/cloud/)
* [  Live Updates ](/docs/cloud/live-updates/)
* Advanced
* Integrations
* [  Native Builds ](/docs/cloud/native-builds/)
* [  Configuration ](/docs/cloud/native-builds/configuration/)
* [  Environments ](/docs/cloud/native-builds/environments/)
* Guides
* [  Sample Projects ](/docs/cloud/native-builds/sample-projects/)
* [  Troubleshooting ](/docs/cloud/native-builds/troubleshooting/)
* [  Automations ](/docs/cloud/automations/)
* [  Assist ](/docs/cloud/assist/)
* Account
* Organizations
* [  Organization and User Management ](/docs/cloud/organizations/memberships/)
* [  Single Sign-On (SSO) ](/docs/cloud/organizations/sso/)
* [  Teams ](/docs/cloud/organizations/teams/)
* [  Two-Factor Authentication ](/docs/cloud/organizations/two-factor-authentication/)
* [  Integrations ](/docs/cloud/integrations/)
* [  License Keys ](/docs/cloud/license-keys/)
* [  Webhooks ](/docs/cloud/webhooks/)
* [  Pricing ](https://capawesome.io/pricing/)
* [  FAQ ](/docs/cloud/faq/)
* [  Support ](/docs/cloud/support/)
* [  Contributing ](/docs/contributing/)
* [  LLMs ](/docs/llms/)
* [  Insiders ](/docs/insiders/)
* [  License ](https://capawesome.io/legal/eula/)
* [  Support ](/docs/insiders/support/)
* [  FAQ ](/docs/insiders/faq/)
* [  Blog ](/blog/)
* Categories

* [  A Simpler Approach: Offload the Build to Capawesome Cloud ](#a-simpler-approach-offload-the-build-to-capawesome-cloud)
* [  Prerequisites ](#prerequisites)
* [  Building the iOS App from GitLab CI/CD ](#building-the-ios-app-from-gitlab-cicd)
* [  Deploying to TestFlight and the App Store ](#deploying-to-testflight-and-the-app-store)
* [  The Complete Pipeline ](#the-complete-pipeline)
* [  Try It Yourself ](#try-it-yourself)
* [  Final Thoughts ](#final-thoughts)

# Build and Deploy iOS Apps with GitLab CI/CD[¶](#build-and-deploy-ios-apps-with-gitlab-cicd "Permanent link")

GitLab CI/CD is happy to build your backend, your web app, and your Android binaries on the shared Linux runners you already have. Then you try to add an iOS build, and the pipeline grinds to a halt: there's no `xcodebuild` on Linux, and suddenly you're shopping for a macOS runner. In this guide, we'll show you how to keep your GitLab pipeline exactly where it is and still ship a signed iOS app to TestFlight and the App Store — without registering, renting, or babysitting a single Mac.

[ ![Build and deploy your Capacitor app with Capawesome Cloud](../../assets/external/cloud.capawesome.io/assets/banners/cloud-build-and-deploy-capacitor-apps.69628c3f.png) ](https://cloud.capawesome.io/) 

## Why iOS Builds Need a macOS Runner[¶](#why-ios-builds-need-a-macos-runner "Permanent link")

Apple's build toolchain is macOS-only. Xcode, `xcodebuild`, `codesign`, the iOS Simulator, and the whole provisioning system run exclusively on Apple hardware, and there's no supported way to produce a real, signed `.ipa` anywhere else. This is true whether you're building a SwiftUI app, a UIKit project, or a cross-platform app — the final archive and signing step always has to happen on a Mac.

That's a problem for GitLab CI/CD, because GitLab's default shared runners run Linux. They'll compile your server, run your tests, and bundle your web assets without complaint, but the moment a job calls `xcodebuild`, it fails. To build iOS, you need a runner that runs macOS — and getting one is where most of the cost and complexity lives.

## Your macOS Runner Options on GitLab[¶](#your-macos-runner-options-on-gitlab "Permanent link")

Before reaching for an external service, it's worth understanding what GitLab itself offers, because both built-in paths come with real trade-offs.

### Option 1: Self-Managed macOS Runners[¶](#option-1-self-managed-macos-runners "Permanent link")

You can register your own Mac as a GitLab Runner. Buy or rent a Mac mini, install macOS, install the GitLab Runner agent, install Xcode, manage certificates and provisioning profiles in the login keychain, and keep all of it updated as Apple ships new OS and Xcode versions. It works, but you now own a build machine: the upfront hardware cost, the security patching, the Xcode upgrades, and the "why did the keychain lock itself again" debugging sessions are all yours.

### Option 2: GitLab Hosted macOS Runners[¶](#option-2-gitlab-hosted-macos-runners "Permanent link")

GitLab.com offers [hosted macOS runners](https://docs.gitlab.com/ci/runners/hosted%5Frunners/macos/) so you don't have to host the hardware yourself. The catch is that they're available only on paid tiers, the macOS minutes are billed at a significantly higher rate than Linux minutes, and you're limited to the Xcode images GitLab provides. You also still write and maintain all of the signing and upload logic yourself — `fastlane` lanes, match or manual provisioning, App Store Connect API keys, and so on. The Mac is managed for you; the iOS release pipeline is not.

Both options put you in the business of operating iOS build infrastructure. There's a third path that keeps GitLab as your orchestrator but hands the macOS work to a service that's built for exactly this.

## A Simpler Approach: Offload the Build to Capawesome Cloud[¶](#a-simpler-approach-offload-the-build-to-capawesome-cloud "Permanent link")

[Capawesome Cloud](https://cloud.capawesome.io/) provides on-demand macOS build infrastructure with an API and a CLI in front of it. Instead of giving GitLab a macOS runner, you keep your jobs on the Linux runners you already have and call the [Capawesome CLI](/docs/cloud/cli/) to start a build. Capawesome Cloud spins up a fresh, sandboxed macOS environment on Apple Silicon M4 Pro hardware, compiles and signs your app, and can upload the result straight to TestFlight or the App Store.

A few things make this a good fit for a GitLab pipeline:

* **No macOS runner to register or maintain.** Your CI jobs run on plain Linux images. The Mac is somebody else's problem.
* **Signing is handled for you.** Upload your distribution certificate and provisioning profiles once into an encrypted vault, then reference them by name — no keychain wrangling in CI.
* **Fast, fresh environments.** Runners are provisioned in seconds and most builds finish in a few minutes, with new Xcode versions available shortly after Apple ships them.
* **Direct store delivery.** A build can go straight to TestFlight or the App Store without a second tool in the loop.

The rest of this guide wires that into a real `.gitlab-ci.yml`.

## Prerequisites[¶](#prerequisites "Permanent link")

Before writing the pipeline, set up a few things once. None of these need to be repeated per build.

First, create a Capawesome Cloud account and an app, then upload your iOS signing assets — your distribution certificate (`.p12`) and the matching provisioning profile — either in the [Console](https://console.cloud.capawesome.io) or with the CLI. See the [iOS certificates guide](/docs/cloud/native-builds/certificates/ios/) for the exact steps. Give the certificate a memorable name; you'll reference it from CI.

Next, configure an [App Store Publishing destination](/docs/cloud/app-store-publishing/destinations/apple-app-store/) so Capawesome Cloud can upload to App Store Connect on your behalf. Using an App Store Connect API key (`.p8`) is the recommended authentication method.

Finally, create a Capawesome Cloud token in the [Console](https://console.cloud.capawesome.io/settings/tokens) and add it to your GitLab project as a [CI/CD variable](https://docs.gitlab.com/ci/variables/) named `CAPAWESOME_CLOUD_TOKEN`. Mark it as **masked** and **protected**. The CLI reads this variable automatically, so there's no separate `login` step in your jobs. It's also handy to store your app ID as a `CAPAWESOME_APP_ID` variable so you don't repeat the UUID everywhere.

## Building the iOS App from GitLab CI/CD[¶](#building-the-ios-app-from-gitlab-cicd "Permanent link")

With the setup done, the build job itself is short. It runs on a standard `node` image — remember, the heavy macOS work happens on Capawesome Cloud, not on the GitLab runner — and all it does is install the CLI and trigger a build.

`[](#%5F%5Fcodelineno-0-1)stages:
[](#%5F%5Fcodelineno-0-2)  - build
[](#%5F%5Fcodelineno-0-3)
[](#%5F%5Fcodelineno-0-4)build-ios:
[](#%5F%5Fcodelineno-0-5)  stage: build
[](#%5F%5Fcodelineno-0-6)  image: node:20
[](#%5F%5Fcodelineno-0-7)  before_script:
[](#%5F%5Fcodelineno-0-8)    - npm install -g @capawesome/cli@latest
[](#%5F%5Fcodelineno-0-9)  script:
[](#%5F%5Fcodelineno-0-10)    - |
[](#%5F%5Fcodelineno-0-11)      npx @capawesome/cli apps:builds:create \
[](#%5F%5Fcodelineno-0-12)        --app-id "$CAPAWESOME_APP_ID" \
[](#%5F%5Fcodelineno-0-13)        --platform ios \
[](#%5F%5Fcodelineno-0-14)        --type app-store \
[](#%5F%5Fcodelineno-0-15)        --certificate "Distribution" \
[](#%5F%5Fcodelineno-0-16)        --git-ref "$CI_COMMIT_SHA" \
[](#%5F%5Fcodelineno-0-17)        --yes
`

The [apps:builds:create](/docs/cloud/cli/#appsbuildscreate) command checks out the commit you point it at with `--git-ref`, builds it on a macOS runner, and signs it with the certificate you named. The `--type` flag selects the build type — use `app-store` for TestFlight and App Store releases, or `ad-hoc`, `development`, `enterprise`, and `simulator` for other distribution needs. By default the command waits for the build to finish and exits non-zero if it fails, so your pipeline correctly turns red on a broken build.

## Deploying to TestFlight and the App Store[¶](#deploying-to-testflight-and-the-app-store "Permanent link")

Building is only half the job — you still need to get the binary in front of testers and users. There are two ways to do this, depending on how much control you want.

The simplest is to let the build deploy itself. Add a `--destination` flag, and Capawesome Cloud uploads the finished build to that destination automatically once the build succeeds.

`[](#%5F%5Fcodelineno-1-1)script:
[](#%5F%5Fcodelineno-1-2)  - |
[](#%5F%5Fcodelineno-1-3)    npx @capawesome/cli apps:builds:create \
[](#%5F%5Fcodelineno-1-4)      --app-id "$CAPAWESOME_APP_ID" \
[](#%5F%5Fcodelineno-1-5)      --platform ios \
[](#%5F%5Fcodelineno-1-6)      --type app-store \
[](#%5F%5Fcodelineno-1-7)      --certificate "Distribution" \
[](#%5F%5Fcodelineno-1-8)      --destination "Apple App Store" \
[](#%5F%5Fcodelineno-1-9)      --git-ref "$CI_COMMIT_SHA" \
[](#%5F%5Fcodelineno-1-10)      --yes
`

That single command takes a commit, builds and signs it on a Mac, and ships it to TestFlight — all from a Linux runner. For many teams, that's the entire release pipeline.

If you'd rather separate building from releasing — for example, to gate the store upload behind a manual approval in GitLab — split the work across two stages. The build job captures the new build's ID into a [dotenv artifact](https://docs.gitlab.com/ci/yaml/artifacts%5Freports/#artifactsreportsdotenv), and a later deploy job reads it back and calls [apps:deployments:create](/docs/cloud/cli/#appsdeploymentscreate).

`[](#%5F%5Fcodelineno-2-1)build-ios:
[](#%5F%5Fcodelineno-2-2)  stage: build
[](#%5F%5Fcodelineno-2-3)  image: node:20
[](#%5F%5Fcodelineno-2-4)  before_script:
[](#%5F%5Fcodelineno-2-5)    - apt-get update && apt-get install -y jq
[](#%5F%5Fcodelineno-2-6)    - npm install -g @capawesome/cli@latest
[](#%5F%5Fcodelineno-2-7)  script:
[](#%5F%5Fcodelineno-2-8)    - |
[](#%5F%5Fcodelineno-2-9)      npx @capawesome/cli apps:builds:create \
[](#%5F%5Fcodelineno-2-10)        --app-id "$CAPAWESOME_APP_ID" \
[](#%5F%5Fcodelineno-2-11)        --platform ios \
[](#%5F%5Fcodelineno-2-12)        --type app-store \
[](#%5F%5Fcodelineno-2-13)        --certificate "Distribution" \
[](#%5F%5Fcodelineno-2-14)        --git-ref "$CI_COMMIT_SHA" \
[](#%5F%5Fcodelineno-2-15)        --json --yes | tee output.json
[](#%5F%5Fcodelineno-2-16)    - echo "BUILD_ID=$(sed -n '/^{/,$p' output.json | jq -r '.id')" >> build.env
[](#%5F%5Fcodelineno-2-17)  artifacts:
[](#%5F%5Fcodelineno-2-18)    reports:
[](#%5F%5Fcodelineno-2-19)      dotenv: build.env
`

The deploy job then runs only when you trigger it, using the `BUILD_ID` passed down from the build stage.

`[](#%5F%5Fcodelineno-3-1)deploy-ios:
[](#%5F%5Fcodelineno-3-2)  stage: deploy
[](#%5F%5Fcodelineno-3-3)  image: node:20
[](#%5F%5Fcodelineno-3-4)  needs:
[](#%5F%5Fcodelineno-3-5)    - build-ios
[](#%5F%5Fcodelineno-3-6)  when: manual
[](#%5F%5Fcodelineno-3-7)  before_script:
[](#%5F%5Fcodelineno-3-8)    - npm install -g @capawesome/cli@latest
[](#%5F%5Fcodelineno-3-9)  script:
[](#%5F%5Fcodelineno-3-10)    - |
[](#%5F%5Fcodelineno-3-11)      npx @capawesome/cli apps:deployments:create \
[](#%5F%5Fcodelineno-3-12)        --app-id "$CAPAWESOME_APP_ID" \
[](#%5F%5Fcodelineno-3-13)        --build-id "$BUILD_ID" \
[](#%5F%5Fcodelineno-3-14)        --destination "Apple App Store"
`

This pattern is worth it when a human should sign off before anything reaches App Store Connect; otherwise the single-command version above is simpler.

## The Complete Pipeline[¶](#the-complete-pipeline "Permanent link")

Putting it together, here's a complete `.gitlab-ci.yml` that builds and releases your iOS app whenever you push a Git tag. Tag-based triggers keep day-to-day commits from burning build minutes while making releases a deliberate, traceable action.

`[](#%5F%5Fcodelineno-4-1)stages:
[](#%5F%5Fcodelineno-4-2)  - release
[](#%5F%5Fcodelineno-4-3)
[](#%5F%5Fcodelineno-4-4)release-ios:
[](#%5F%5Fcodelineno-4-5)  stage: release
[](#%5F%5Fcodelineno-4-6)  image: node:20
[](#%5F%5Fcodelineno-4-7)  before_script:
[](#%5F%5Fcodelineno-4-8)    - npm install -g @capawesome/cli@latest
[](#%5F%5Fcodelineno-4-9)  script:
[](#%5F%5Fcodelineno-4-10)    - |
[](#%5F%5Fcodelineno-4-11)      npx @capawesome/cli apps:builds:create \
[](#%5F%5Fcodelineno-4-12)        --app-id "$CAPAWESOME_APP_ID" \
[](#%5F%5Fcodelineno-4-13)        --platform ios \
[](#%5F%5Fcodelineno-4-14)        --type app-store \
[](#%5F%5Fcodelineno-4-15)        --certificate "Distribution" \
[](#%5F%5Fcodelineno-4-16)        --destination "Apple App Store" \
[](#%5F%5Fcodelineno-4-17)        --git-ref "$CI_COMMIT_SHA" \
[](#%5F%5Fcodelineno-4-18)        --yes
[](#%5F%5Fcodelineno-4-19)  rules:
[](#%5F%5Fcodelineno-4-20)    - if: $CI_COMMIT_TAG
`

To cut a release, you just tag a commit and push — GitLab triggers the pipeline, Capawesome Cloud builds and signs on a Mac, and the result lands in TestFlight a few minutes later. No macOS runner ever entered the picture.

`[](#%5F%5Fcodelineno-5-1)git tag v1.0.0
[](#%5F%5Fcodelineno-5-2)git push origin v1.0.0
`

From here you can adapt the `rules` to match your branching model, add an environment with `--environment` to inject build-time variables and secrets, or pin a specific build stack with `--stack`. The [CLI reference](/docs/cloud/cli/) covers every option.

## Try It Yourself[¶](#try-it-yourself "Permanent link")

If iOS has been the awkward gap in your otherwise tidy GitLab pipeline, this closes it without adding a Mac to your infrastructure. Sign up for free, upload your certificate, and add the job above to your `.gitlab-ci.yml`.

[Try Capawesome Cloud Free](https://capawesome.io)

## Final Thoughts[¶](#final-thoughts "Permanent link")

You don't need to register a self-managed Mac runner or pay a premium for hosted macOS minutes just to ship iOS from GitLab. By keeping GitLab CI/CD as the orchestrator and offloading the macOS build, signing, and store upload to Capawesome Cloud through the CLI, your iOS releases become a few lines of YAML on the Linux runners you already have — and a single `git tag` away.

If you want to see the same workflow from the Console and CLI without the GitLab layer, read [How to Build and Deploy iOS Apps Without Owning a Mac](/blog/how-to-build-and-deploy-ios-apps-without-a-mac/). And if you have questions or want to share your setup, join us on the [Capawesome Discord server](https://discord.gg/VCXxSVjefW) or [subscribe to the Capawesome newsletter](https://capawesome.io/newsletter) to stay up to date.

May 31, 2026 

 Back to top 